diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..def1a9ea02 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1570,6 +1570,61 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) return islands; } +std::vector transfer_facet_paint(const indexed_triangle_set &src_mesh, + const TriangleSelector::TriangleSplittingData &src_paint, + const indexed_triangle_set &dst_mesh) +{ + if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_paint.bitstream.empty()) + return {}; + + const TriangleMesh src(src_mesh); + TriangleSelector selector(src); + selector.deserialize(src_paint, false); + + // Every sub-triangle of the source, each carrying its own state - NONE included, so the soup + // covers the whole surface and a foot point always lands on something. One soup rather than one + // tree per state: a foot point has one nearest triangle, and that triangle's state is the answer. + std::vector per_state; + selector.get_facets(per_state); + indexed_triangle_set soup; + std::vector label; + for (size_t st = 0; st < per_state.size(); ++st) { + const indexed_triangle_set &part = per_state[st]; + if (part.indices.empty()) + continue; + const int base = int(soup.vertices.size()); + soup.vertices.insert(soup.vertices.end(), part.vertices.begin(), part.vertices.end()); + for (const stl_triangle_vertex_indices &t : part.indices) { + soup.indices.emplace_back(t[0] + base, t[1] + base, t[2] + base); + label.push_back(uint8_t(st)); + } + } + if (soup.indices.empty()) + return {}; + + const AABBTreeIndirect::Tree3f tree = + AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(soup.vertices, soup.indices); + + std::vector out(dst_mesh.indices.size(), 0); + tbb::parallel_for(tbb::blocked_range(0, dst_mesh.indices.size()), + [&](const tbb::blocked_range &range) { + for (size_t j = range.begin(); j < range.end(); ++j) { + const stl_triangle_vertex_indices &t = dst_mesh.indices[j]; + // The centroid, not a corner: a facet takes one state, and its middle is the point that + // is inside it whatever the triangle looks like. + const Vec3f centroid = (dst_mesh.vertices[size_t(t[0])] + dst_mesh.vertices[size_t(t[1])] + + dst_mesh.vertices[size_t(t[2])]) / 3.f; + size_t hit = 0; + Vec3f foot; + const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(soup.vertices, soup.indices, + tree, centroid, hit, foot); + if (d2 >= 0.f && hit < label.size()) + out[j] = label[hit]; + } + }); + return out; +} + void average_island_scales(std::vector &islands) { if (islands.empty()) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 46097f1207..914176454c 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -578,6 +578,31 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla const Vec3f &patch_center = Vec3f::Zero(), const Vec3f &patch_axis = Vec3f::UnitZ(), const Vec2f *lscm_uv = nullptr); +// Carries a per-facet paint annotation - the colour paint above all - from a mesh onto the mesh a +// bake produced from it: every triangle of `dst_mesh` takes the state painted at its own foot point on +// `src_mesh`, i.e. on the nearest point of the surface it was built from. Returned as one +// EnforcerBlockerType per `dst_mesh` triangle, 0 (NONE) where that foot point carried no paint. +// +// ModelVolume::restore_painting() cannot do this. Its remap pairs a source paint triangle with a +// target triangle that *overlaps* it to within 0.01 mm, which is right for a remesh or a +// simplification - they leave the surface where it was - and useless after a bake, which moves the +// surface by the relief depth. Measured on a model displaced by a tenth of its own size, that remap +// kept a quarter of the colour and left the rest unpainted; this keeps essentially all of it, because +// a foot point is exactly as far away as the relief is deep and the lookup does not care how far that +// is. +// +// The source's *sub*-triangles are what is looked up, not its triangles, so the part of a triangle a +// brush stroke only clipped a corner off keeps its own state rather than rounding to the whole +// triangle's. No distance limit is needed or wanted: the nearest point on the source surface is where +// a baked triangle came from however far the relief carried it, and an unpainted origin reports NONE +// on its own. +// +// Returns an empty vector when `src_paint` is empty or either mesh is - the caller then has nothing to +// carry, which is not the same as "carry nothing". +std::vector transfer_facet_paint(const indexed_triangle_set &src_mesh, + const TriangleSelector::TriangleSplittingData &src_paint, + const indexed_triangle_set &dst_mesh); + // Area-weighted centroid and average normal of a layer's currently painted patch, in mesh-local // coordinates - the same measurements build_texture_displacement() uses to pick its dominant // projection axis. Used by the GUI to anchor the on-canvas "adjust texture placement" gizmo to @@ -771,12 +796,9 @@ struct TextureColorRequest // single facets; an image detail a few facets wide still leaves thousands of pinhead islands // that the slicer's multi-material segmentation cannot digest. 0 turns it off. float min_color_region_mm2 = 0.5f; - // Filled per triangle of the *returned* mesh: the quantize callback's index plus one, or 0 for - // "this triangle takes no colour from the texture". That is the same indexing as the base mesh - // only on the classic path, which moves vertices and leaves the topology alone; the one-run - // pipeline rebuilds the mesh, so nothing recorded against the base mesh - an existing paint mask, - // say - may be carried over by triangle id (see TriangleSelector::remap_painting()). - // The +1 lines up with EnforcerBlockerType, where 0 is NONE ("use the volume's own + // Filled per *base mesh* triangle (the bake is topology-preserving, so this indexes the returned + // mesh too): the quantize callback's index plus one, or 0 for "this triangle takes no colour from + // the texture". The +1 lines up with EnforcerBlockerType, where 0 is NONE ("use the volume's own // filament"): where every palette entry is a filament, these go straight to a TriangleSelector. A // palette with mixes maps each index to the mix's filament slot first (see // GLGizmoTextureDisplacement::palette_filaments()). diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index 465c5b6386..2ea468a76f 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -8,12 +8,12 @@ #include "libslic3r/TextureDisplacement.hpp" #include #include +#include #include "slic3r/GUI/Jobs/Worker.hpp" #include #include "libslic3r/Model.hpp" #include "libslic3r/format.hpp" -#include "libslic3r/Point.hpp" #include "libslic3r/TriangleSelector.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" @@ -27,6 +27,7 @@ #include "slic3r/Utils/UndoRedo.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/TriangleMesh.hpp" +#include namespace Slic3r::GUI { @@ -77,23 +78,6 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) }, color, m_input.volume_to_world, nullptr, &m_stats)); - // Bring the colour that was already painted onto the mesh this bake just built, while still on - // the worker thread. Triangle ids only mean the same thing on both meshes when the bake preserved - // the topology - the classic path moves vertices and nothing else - and the one-run pipeline - // rebuilds the mesh from scratch. Deserializing the old mask against the new one only checks that - // the ids are in range, and a bake almost always *grows* the triangle count, so every stale id - // passes that check and lands on an unrelated face: colour debris where nothing was painted, and - // the earlier pass's colour destroyed rather than kept. remap_painting() transfers it by position - // instead, which is what lets successive bakes accumulate. It is spatial work over both meshes, - // which is why it belongs here and not in finalize(). - if (!m_input.mmu_segmentation.bitstream.empty() && !m_result.empty() && !ctl.was_canceled()) { - if (m_result.its.indices.size() == m_input.base_mesh.indices.size()) - m_carried_mmu = m_input.mmu_segmentation; - else - m_carried_mmu = TriangleSelector::remap_painting(m_input.base_mesh, m_input.mmu_segmentation, - m_result.its, Transform3d::Identity(), {}); - } - // Always finish at 100: this is what closes the notification. Reported even on cancel, where // build_texture_displacement() returns an empty mesh and finalize() commits nothing. ctl.update_status(100, status); @@ -144,39 +128,76 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept { mix.a_percent(), 100 - mix.a_percent() }); }); - // Read while the volume still holds the mesh the paint was recorded against, and after the - // filament slots above - adding one can clamp the paint, and that clamp has to survive. - // Normally process() has already carried the colour onto the baked topology; it only has to - // be redone here when the paint changed after the job was queued, because the snapshot - // process() worked from is then not what the user is looking at. - const bool mmu_changed = volume->mmu_segmentation_facets.timestamp() != m_input.mmu_segmentation_timestamp; - const indexed_triangle_set pre_bake_mesh = mmu_changed ? volume->mesh().its - : indexed_triangle_set{}; - const TriangleSelector::TriangleSplittingData live_mmu = mmu_changed - ? volume->mmu_segmentation_facets.get_data() - : TriangleSelector::TriangleSplittingData{}; + // Whether the bake replaced the triangles or only moved the vertices it was given. The classic + // path is topology-preserving by contract, so every per-triangle annotation on the volume still + // means what it did; the default pipeline refines, cuts and simplifies, so none of them do. + // Both are checked: the flag is what the bake promised, the count is what it delivered. Only + // the triangles matter - the annotations are per facet and reference no vertex index - so a + // vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a + // reason to remap anything. + const bool topology_changed = m_input.options.pipeline_v2 || + m_result.its.indices.size() != m_input.base_mesh.indices.size(); + + // Carry the volume's existing paint - the model's own colours above all - across that rebuild. + // Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that + // no longer existed, which is what scrambled the colours of an imported coloured model even + // with colour switched off on every layer and nothing in the bake that was meant to touch them. + // + // transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants + // a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is + // nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact: + // triangle i is still triangle i, so the paint is already exactly right and carrying it at all + // could only lose detail. + std::array before; + if (topology_changed) { + before[0] = volume->supported_facets.get_data(); + before[1] = volume->seam_facets.get_data(); + before[2] = volume->mmu_segmentation_facets.get_data(); + before[3] = volume->fuzzy_skin_facets.get_data(); + } volume->set_mesh(std::move(m_result)); volume->set_new_unique_id(); volume->calculate_convex_hull(); + if (topology_changed) { + const std::array target{ &volume->supported_facets, &volume->seam_facets, + &volume->mmu_segmentation_facets, + &volume->fuzzy_skin_facets }; + for (size_t ch = 0; ch < target.size(); ++ch) { + const std::vector state = + transfer_facet_paint(m_input.base_mesh, before[ch], volume->mesh().its); + // Nothing was painted in this channel, or there is nothing to paint it onto: either + // way the annotation must not keep describing the mesh that is gone. + if (state.empty()) { + target[ch]->reset(); + continue; + } + TriangleSelector selector(volume->mesh()); + bool any = false; + for (size_t i = 0; i < state.size(); ++i) + if (state[i] != 0) { + selector.set_facet(int(i), EnforcerBlockerType(state[i])); + any = true; + } + if (any) + target[ch]->set(selector); + else + target[ch]->reset(); + } + } + // Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there // rather than replacing it: a triangle the texture does not colour keeps its existing filament, // and one the user never painted at all stays at NONE, which already means "the volume's own // filament". That is what confines the effect to the painted area without having to invent a - // colour for everything outside it. What was already painted comes from m_carried_mmu, which - // process() brought onto this mesh's topology; this bake's own colour goes on top of it, so it - // wins wherever it painted and leaves the rest alone. + // colour for everything outside it. What was already painted is whatever the transfer above + // left in the annotation, so this bake's colour lands on top of it rather than instead of it. if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) { - if (mmu_changed && !live_mmu.bitstream.empty()) - m_carried_mmu = pre_bake_mesh.indices.size() == volume->mesh().its.indices.size() - ? live_mmu - : TriangleSelector::remap_painting(pre_bake_mesh, live_mmu, - volume->mesh().its, - Transform3d::Identity(), {}); TriangleSelector selector(volume->mesh()); - if (!m_carried_mmu.bitstream.empty()) - selector.deserialize(m_carried_mmu, false); + const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data(); + if (!existing.bitstream.empty()) + selector.deserialize(existing, false); for (size_t i = 0; i < m_triangle_color.size(); ++i) { const size_t entry = size_t(m_triangle_color[i]); if (entry > 0 && entry <= filament_of.size() && filament_of[entry - 1] >= 0) @@ -195,7 +216,6 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept // pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old // topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and // then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object(). - const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size(); for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(), [slot](const TextureDisplacementLayer &l) { return l.slot == slot; }); @@ -258,8 +278,6 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol input.layers = volume.texture_displacement_layers; input.options = volume.texture_displacement_options; input.volume_to_world = texture_displacement_volume_to_world(volume); - input.mmu_segmentation = volume.mmu_segmentation_facets.get_data(); - input.mmu_segmentation_timestamp = volume.mmu_segmentation_facets.timestamp(); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp index 9abf26743d..40d7ab7b2b 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp @@ -10,7 +10,6 @@ #include "libslic3r/ObjectID.hpp" #include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TriangleMesh.hpp" -#include "libslic3r/TriangleSelector.hpp" #include "Job.hpp" @@ -35,13 +34,6 @@ struct TextureDisplacementBakeInput // writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU // paint gizmo writes - alongside the displaced geometry. TextureColorSettings color; - // The colour already painted on the volume, indexed against `base_mesh`. Carried here rather than - // read at commit time because the one-run pipeline rebuilds the mesh, and bringing this across to - // the new topology is spatial work (see process()) that has no business on the UI thread. - TriangleSelector::TriangleSplittingData mmu_segmentation; - // What `mmu_segmentation` was taken from, so the commit can tell whether the volume was painted - // while the bake ran and fall back to the live data if it was. - ObjectBase::Timestamp mmu_segmentation_timestamp = 0; // Whether this job pushes its own undo step when it commits. False when the caller has already // taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes // and subdivides first and has to undo as a single action. @@ -68,9 +60,6 @@ private: // Empty unless a layer asked for colour. See TextureColorRequest; finalize() turns these into // filaments. std::vector m_triangle_color; - // m_input.mmu_segmentation brought onto m_result's topology, so finalize() can lay this bake's - // colour over the colour that was already there. Indexed against m_result, not base_mesh. - TriangleSelector::TriangleSplittingData m_carried_mmu; std::function m_on_finished; }; diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 94285b2ac6..ced1b76236 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2105,78 +2105,6 @@ TEST_CASE("Cancelling while flat faces are harvested past the budget stops the d 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); -} - -TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]") -{ - TextureDisplacementLayer gray; - gray.image_data = make_flat_gray_png(128); - CHECK_FALSE(height_texture_has_color(gray)); - - TextureDisplacementLayer color; - color.image_data = make_flat_rgb_png(200, 40, 10); - // Before the image is decoded and after, and whatever the smoothing. - CHECK(height_texture_has_color(color)); - CHECK(decode_height_texture(color).has_color()); - CHECK(height_texture_has_color(color)); - color.smoothing = 0.5f; - CHECK(height_texture_has_color(color)); - - CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); -} - TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colours across", "[TextureDisplacement][Regression]") { @@ -2185,8 +2113,9 @@ TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colou // thing. Taken across by triangle id it lands on unrelated faces - a bake almost always grows the // triangle count, so every stale id passes the only bound check deserialize() makes - which both // scatters colour over surfaces nothing painted and destroys what the first pass laid down. - // TriangleSelector::remap_painting() carries it by position instead, which is what makes successive - // bakes accumulate. See TextureDisplacementBakeJob, which is where the two meet. + // transfer_facet_paint() carries it across by position instead - every triangle of the new mesh + // takes the state painted at its foot point on the old one - which is what makes successive bakes + // accumulate. See TextureDisplacementBakeJob, which is where the two meet. constexpr float side = 20.f; constexpr float refine = 0.5f; constexpr float radius = 4.f; @@ -2264,10 +2193,11 @@ TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colou { const TriangleMesh mesh(second); TriangleSelector selector(mesh); - const TriangleSelector::TriangleSplittingData carried = TriangleSelector::remap_painting( - first, painted_first, second, Transform3d::Identity(), {}); - REQUIRE_FALSE(carried.bitstream.empty()); - selector.deserialize(carried, false); + const std::vector carried = transfer_facet_paint(first, painted_first, second); + REQUIRE(carried.size() == second.indices.size()); + for (size_t i = 0; i < carried.size(); ++i) + if (carried[i] != 0) + selector.set_facet(int(i), EnforcerBlockerType(carried[i])); for (size_t i = 0; i < color2.size(); ++i) if (color2[i] != 0) selector.set_facet(int(i), EnforcerBlockerType(color2[i])); @@ -2292,9 +2222,10 @@ TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colou coloured_first.vertices, coloured_first.indices); // A triangle may carry colour the second bake did not produce only where the first bake's colour - // actually was. Carrying the mask by position can widen that region by a triangle, so the margin - // is the edge length the bake was told to refine to; landing anywhere else means the mask was read - // against a mesh it no longer matches. + // actually was. A foot point sits on the surface the bake started from, which the relief has since + // moved, and the transfer can widen the region by a triangle, so the margin is twice the edge + // length the bake was told to refine to; landing anywhere else means the mask was read against a + // mesh it no longer matches. size_t own = 0, carried_over = 0, stranded = 0; float worst = 0.f; for (size_t i = 0; i < second.indices.size(); ++i) { @@ -2324,3 +2255,75 @@ TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colou CHECK(carried_over > 0); // the first bake's colour survived the second CHECK(stranded == 0); // and nothing was painted where neither bake put colour } + +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); +} + +TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]") +{ + TextureDisplacementLayer gray; + gray.image_data = make_flat_gray_png(128); + CHECK_FALSE(height_texture_has_color(gray)); + + TextureDisplacementLayer color; + color.image_data = make_flat_rgb_png(200, 40, 10); + // Before the image is decoded and after, and whatever the smoothing. + CHECK(height_texture_has_color(color)); + CHECK(decode_height_texture(color).has_color()); + CHECK(height_texture_has_color(color)); + color.smoothing = 0.5f; + CHECK(height_texture_has_color(color)); + + CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); +}