From 18cef4f51d08a7bfdb403094820f6bb6500de087 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 07:57:15 +0200 Subject: [PATCH] Texture a model through its own UV channel, and keep its colours through a bake Importing a model that already carries colours was broken for texture displacement in two ways: the UV layout it was authored against was thrown away and re-unwrapped, and a bake overwrote the colours it arrived with. A model's own UV channel is now kept. ModelVolume::texture_uv holds three uvs per triangle, in the mesh's own triangle order and with V flipped to the convention DecodedHeightTexture samples in. It is filled from a textured OBJ's vt data on import and carried across every stage that replaces the mesh - the colour import's painted mesh, the gizmo's remesh and subdivide, the default pipeline's rebuild - by transfer_uv_channel(), which gives each new corner the uv of its closest point on the surface it came from. A new mapping, TextureProjectionMethod::ModelUV, samples that channel instead of solving an unwrap, and a new layer on a volume that has one starts there. Its uv is already normalized, so it keeps the layer's rotation and offset but not the millimetre tile size (the slider is disabled) or the non-square aspect correction; with no channel it falls back to Triplanar, as LSCM does when a patch will not flatten. A bake with colour off on every layer now leaves the model's colours where they were. The default pipeline rebuilds the topology, which left mmu_segmentation_facets describing a triangle set that no longer existed. ModelVolume::restore_painting() cannot carry it: its remap wants a target triangle overlapping the source paint to within 0.01 mm, and a baked surface has moved by the relief depth. transfer_facet_paint() gives each new facet the state at its own foot point on the pre-bake surface instead, and the bake commit runs it over all four paint channels. The classic path is untouched, since there triangle i is still triangle i. Blend gains an Override entry next to Add: the layer replaces the displacement under it rather than combining with it, which is what the stack's colour already does where two layers overlap. The combo's order is the panel's, not the enum's, so stored blend modes are unaffected. Measured on a textured OBJ of 85224 triangles (uv_color_probe in the bake harness): the channel survives import intact, the relief follows the model's own uv on 98.7% of vertices against 51.3% for a re-unwrap, and a colour-off bake through the default pipeline keeps the colours on 99.8% of the baked surface where it previously kept none. Through a coloured 3mf, 100%. --- .../texture_displacement_map_model_uv.svg | 1 + src/libslic3r/Model.cpp | 22 ++ src/libslic3r/Model.hpp | 25 +- src/libslic3r/TextureDisplacement.cpp | 331 ++++++++++++++--- src/libslic3r/TextureDisplacement.hpp | 118 +++++- src/libslic3r/TexturePainting.cpp | 21 ++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 154 ++++++-- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 29 +- .../GUI/Jobs/TextureDisplacementBakeJob.cpp | 70 +++- .../GUI/Jobs/TextureDisplacementBakeJob.hpp | 3 + .../GUI/Jobs/TextureDisplacementDebugJob.cpp | 9 +- .../GUI/Jobs/TextureDisplacementDebugJob.hpp | 3 + .../Jobs/TextureDisplacementPrepareJob.cpp | 6 +- .../Jobs/TextureDisplacementPrepareJob.hpp | 4 + .../Jobs/TextureDisplacementPreviewJob.cpp | 3 +- .../Jobs/TextureDisplacementPreviewJob.hpp | 3 + tests/libslic3r/test_texture_displacement.cpp | 350 ++++++++++++++++++ 17 files changed, 1046 insertions(+), 106 deletions(-) create mode 100644 resources/images/texture_displacement_map_model_uv.svg diff --git a/resources/images/texture_displacement_map_model_uv.svg b/resources/images/texture_displacement_map_model_uv.svg new file mode 100644 index 0000000000..c303d4e0ac --- /dev/null +++ b/resources/images/texture_displacement_map_model_uv.svg @@ -0,0 +1 @@ + diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 6cca66e44b..a152bef0ae 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -353,6 +353,24 @@ Model Model::read_from_file(const std::string& ObjInfo obj_info; ObjParser::MtlData mtl_data; result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data); + // Keep the model's own UV channel on the volume, whatever the colour import then does with + // the mesh: it is what TextureProjectionMethod::ModelUV maps a texture-displacement layer + // through, so a model that arrives with a UV layout is textured on that layout instead of on + // a fresh unwrap. load_obj() fills obj_info.uvs per face of the mesh it built, in the same + // order (and only when every face named a known material, hence the size check). V is flipped + // on the way in, to the convention DecodedHeightTexture samples in - the same flip + // obj_to_textured_mesh() does for the colour importer. + if (result && !obj_info.uvs.empty() && !model.objects.empty() && !model.objects.back()->volumes.empty()) { + ModelVolume &volume = *model.objects.back()->volumes[0]; + const indexed_triangle_set &its = volume.mesh().its; + if (obj_info.uvs.size() == its.indices.size()) { + volume.texture_uv.resize(its.indices.size() * 3); + for (size_t fi = 0; fi < obj_info.uvs.size(); ++fi) + for (int k = 0; k < 3; ++k) + volume.texture_uv[fi * 3 + size_t(k)] = + Vec2f(obj_info.uvs[fi][size_t(k)].x(), 1.f - obj_info.uvs[fi][size_t(k)].y()); + } + } if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) { // Textured OBJ: hand the mesh + materials to the texture-to-color importer // instead of the flat per-face colour dialog. @@ -2133,6 +2151,10 @@ void ModelVolume::reset_extra_facets() // than left referring to a mesh that no longer matches it. for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) this->texture_displacement_facet(i).reset(); + // Same reasoning for the model's own UV channel: it is indexed by triangle, so against a mesh + // this no longer matches it would map the texture by another mesh's triangle numbering. A caller + // that can carry it across - see transfer_uv_channel() - puts it back after this. + this->reset_texture_uv(); } std::optional ModelVolume::save_painting() const diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 4bb1794b46..37a16283ca 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -974,6 +974,26 @@ public: // they are not a property of any single layer. TextureDisplacementOptions texture_displacement_options; + // The model's own texture coordinates, as the file it was imported from carried them: three per + // triangle of mesh().its, in its triangle order (corner k of triangle i at 3i+k), V already + // flipped to the sampling convention (V = 0 is the image's top row). Empty when the source had + // none, which is every STL and every 3mf - only a textured OBJ brings one in today. + // + // Per corner rather than per vertex because that is what a UV layout is: the two sides of a seam + // are the same vertex with two different coordinates, and collapsing them would smear the texture + // across every island boundary. TextureProjectionMethod::ModelUV maps through it, which is what + // lets a model that already carries a UV channel be textured on the artist's own layout instead + // of on a fresh unwrap. + // + // Indexed by triangle, so anything that *replaces* the mesh has to bring it across (or drop it): + // see transfer_uv_channel(), and reset_texture_uv() for the cases that cannot. + std::vector texture_uv; + + bool has_texture_uv() const { return !texture_uv.empty() && texture_uv.size() == mesh().its.indices.size() * 3; } + // Forgets the channel. For a mesh change that nothing can carry it across - a split, a boolean - + // where leaving it would map the texture by another mesh's triangle numbering. + void reset_texture_uv() { texture_uv.clear(); } + // Save painting data before reset_extra_facets() discards it. // Used for replacing mesh without losing painting data. // Only for model parts (not modifiers/connectors). @@ -1236,6 +1256,7 @@ private: texture_displacement_facets_6(other.texture_displacement_facets_6), texture_displacement_facets_7(other.texture_displacement_facets_7), texture_displacement_layers(other.texture_displacement_layers), texture_displacement_options(other.texture_displacement_options), + texture_uv(other.texture_uv), cut_info(other.cut_info), text_configuration(other.text_configuration), emboss_shape(other.emboss_shape) { assert(this->id().valid()); @@ -1334,7 +1355,7 @@ private: cereal::load_by_value(ar, f); mesh_changed |= tf != f.timestamp(); } - ar(texture_displacement_layers, texture_displacement_options); + ar(texture_displacement_layers, texture_displacement_options, texture_uv); cereal::load_by_value(ar, config); cereal::load(ar, text_configuration); cereal::load(ar, emboss_shape); @@ -1358,7 +1379,7 @@ private: cereal::save_by_value(ar, fuzzy_skin_facets); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) cereal::save_by_value(ar, texture_displacement_facet(i)); - ar(texture_displacement_layers, texture_displacement_options); + ar(texture_displacement_layers, texture_displacement_options, texture_uv); cereal::save_by_value(ar, config); cereal::save(ar, text_configuration); cereal::save(ar, emboss_shape); diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..4700ca952b 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1672,6 +1672,165 @@ std::vector compute_lscm_corner_uvs(const indexed_triangle_set &patch, co return corner; } +// Barycentric weights of `p` inside triangle (a, b, c), as the pair that belongs with the edges +// (b - a) and (c - a): p ~= (1 - w1 - w2) * a + w1 * b + w2 * c. `p` is projected onto the triangle's +// plane, so a point a hair off the surface still reports the weights of its foot point. A degenerate +// triangle reports its centroid, the only coordinate that means anything there. +static void barycentric_weights(const Vec3f &a, const Vec3f &b, const Vec3f &c, const Vec3f &p, float &w1, float &w2) +{ + const Vec3f e0 = b - a, e1 = c - a, ep = p - a; + const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1); + const float den = d00 * d11 - d01 * d01; + if (std::abs(den) <= 1e-20f) { + w1 = w2 = 1.f / 3.f; + return; + } + w1 = (d11 * dp0 - d01 * dp1) / den; + w2 = (d00 * dp1 - d01 * dp0) / den; +} + +std::vector compute_model_corner_uvs(const indexed_triangle_set &base_mesh, + const std::vector &model_uv, + const indexed_triangle_set &patch, + const std::vector &patch_source) +{ + if (patch.indices.empty() || model_uv.size() != base_mesh.indices.size() * 3 || + patch_source.size() != patch.indices.size()) + return {}; + + std::vector corner(patch.indices.size() * 3, Vec2f::Zero()); + for (size_t j = 0; j < patch.indices.size(); ++j) { + const int S = patch_source[j]; + if (S < 0 || size_t(S) >= base_mesh.indices.size()) + continue; // no source triangle: leave (0, 0), which the callers read as "no placement" + const stl_triangle_vertex_indices &src = base_mesh.indices[size_t(S)]; + const Vec3f &a = base_mesh.vertices[size_t(src[0])]; + const Vec3f &b = base_mesh.vertices[size_t(src[1])]; + const Vec3f &c = base_mesh.vertices[size_t(src[2])]; + const Vec2f *uv = &model_uv[size_t(S) * 3]; + const stl_triangle_vertex_indices &tri = patch.indices[j]; + for (int k = 0; k < 3; ++k) { + // An unsplit patch triangle is its source triangle, so its corners land exactly on a, b + // and c and this reproduces their uvs unchanged; a sub-triangle the brush cut gets the + // interpolated coordinate, which is the whole point of going through the barycentric. + float w1 = 0.f, w2 = 0.f; + barycentric_weights(a, b, c, patch.vertices[size_t(tri[k])], w1, w2); + corner[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2]; + } + } + return corner; +} + +std::vector collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector &corner) +{ + if (patch.indices.empty() || corner.size() != patch.indices.size() * 3) + return {}; + + std::vector per_vertex(patch.vertices.size(), Vec2f::Zero()); + std::vector seen(patch.vertices.size(), 0); + for (size_t j = 0; j < patch.indices.size(); ++j) + for (int k = 0; k < 3; ++k) { + const int vi = patch.indices[j][k]; + if (vi < 0 || size_t(vi) >= per_vertex.size() || seen[size_t(vi)]) + continue; + seen[size_t(vi)] = 1; + per_vertex[size_t(vi)] = corner[j * 3 + size_t(k)]; + } + return per_vertex; +} + +std::vector transfer_uv_channel(const indexed_triangle_set &src_mesh, + const std::vector &src_uv, + const indexed_triangle_set &dst_mesh, + float max_distance) +{ + if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_uv.size() != src_mesh.indices.size() * 3) + return {}; + + const AABBTreeIndirect::Tree3f tree = + AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(src_mesh.vertices, src_mesh.indices); + const float limit2 = max_distance >= 0.f ? max_distance * max_distance : std::numeric_limits::max(); + + std::vector out(dst_mesh.indices.size() * 3, Vec2f::Zero()); + 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 &tri = dst_mesh.indices[j]; + for (int k = 0; k < 3; ++k) { + const Vec3f &p = dst_mesh.vertices[size_t(tri[k])]; + size_t hit = 0; + Vec3f foot; + const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + src_mesh.vertices, src_mesh.indices, tree, p, hit, foot); + if (d2 < 0.f || d2 > limit2 || hit >= src_mesh.indices.size()) + continue; // nowhere near the source surface: (0, 0), as documented + const stl_triangle_vertex_indices &st = src_mesh.indices[hit]; + float w1 = 0.f, w2 = 0.f; + barycentric_weights(src_mesh.vertices[size_t(st[0])], src_mesh.vertices[size_t(st[1])], + src_mesh.vertices[size_t(st[2])], foot, w1, w2); + const Vec2f *uv = &src_uv[hit * 3]; + out[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2]; + } + } + }); + return out; +} + +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; +} + namespace { // apply_uv_transform()'s per-layer constants, worked out once. Triplanar sampling runs the transform // three times per point, and recomputing the rotation's cos/sin and the tiling reciprocal on every one @@ -1681,13 +1840,16 @@ struct UVTransform float scale, cs, sn, aspect; Vec2f offset; - UVTransform(const TextureDisplacementLayer &layer, float aspect_) + // `normalized_uv` drops the two steps that only mean something for a coordinate measured on the + // model - the millimetre tiling scale and the non-square aspect correction - and keeps the + // rotation and offset. See apply_uv_transform(); TextureProjectionMethod::ModelUV is what needs it. + UVTransform(const TextureDisplacementLayer &layer, float aspect_, bool normalized_uv = false) { - scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; + scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale); const float rad = layer.rotation_deg * float(M_PI) / 180.f; cs = std::cos(rad); sn = std::sin(rad); - aspect = aspect_; + aspect = normalized_uv ? 1.f : aspect_; offset = layer.offset; } Vec2f operator()(const Vec2f &planar) const @@ -1712,9 +1874,11 @@ inline Vec3f triplanar_weights(const Vec3f &normal) } } // namespace -Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect) +Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv) { - const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; + // A uv that is already normalized has no millimetre scale to divide by and no aspect to correct - + // see the header. UVTransform does the same, and the two must stay in step. + const float scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale); const Vec2f scaled = planar * scale; const float rad = layer.rotation_deg * float(M_PI) / 180.f; @@ -1732,7 +1896,7 @@ Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &la // Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a // shear, and doing it the other way round would make "Rotation" skew the pattern instead of // turning it. - if (aspect > 0.f && aspect != 1.f) + if (!normalized_uv && aspect > 0.f && aspect != 1.f) rotated.y() *= aspect; return rotated + layer.offset; @@ -1757,6 +1921,9 @@ float blend_displacement(float accumulated, float value, TextureBlendMode mode) value; return accumulated / divisor; } + // Replaces instead of combining, so `accumulated` is deliberately unused: whatever the layers + // below put here is gone where this one is painted. + case TextureBlendMode::Override: return value; case TextureBlendMode::Add: default: return accumulated + value; } @@ -1786,14 +1953,21 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp // apply_uv_transform()). Every projection except the projective "from view" one funnels through // here, so this one line is what makes them all aspect-correct. const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; - const UVTransform xf(layer, aspect); + // A ModelUV layer's precomputed uv is the model's own, already normalized; every other coordinate + // reaching the transform - the unwrap's, and every analytic projection's - is in millimetres. The + // `lscm_uv` test is what keeps the two apart: a ModelUV layer with no channel gets none, falls + // through to the millimetre methods below, and must be scaled like them. + const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV && + lscm_uv != nullptr; + const UVTransform xf(layer, aspect, normalized_uv); auto sample_at = [&](const Vec2f &planar) { return texture.sample(xf(planar), layer.tile_enabled, layer.tile_method); }; - // Precomputed per-patch LSCM solve wins over the layer's own method (see the header): the - // caller only passes it when the patch actually parameterized successfully, so a patch that - // failed to unwrap falls through to the analytic methods below as its documented fallback. + // A precomputed per-patch uv - the LSCM solve, or the model's own channel - wins over the layer's + // own method (see the header): the caller only passes it when the patch actually has one, so a + // patch that failed to unwrap, or a volume with no UV channel, falls through to the analytic + // methods below as its documented fallback. if (lscm_uv != nullptr) return sample_at(*lscm_uv); @@ -1816,7 +1990,8 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp // Flat projection onto the captured projector plane. Single-valued per point, so unlike // blended triplanar it is one sample, and it is what "project from view" places. return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); - case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar + case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar + case TextureProjectionMethod::ModelUV: // the volume carries no UV channel - same fallback case TextureProjectionMethod::Triplanar: default: break; } @@ -1851,8 +2026,12 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla // the two differ in what "nothing here" means. Height returns 0, which is a perfectly good height // (no displacement); colour has no such neutral value - black is a colour - so every path that // returns 0 there has to report false here instead, and the caller leaves the triangle uncoloured. - const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; - const UVTransform xf(layer, aspect); + const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; + // Same reasoning as sample_layer_height(), and it has to match it exactly or a layer's colour and + // its relief would be read from different places in the image. + const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV && + lscm_uv != nullptr; + const UVTransform xf(layer, aspect, normalized_uv); auto sample_at = [&](const Vec2f &planar) { return texture.sample_color(xf(planar), layer.tile_enabled, layer.tile_method); }; @@ -1866,6 +2045,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla }; if (lscm_uv != nullptr) { + // The coverage test goes through the same transform, so an untiled ModelUV layer is "there" + // exactly where the model's own uv lands inside the image. if (!covered(*lscm_uv)) return false; out = sample_at(*lscm_uv); @@ -1905,7 +2086,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla out = sample_at(p); return true; } - case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar + case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar + case TextureProjectionMethod::ModelUV: // the volume carries no UV channel - same fallback case TextureProjectionMethod::Triplanar: default: break; } @@ -2210,9 +2392,10 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set const TextureColorRequest *color, bool flip_normals, BakeStageRecorder *debug, - TextureBakeStats *stats) + TextureBakeStats *stats, + const std::vector *model_uv) { - HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data); + HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data, model_uv); if (!combined) return mesh; // nothing decodable to displace with @@ -2382,7 +2565,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set // part of it (half a tile in the neighbour's colour, a tile edge that wanders). const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize); const ColorFieldSampler color_sampler = - want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure) : ColorFieldSampler{}; + want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure, model_uv) : + ColorFieldSampler{}; // // The *palette* index, not the printed filament. The decimation treats any edge whose two faces // differ as a crease (TextureBakeDecimate.cpp), so it must only ever see where the **perceived** @@ -2514,7 +2698,8 @@ static indexed_triangle_set build_texture_displacement_in_place( const TextureColorRequest *color, bool flip_normals, BakeStageRecorder *debug, - TextureBakeStats *stats) + TextureBakeStats *stats, + const std::vector *model_uv) { // The classic path moves the vertices the mesh already has, so there is no budget to report on. (void) stats; @@ -2551,7 +2736,7 @@ static indexed_triangle_set build_texture_displacement_in_place( if (options.pipeline_v2) return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, color, flip_normals, - debug, stats); + debug, stats, model_uv); // Layers are combined in slot order, like stacked layers in an image editor: each one folds its // own displacement into the running total via its blend mode (see TextureBlendMode). @@ -2669,13 +2854,19 @@ static indexed_triangle_set build_texture_displacement_in_place( selector_dirty = true; const bool color_this_layer = want_color && layer->color_enabled; + // The model's own channel is per base-mesh *triangle*, so reading it needs to know which base + // triangle each (possibly brush-split) patch triangle came from - the colour pass's map, asked + // for here as well. + const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV && + model_uv != nullptr && model_uv->size() == mesh.indices.size() * 3; // A flat-colour image is matched against the filaments alone (see TextureColorRequest). const ColorQuantizeFn &layer_quantize = (color_this_layer && color->quantize_pure && analyze_texture_detail(*layer).flat_colors) ? color->quantize_pure : color->quantize; - std::vector patch_source; // sub-triangle -> base mesh triangle, only built when colouring + std::vector patch_source; // sub-triangle -> base mesh triangle; built when colouring or mapping by ModelUV const indexed_triangle_set patch = - selector.get_facets_strict(EnforcerBlockerType::ENFORCER, color_this_layer ? &patch_source : nullptr); + selector.get_facets_strict(EnforcerBlockerType::ENFORCER, + (color_this_layer || model_uv_layer) ? &patch_source : nullptr); if (patch.indices.empty()) continue; // get_facets_strict() returns the same vertex array whichever state is asked for (only the @@ -2706,18 +2897,25 @@ static indexed_triangle_set build_texture_displacement_in_place( // A real unwrap of the whole patch, computed once here rather than per vertex - it is a // per-chart solve over the whole patch, not a per-point formula. Cached, so repeating this // for every slider tweak costs a hash rather than a re-solve (see compute_patch_unwrap()). - const std::vector lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? + // ModelUV takes its corners from the volume's own channel instead of solving anything, and + // collapses them to one per vertex the same way the unwrap does. + const std::vector model_corner_uvs = model_uv_layer ? + compute_model_corner_uvs(mesh, *model_uv, patch, patch_source) : + std::vector{}; + const std::vector patch_uvs = model_uv_layer ? collapse_corner_uvs(patch, model_corner_uvs) : + (layer->projection_method == TextureProjectionMethod::LSCM) ? compute_lscm_uvs(patch, *layer) : std::vector{}; // The colour pass below samples per *triangle*, so it takes the per-corner unwrap instead: the // per-vertex collapse above would hand a triangle at a seam the island layout did not join its // neighbour's placement, painting one triangle per face from the wrong part of the texture. - // (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.) - const std::vector lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? + // (The displacement itself stays on patch_uvs - a vertex has one position, so one height.) + const std::vector patch_corner_uvs = model_uv_layer ? model_corner_uvs : + (layer->projection_method == TextureProjectionMethod::LSCM) ? compute_lscm_corner_uvs(patch, *layer) : std::vector{}; - const bool corner_uv_ok = !lscm_corner_uvs.empty() && - lscm_corner_uvs.size() == patch.indices.size() * 3; + const bool corner_uv_ok = !patch_corner_uvs.empty() && + patch_corner_uvs.size() == patch.indices.size() * 3; // Colour, if this layer carries any. Area-weighted over each base triangle's *painted* part, // so a triangle the brush only clipped a corner off takes the colour of that corner rather @@ -2747,7 +2945,7 @@ static indexed_triangle_set build_texture_displacement_in_place( // the per-vertex height sampling uses, evaluated at the centroid instead. Vec3f n = Vec3f::Zero(); Vec2f uv = Vec2f::Zero(); - bool have_uv = !lscm_uvs.empty(); + bool have_uv = !patch_uvs.empty(); for (int k = 0; k < 3; ++k) { const int vi = t[k]; if (vi < int(vertex_normals.size())) @@ -2755,9 +2953,9 @@ static indexed_triangle_set build_texture_displacement_in_place( if (!have_uv) continue; if (corner_uv_ok) - uv += lscm_corner_uvs[j * 3 + size_t(k)]; - else if (size_t(vi) < lscm_uvs.size()) - uv += lscm_uvs[size_t(vi)]; + uv += patch_corner_uvs[j * 3 + size_t(k)]; + else if (size_t(vi) < patch_uvs.size()) + uv += patch_uvs[size_t(vi)]; else have_uv = false; } @@ -2831,7 +3029,7 @@ static indexed_triangle_set build_texture_displacement_in_place( [&](const tbb::blocked_range &range) { for (size_t k = range.begin(); k < range.end(); ++k) { const size_t vi = size_t(layer_vertices[k]); - const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[vi]; + const Vec2f *lscm_uv = patch_uvs.empty() ? nullptr : &patch_uvs[vi]; const float h = sample_layer_height(height, *layer, mesh.vertices[vi], vertex_normals[vi], patch_centroid, patch_axis, lscm_uv); // midlevel is the height that means "stay put", so anything below it displaces @@ -2964,7 +3162,8 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set const TextureColorRequest *color, const Transform3d &volume_to_world, BakeStageRecorder *debug, - TextureBakeStats *stats) + TextureBakeStats *stats, + const std::vector *model_uv) { // An untransformed volume on an untransformed instance is by far the common case, and the round // trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take @@ -2974,7 +3173,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set const Transform3d frame = texture_displacement_bake_frame(volume_to_world); if (frame.matrix().isApprox(Transform3d::Identity().matrix())) return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color, - false, debug, stats); + false, debug, stats, model_uv); const Transform3d to_local = frame.inverse(); // A mirroring placement leaves the positions correct but every winding-derived normal pointing @@ -2988,8 +3187,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set v = (frame * v.cast()).cast(); const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0; + // The UV channel rides along untouched: a uv is a coordinate in the texture, not on the model, so + // the frame the mesh is measured in does not reach it. indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options, - progress, color, mirrored, debug, stats); + progress, color, mirrored, debug, stats, model_uv); // Everything the bake recorded is in world millimetres, like `out` itself. The debug view draws in // the volume's local frame, so the stages are brought back the same way the result is. if (debug != nullptr) @@ -3061,7 +3262,8 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume) return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data, volume.texture_displacement_options, {}, nullptr, - texture_displacement_volume_to_world(volume)); + texture_displacement_volume_to_world(volume), nullptr, nullptr, + volume.has_texture_uv() ? &volume.texture_uv : nullptr); } void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, @@ -3125,16 +3327,18 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector namespace { // 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. -// An unwrap turned into something a *point* sampler can use. LSCM has no formula from position to -// uv - it is a per-triangle map - so a point is placed on the painted patch (the nearest patch -// triangle, and its barycentric coordinates there) and the uv is interpolated from that triangle's own -// per-corner uvs. Exact for a point on the base surface, which is where both samplers are queried: the -// displacement samples refined positions before moving them, and the colour pass samples the foot -// point on the painted pieces. +// A per-triangle uv map turned into something a *point* sampler can use. Neither of the two maps that +// are not a formula from position to uv - the LSCM unwrap and the model's own UV channel - can be +// evaluated at an arbitrary point directly, so a point is placed on the painted patch (the nearest +// patch triangle, and its barycentric coordinates there) and the uv is interpolated from that +// triangle's own per-corner uvs. Exact for a point on the base surface, which is where both samplers +// are queried: the displacement samples refined positions before moving them, and the colour pass +// samples the foot point on the painted pieces. struct LscmLookup { - indexed_triangle_set patch; // the layer's painted patch, as the unwrap was solved on + indexed_triangle_set patch; // the layer's painted patch, as the uvs were computed on AABBTreeIndirect::Tree3f tree; - std::vector corner; // compute_lscm_corner_uvs(patch, layer) + // compute_lscm_corner_uvs(patch, layer), or compute_model_corner_uvs() for a ModelUV layer + std::vector corner; // False when `pos` is not on this layer's patch (farther than `tol`): there is no uv there, so the // layer contributes nothing - the same as a non-tiled texture outside its placement. @@ -3147,15 +3351,9 @@ struct LscmLookup { if (d2 < 0.f || d2 > tol * tol || hit >= patch.indices.size()) return false; const stl_triangle_vertex_indices &t = patch.indices[hit]; - const Vec3f &a = patch.vertices[size_t(t[0])], &b = patch.vertices[size_t(t[1])], &c = patch.vertices[size_t(t[2])]; - const Vec3f e0 = b - a, e1 = c - a, ep = foot - a; - const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1); - const float den = d00 * d11 - d01 * d01; - float w1 = 1.f / 3.f, w2 = 1.f / 3.f; // a degenerate triangle takes its centroid's uv - if (std::abs(den) > 1e-20f) { - w1 = (d11 * dp0 - d01 * dp1) / den; - w2 = (d00 * dp1 - d01 * dp0) / den; - } + float w1 = 0.f, w2 = 0.f; // a degenerate triangle takes its centroid's uv + barycentric_weights(patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])], patch.vertices[size_t(t[2])], + foot, w1, w2); const Vec2f *c3 = &corner[hit * 3]; uv = (1.f - w1 - w2) * c3[0] + w1 * c3[1] + w2 * c3[2]; return true; @@ -3214,7 +3412,7 @@ struct PreparedLayer { // where a layer is placed. `need_color` additionally drops layers that cannot contribute colour. std::shared_ptr> prepare_sampleable_layers( const indexed_triangle_set &base_mesh, const std::vector &layers, - const TextureDisplacementFacetsData &facets_data, bool need_color) + const TextureDisplacementFacetsData &facets_data, bool need_color, const std::vector *model_uv) { auto prepared = std::make_shared>(); @@ -3245,9 +3443,14 @@ std::shared_ptr> prepare_sampleable_layers( if (tex.empty() || (need_color && !tex.has_color())) continue; + const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV && + model_uv != nullptr && model_uv->size() == base_mesh.indices.size() * 3; + TriangleSelector selector(selector_mesh); selector.deserialize(data, false); - const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER); + std::vector patch_source; // only the model's own channel needs it; see the classic path + const indexed_triangle_set patch = + selector.get_facets_strict(EnforcerBlockerType::ENFORCER, model_uv_layer ? &patch_source : nullptr); if (patch.indices.empty()) continue; @@ -3257,12 +3460,16 @@ std::shared_ptr> prepare_sampleable_layers( texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal); std::shared_ptr lscm; - if (layer->projection_method == TextureProjectionMethod::LSCM) { + if (model_uv_layer || layer->projection_method == TextureProjectionMethod::LSCM) { // Solved on the very patch the classic path and the GUI solve it on (same geometry, seam // angle and edges), so it hits the unwrap cache and lands exactly where the UV editor - // shows it, hand-placed islands and UV edits included. + // shows it, hand-placed islands and UV edits included. A ModelUV layer needs no solve at + // all - its corners are read straight out of the volume's channel - but it is looked up + // through the same per-triangle machinery, for the same reason: there is no formula from a + // point to its uv. auto l = std::make_shared(); - l->corner = compute_lscm_corner_uvs(patch, *layer); + l->corner = model_uv_layer ? compute_model_corner_uvs(base_mesh, *model_uv, patch, patch_source) : + compute_lscm_corner_uvs(patch, *layer); if (l->corner.size() == patch.indices.size() * 3) { l->patch = patch; l->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(l->patch.vertices, l->patch.indices); @@ -3288,11 +3495,12 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set const std::vector &layers, const TextureDisplacementFacetsData &facets_data, ColorQuantizeFn quantize, - ColorQuantizeFn quantize_pure) + ColorQuantizeFn quantize_pure, + const std::vector *model_uv) { if (!quantize) return nullptr; - auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true); + auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true, model_uv); if (prepared->empty()) return nullptr; // Per layer: a flat-colour image is matched against the filaments alone, when that quantizer @@ -3323,9 +3531,10 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, - const TextureDisplacementFacetsData &facets_data) + const TextureDisplacementFacetsData &facets_data, + const std::vector *model_uv) { - auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false); + auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false, model_uv); if (prepared->empty()) return nullptr; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..2c5b03e855 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -86,6 +86,17 @@ enum class TextureProjectionMethod : int // that is inherent to view projection and is the user's call, not a bug. The projector's two // in-plane axes live in TextureDisplacementLayer::view_project_right/up. ViewProjected = 4, + // The model's own texture coordinates, as the file it was imported from carried them (see + // ModelVolume::texture_uv). Nothing is unwrapped or projected: the artist's layout *is* the + // placement, so a texture authored against that layout - the model's own albedo map, say - lands + // exactly where it was painted. Automatically picked for a new layer on a volume that has a UV + // channel, since re-unwrapping a model that already carries one can only be worse than using it. + // + // The uv is already normalized, so unlike every method above it is not measured in millimetres: + // "Tile size" and the non-square aspect correction do not apply to it (the layer's rotation and + // offset still do). Falls back to Triplanar when the volume has no channel, exactly as LSCM falls + // back when a patch cannot be flattened. + ModelUV = 5, }; // Dihedral angle (degrees) above which an edge between two painted triangles becomes a chart seam @@ -121,6 +132,11 @@ enum class TextureBlendMode : int Subtract = 1, // acc - value (carve this layer's relief out of the layers below) Multiply = 2, // acc * (value / 1mm) (mask/modulate the layers below by this layer) Divide = 3, // acc / (value / 1mm) (inverse mask; guarded against a zero/near-zero divisor) + // acc = value: this layer replaces what is under it instead of combining with it, so where it is + // painted only its own relief remains. The image editor's "Normal" mode, under the name the panel + // uses. Note the stack's *colour* already behaves this way - a higher slot's colour overwrites a + // lower one's where they overlap - so this is what makes the displacement agree with it. + Override = 4, }; // Combines one layer's signed displacement `value` (mm) into `accumulated` (mm) per `mode`. @@ -541,7 +557,13 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal); // squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is // `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the // square case and leaves the coordinate exactly as it always was. -Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f); +// +// `normalized_uv` says the coordinate is already a uv rather than a millimetre measurement on the +// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tiling scale (mm +// per tile is meaningless for it) nor the aspect correction (the artist's layout already accounts for +// the image's proportions) applies, and only the rotation and offset are left. +Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f, + bool normalized_uv = false); // Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a // local-space point, writing the resulting texture uv. Returns false - and leaves `uv` untouched - @@ -691,6 +713,80 @@ std::vector compute_lscm_uvs(const indexed_triangle_set &patch, const Tex // Returns an empty vector if the patch has no triangles or the unwrap carries no source_face map. std::vector compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer); +// Three UVs per patch triangle, read out of the model's *own* UV channel - the +// TextureProjectionMethod::ModelUV counterpart of compute_lscm_corner_uvs(), and laid out the same way +// (corner k of triangle i at index 3i+k). +// +// `model_uv` is the volume's channel: three uvs per triangle of `base_mesh`, in its triangle order +// (see ModelVolume::texture_uv). `patch_source` maps each patch triangle back to the base-mesh +// triangle it was cut from - TriangleSelector::get_facets_strict()'s out_source. A patch triangle is +// always a piece of exactly one base triangle, so each of its corners is interpolated barycentrically +// inside that triangle's three uvs. That is what keeps a sub-triangle the brush split (whose corners +// are not base-mesh vertices at all) on the texture, and what keeps the two sides of a UV seam +// reading from their own island instead of from one averaged coordinate. +// +// Returns an empty vector when the channel does not match `base_mesh`, when `patch_source` does not +// match `patch`, or when either is empty - the callers treat that as "no placement" and fall back, +// exactly as they do for an unwrap that failed. +std::vector compute_model_corner_uvs(const indexed_triangle_set &base_mesh, + const std::vector &model_uv, + const indexed_triangle_set &patch, + const std::vector &patch_source); + +// One UV per patch vertex, collapsed from a per-corner array: the first corner to reach a vertex wins. +// This is the per-corner/per-vertex split compute_lscm_uvs() documents, applied to any corner array - +// displacement is per-vertex, so a vertex on a seam has to settle on one of its copies. +// Returns an empty vector if `corner` is not three per patch triangle. +std::vector collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector &corner); + +// Resamples a per-corner UV channel from `src_mesh` onto `dst_mesh`, for when something replaces a +// volume's mesh and the model's own UV channel has to come along: the colour import's painted mesh, +// the gizmo's remesh/subdivide, the default bake pipeline's rebuild. Each destination corner takes the +// uv of its closest point on the source surface, interpolated barycentrically inside the source +// triangle it lands on - the same transfer LscmLookup does per point, run over every corner. +// +// Exact wherever the two meshes describe the same surface, which is the case for every caller here +// (they subdivide, repair or displace it, they do not reshape it). A corner that lands nowhere near +// the source - farther than `max_distance` (negative: no limit) - takes (0, 0). +// +// One thing a positional transfer cannot reproduce: a *seam* vertex, where one position carries a +// different uv in each island that meets there. Position alone does not say which island a corner +// belongs to, so corners on a seam all take whichever island the nearest-triangle lookup lands on, +// and the texture can be off by up to one triangle in a band along the island borders. Doing better +// needs a triangle-to-triangle correspondence, which only some of the stages that replace a mesh +// have (a remesh has none at all). +// +// Returns an empty vector when `src_uv` is not three per source triangle, or when either mesh is empty. +std::vector transfer_uv_channel(const indexed_triangle_set &src_mesh, + const std::vector &src_uv, + const indexed_triangle_set &dst_mesh, + float max_distance = -1.f); + +// 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); + // The TextureDisplacementLayer::lscm_uv_overrides key for one unwrapped vertex (an index into PatchUnwrap::uvs). inline int lscm_uv_override_key(int unwrapped_vertex) { return -(unwrapped_vertex + 1); } @@ -749,6 +845,10 @@ using TextureDisplacementFacetsData = std::array palette index. Supplied by the GUI, which owns both the perceptual matching and the list @@ -823,7 +923,9 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set // stage, already brought back into `base_mesh`'s frame. BakeStageRecorder *debug = nullptr, // When given, receives what the bake spent. - TextureBakeStats *stats = nullptr); + TextureBakeStats *stats = nullptr, + // The model's own UV channel; see above. + const std::vector *model_uv = nullptr); // `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own. // The mesh is shared by every instance, so a multi-instance object can only be baked for one of @@ -898,11 +1000,15 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set const std::vector &layers, const TextureDisplacementFacetsData &facets_data, ColorQuantizeFn quantize, - ColorQuantizeFn quantize_pure = nullptr); + ColorQuantizeFn quantize_pure = nullptr, + // The model's own UV channel, for ModelUV layers; see + // build_texture_displacement(). + const std::vector *model_uv = nullptr); HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, - const TextureDisplacementFacetsData &facets_data); + const TextureDisplacementFacetsData &facets_data, + const std::vector *model_uv = nullptr); // 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 @@ -1107,6 +1213,10 @@ struct TextureDisplacementPrepareResult { indexed_triangle_set mesh; TextureDisplacementFacetsData masks; + // The model's own UV channel, resampled onto `mesh` (see transfer_uv_channel()). Empty when the + // volume had none. Like the masks it is handed back rather than left to the caller to recompute, + // because only the run itself knows which stages replaced the mesh. + std::vector uv; // The remesh landed but no layer's paint survived being carried onto it. Nothing is committed: // everything downstream is driven by that paint, so baking on would bake a flat mesh. bool paint_lost = false; diff --git a/src/libslic3r/TexturePainting.cpp b/src/libslic3r/TexturePainting.cpp index 1bcddfd3d2..2f217b1aa2 100644 --- a/src/libslic3r/TexturePainting.cpp +++ b/src/libslic3r/TexturePainting.cpp @@ -25,6 +25,7 @@ #include "TextureToColor/ColorUtils.hpp" #include "Model.hpp" +#include "TextureDisplacement.hpp" #include "TextureToColor/TriMesh.hpp" #include "TriangleMesh.hpp" #include "TriangleSelector.hpp" @@ -554,9 +555,29 @@ bool apply_painted_mesh_to_volume( << ", unit_scale=" << unit_scale << ")"; } + // The model's own UV channel is indexed by triangle, and this replaces the triangles, so it has + // to be resampled onto them. Kept rather than dropped because it is the layout the model was + // authored against: a texture-displacement layer set to ModelUV maps through it, and the colour + // import is exactly the path a model that carries one arrives by. + const indexed_triangle_set old_its = volume.has_texture_uv() ? volume.mesh().its : indexed_triangle_set{}; + const std::vector old_uv = volume.texture_uv; + volume.set_mesh(std::move(new_mesh)); volume.calculate_convex_hull(); + // Resampled here and not further down, while the two meshes still share a frame: the painted mesh + // was just shifted by the same source.mesh_offset the old one was centred by, so the only thing + // between them is whatever drift the repair introduced. The re-centering below translates the mesh + // again, and a translation is exactly what a closest-point transfer cannot see through - it would + // read every uv off a point that far away. A uv is a coordinate in the texture, so it does not care + // about either shift; it just has to be read before the second one happens. + if (!old_its.indices.empty()) + volume.texture_uv = transfer_uv_channel(old_its, old_uv, volume.mesh().its); + else + // No channel to carry, so make sure none is left behind either: a leftover of the right length + // would read as a valid channel against a mesh it was never measured on. + volume.reset_texture_uv(); + // Re-center the replaced mesh so its bbox center sits at the origin, // matching what center_geometry_after_creation did for the original mesh. // CGAL repair / subdivision may shift the bbox center (drift); without diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..52edb884de 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1221,9 +1221,33 @@ indexed_triangle_set GLGizmoTextureDisplacement::patch_in_world(const indexed_tr return world; } -std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch, - const TextureDisplacementLayer &layer) const +std::vector GLGizmoTextureDisplacement::model_uv_corners(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const { + const ModelVolume *mv = texture_volume(); + if (layer.projection_method != TextureProjectionMethod::ModelUV || patch_source == nullptr || mv == nullptr || + !mv->has_texture_uv()) + return {}; + // The channel is indexed against the volume's *local* mesh, and a uv is a coordinate in the + // texture rather than on the model, so unlike every projected method this never goes through + // patch_in_world() - there is no millimetre measurement here for a world scale to change. + std::vector uv = compute_model_corner_uvs(mv->mesh().its, mv->texture_uv, local_patch, *patch_source); + for (Vec2f &p : uv) + // Already a uv: no tiling scale, no aspect correction - see apply_uv_transform(). + p = apply_uv_transform(p, layer, 1.f, /* normalized_uv */ true); + return uv; +} + +std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const +{ + if (layer.projection_method == TextureProjectionMethod::ModelUV) + // Collapsed to one per vertex the same way the unwrap is - a vertex has one position, so the + // displacement can only read one height for it. + return collapse_corner_uvs(local_patch, model_uv_corners(local_patch, layer, patch_source)); + // The bake maps the texture in world millimetres, so "Tile size (mm)" means the same thing on a // scaled instance as it does on an untouched one (see build_texture_displacement()). Everything // that has to agree with the bake - the fast preview's uvs, the checker/distortion overlays - @@ -1283,8 +1307,11 @@ int GLGizmoTextureDisplacement::layer_projection_frame(const indexed_triangle_se } std::vector GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch, - const TextureDisplacementLayer &layer) const + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const { + if (layer.projection_method == TextureProjectionMethod::ModelUV) + return model_uv_corners(local_patch, layer, patch_source); if (layer.projection_method == TextureProjectionMethod::LSCM) { const indexed_triangle_set patch = patch_in_world(local_patch); const float aspect = layer_texture_aspect(layer); @@ -1294,7 +1321,7 @@ std::vector GLGizmoTextureDisplacement::compute_layer_corner_uvs(const in return uv; } // Single-valued per point: fan the per-vertex result out over the corners. - const std::vector per_vertex = compute_layer_vertex_uvs(local_patch, layer); + const std::vector per_vertex = compute_layer_vertex_uvs(local_patch, layer, patch_source); if (per_vertex.size() != local_patch.vertices.size()) return {}; std::vector corner(local_patch.indices.size() * 3); @@ -1315,7 +1342,11 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh() // Uses the *live* selector (not the flushed model facet data), so this reflects an in-progress // stroke immediately rather than only once it ends - the point of this preview mode is to be // the fast, no-CPU-meshing one. - const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER); + // The source map comes along because a ModelUV layer's uvs are read per base-mesh triangle; every + // other mapping ignores it. + std::vector patch_source; + const indexed_triangle_set patch = + m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source); if (patch.indices.empty()) return; @@ -1339,7 +1370,8 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh() // Per *corner*, not per vertex: the mesh below is flat (unshared) anyway, so each triangle can // carry its own chart's UVs - see compute_layer_corner_uvs(). const TextureDisplacementLayer *active = active_layer(); - std::vector corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector{}; + std::vector corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active, &patch_source) : + std::vector{}; m_shaded_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3; if (!m_shaded_preview_uses_vertex_uv) corner_uv.clear(); @@ -1996,7 +2028,9 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() const ModelVolume *mv = texture_volume(); if (mv == nullptr || m_triangle_selectors.empty()) return; - const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER); + std::vector patch_source; // only ModelUV reads it; see compute_layer_vertex_uvs() + const indexed_triangle_set patch = + m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source); if (patch.indices.empty()) return; // Everything below works in the *patch's* vertex space, not the mesh's. Those agree only until a @@ -2010,7 +2044,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() // The checker samples wherever the projection puts it; the projections the shader can't // reconstruct (LSCM, ViewProjected) get a precomputed per-vertex uv, the rest project in-shader. - std::vector uv = compute_layer_vertex_uvs(patch, *layer); + std::vector uv = compute_layer_vertex_uvs(patch, *layer, &patch_source); const bool have_uvs = uv.size() == patch.vertices.size(); m_uvcheck_uses_vertex_uv = have_uvs; m_uvcheck_projection_mode = have_uvs ? 0 : @@ -2019,7 +2053,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() // different uv in each island it borders, so the shared-vertex form drew one triangle per face from // a neighbouring island's placement. Only the *drawing* needs this; the distortion metric below is // a per-vertex average by construction and keeps using `uv`. - const std::vector corner_uv = compute_layer_corner_uvs(patch, *layer); + const std::vector corner_uv = compute_layer_corner_uvs(patch, *layer, &patch_source); const bool have_corner_uvs = have_uvs && corner_uv.size() == patch.indices.size() * 3; // Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible. @@ -2295,6 +2329,8 @@ void GLGizmoTextureDisplacement::queue_preview_job() if (input.options.pipeline_v2) input.options.v2_max_triangles_k = 0; input.volume_to_world = texture_displacement_volume_to_world(*mv); + if (mv->has_texture_uv()) + input.model_uv = mv->texture_uv; for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); // Captured here rather than read in the handler: the palette is main-thread state, and the preview @@ -3676,7 +3712,8 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() // Paint / Erase, brush cursor shape, mapping, tiling, placement and the dock toggle. "texture_displacement_add.svg", "texture_displacement_add_negative.svg", "circle_paint.svg", "menu_obj_sphere.svg", "menu_obj_cube.svg", "menu_obj_cylinder.svg", "texture_displacement_map_unwrap.svg", - "texture_displacement_map_view.svg", "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg", + "texture_displacement_map_view.svg", "texture_displacement_map_model_uv.svg", + "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg", "texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg", "texture_displacement_move_down.svg", "texture_displacement_drag.svg", "texture_displacement_select_all.svg", "texture_displacement_erase_all.svg", @@ -3723,6 +3760,11 @@ void GLGizmoTextureDisplacement::add_texture_layer() TextureDisplacementLayer layer; layer.slot = free_slot; + // A model that came with a UV channel is mapped by it. Re-unwrapping such a model throws away the + // layout it was authored against and can only be worse than using it, so the default follows the + // model rather than the other way round; the Mapping row still offers every other method. + if (mv->has_texture_uv()) + layer.projection_method = TextureProjectionMethod::ModelUV; // Start the layer off on the first library texture rather than on nothing at all: a textureless // layer looks broken (painting on it appears to do nothing, because there is no height map to // displace by). The user swaps it for another from the layer's own picker. @@ -4868,7 +4910,8 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, - const std::vector &layers, const TextureDisplacementPrepareParams ¶ms, + const std::vector &layers, const std::vector &model_uv, + const TextureDisplacementPrepareParams ¶ms, const TextureColorSettings &color_settings, const DisplacementProgressFn &progress, BakeStageRecorder *debug) { @@ -4895,6 +4938,13 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( bool had_paint = false; for (const TriangleSelector::TriangleSplittingData &m : masks) had_paint = had_paint || TriangleSelector::has_facets(m, EnforcerBlockerType::ENFORCER); + // The model's own UV channel for whatever `mesh` currently is. Every stage below keeps the input's + // surface - it evens out, refines or cuts the triangles lying on it - so each corner reads its uv + // straight off `base`'s channel at its own position instead of being carried stage by stage, which + // would compound each stage's approximation. Untouched while nothing has replaced the mesh yet. + const auto uv_now = [&]() { + return (model_uv.empty() || !changed) ? model_uv : transfer_uv_channel(base, model_uv, mesh.its); + }; if (!report(1)) return out; @@ -4941,9 +4991,11 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( // Feature-adaptive: sample the combined displacement so refinement follows texture // curvature. A null sampler (only LSCM layers, or nothing decodable) falls back to the // length baseline alone. + const std::vector uv_stage = uv_now(); + const std::vector *uv_ptr = uv_stage.empty() ? nullptr : &uv_stage; HeightFieldSampler sampler; if (params.subdiv_feature) - sampler = make_combined_displacement_sampler(mesh.its, layers, current); + sampler = make_combined_displacement_sampler(mesh.its, layers, current, uv_ptr); // Colour boundaries need triangles of their own - the chord test cannot see them, since // the height field is perfectly smooth across a change of filament. ColorFieldSampler color; @@ -4951,7 +5003,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( // boundaries are refined where the bake will actually change filament. if (params.subdiv_color_edge_mm > 0.f && !color_settings.empty()) color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(color_settings.palette), - make_palette_quantizer(color_settings.palette_pure)); + make_palette_quantizer(color_settings.palette_pure), uv_ptr); // The refinement follows perceived colour: a mix is one colour here, however the slicer // interleaves its filaments layer by layer. // "Min edge" is a feature-mode control (it is the floor the curvature test refines down @@ -5010,8 +5062,10 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( std::vector cut_region(mesh.its.indices.size(), 0); for (size_t i = 0; i < cut_region.size() && i < source.size(); ++i) cut_region[i] = (region[size_t(source[i])] & REFINE_PAINTED) ? 1 : 0; + const std::vector uv_refined = uv_now(); const HeightFieldSampler refined_sampler = - make_combined_displacement_sampler(mesh.its, layers, current); + make_combined_displacement_sampler(mesh.its, layers, current, + uv_refined.empty() ? nullptr : &uv_refined); std::vector cut_source; size_t cuts = 0; indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler, @@ -5035,6 +5089,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( if (!report(100) || !changed) return out; // an empty result: nothing to commit, which is not a failure + out.uv = uv_now(); out.mesh = std::move(mesh.its); out.masks = std::move(current); return out; @@ -5686,6 +5741,8 @@ void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareP input.layers = mv->texture_displacement_layers; input.params = params; input.snapshot_name = snapshot_name; + if (mv->has_texture_uv()) + input.model_uv = mv->texture_uv; if (params.subdiv_color_edge_mm > 0.f) input.color = color_settings_for(*mv); @@ -6454,10 +6511,22 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad); hover_tip(_u8L("Height of the relief: how far white in the texture lifts the surface, in " "millimetres. Black does not move it at all, unless Midlevel says otherwise.")); - m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad); - hover_tip(_u8L("How wide one copy of the texture is on the model. Smaller repeats the pattern " - "more often and makes its detail finer; with Tile off, this is the size of the " - "single copy.")); + // Mapped by the model's own UV layout there is no millimetre scale to set: the layout is the + // placement (see TextureProjectionMethod::ModelUV), so the slider would move nothing. + const bool tile_size_na = layer.projection_method == TextureProjectionMethod::ModelUV; + m_imgui->disabled_begin(tile_size_na); + m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, + "%.2f mm", true, card_pad); + m_imgui->disabled_end(); + // AllowWhenDisabled, or the one case that needs explaining is the one that cannot be hovered. + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + m_imgui->tooltip(tile_size_na ? + _u8L("Mapped by the model's own UV layout, which is already the placement - one " + "copy of the texture covers it exactly, so there is no tile size to set.") : + _u8L("How wide one copy of the texture is on the model. Smaller repeats the " + "pattern more often and makes its detail finer; with Tile off, this is the " + "size of the single copy."), + wrap_w); m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad); hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an " "edge of the model.")); @@ -6564,7 +6633,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } } - // Mapping, as five icons in TextureProjectionMethod's own order. + // Mapping, as one icon per TextureProjectionMethod, in the enum's own order. { const float x0 = ImGui::GetCursorPosX(); ImGui::AlignTextToFramePadding(); @@ -6592,13 +6661,26 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float { "texture_displacement_map_view.svg", _L("From view"), _L("From view - projects straight onto the painted area from where you are looking, like a slide " "projector.") }, + { "texture_displacement_map_model_uv.svg", _L("Model UV"), + _L("Model UV - uses the texture coordinates the model itself came with, so a texture made for " + "that model lands exactly where it was painted. Nothing is unwrapped, and Tile size does " + "not apply: the model's own layout is the placement.") }, }; + // Only a volume that actually carries a UV channel can be mapped by it. Shown + // greyed out rather than hidden, so the reason is visible instead of the control + // simply being missing. + const wxString model_uv_na = + (mv != nullptr && mv->has_texture_uv()) ? + wxString() : + _L("This model carries no texture coordinates of its own. Only a model imported with a UV " + "channel - a textured OBJ, say - can be mapped by them."); for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) { if (mi > 0) ImGui::SameLine(0.f, gap_s); const auto method = static_cast(mi); + const wxString na = (method == TextureProjectionMethod::ModelUV) ? model_uv_na : wxString(); if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label, - mappings[mi].tip) && + mappings[mi].tip, na) && layer.projection_method != method) { // Capture the current view the moment "From view" is chosen, so it does something // sensible immediately rather than projecting from a stale/default direction. @@ -6728,18 +6810,33 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); const std::string blend_add = _u8L_CONTEXT("Add", "Texture Displacement"); + const std::string blend_override = _u8L_CONTEXT("Override", "Texture Displacement"); const std::string blend_subtract = _u8L_CONTEXT("Subtract", "Texture Displacement"); const std::string blend_multiply = _u8L_CONTEXT("Multiply", "Texture Displacement"); const std::string blend_divide = _u8L("Divide"); - const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(), blend_divide.c_str() }; + const char *blend_items[] = { blend_add.c_str(), blend_override.c_str(), blend_subtract.c_str(), + blend_multiply.c_str(), blend_divide.c_str() }; + // The combo's order is the panel's, not the enum's - Override reads as the sibling of Add and + // belongs next to it, while TextureBlendMode's numbering is fixed by what projects already + // store. Hence the explicit mapping rather than a cast of the index. + static const TextureBlendMode blend_modes[] = { TextureBlendMode::Add, TextureBlendMode::Override, + TextureBlendMode::Subtract, TextureBlendMode::Multiply, + TextureBlendMode::Divide }; + static_assert(IM_ARRAYSIZE(blend_items) == IM_ARRAYSIZE(blend_modes), + "every Blend entry needs its mode"); // The first layer has nothing before it to combine with - build_texture_displacement() makes it // add regardless - so its combo shows Add and cannot be changed. const bool base_layer = li == 0; - int blend_mode = base_layer ? 0 : static_cast(layer.blend_mode); + int blend_mode = 0; + if (!base_layer) + for (int bi = 0; bi < int(IM_ARRAYSIZE(blend_modes)); ++bi) + if (blend_modes[bi] == layer.blend_mode) + blend_mode = bi; ImGui::SetNextItemWidth(-(card_pad + gap_s + icon_sm)); m_imgui->disabled_begin(base_layer); - if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) { - layer.blend_mode = static_cast(blend_mode); + if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer && + blend_mode >= 0 && blend_mode < int(IM_ARRAYSIZE(blend_modes))) { + layer.blend_mode = blend_modes[blend_mode]; m_preview_params_dirty = true; } m_imgui->disabled_end(); @@ -6747,10 +6844,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->tooltip(base_layer ? _u8L("The base layer has nothing beneath it to combine with, so it always adds.") : _u8L("What this layer does where it overlaps the ones below. Add and " - "Subtract pile relief on or carve it away. Multiply and Divide scale " - "what is underneath, which turns this layer into a mask over it - " - "there, Depth acts as a strength, and 1 mm leaves white areas " - "untouched."), + "Subtract pile relief on or carve it away. Override replaces them, " + "so only this layer's relief is left where it is painted. Multiply " + "and Divide scale what is underneath, which turns this layer into a " + "mask over it - there, Depth acts as a strength, and 1 mm leaves " + "white areas untouched."), wrap_w); ImGui::SameLine(0.f, gap_s); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..c76abf4375 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -61,6 +61,12 @@ public: static TextureDisplacementPrepareResult prepare_mesh(const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, const std::vector &layers, + // `base`'s own UV channel, when it has one + // (ModelVolume::texture_uv). Resampled onto + // the result and reported back in it, and fed + // to the samplers so a ModelUV layer drives + // the refinement through its own layout. + const std::vector &model_uv, const TextureDisplacementPrepareParams ¶ms, const TextureColorSettings &color_settings, const DisplacementProgressFn &progress, @@ -818,18 +824,31 @@ private: // Merges chart `b`'s join group into chart `a`'s (materialising `groups` to `chart_count` first). static void join_island_groups(std::vector &groups, int a, int b, int chart_count); // Final per-vertex texture uv for the projections the shader can't reconstruct itself - LSCM (an - // unwrap) and ViewProjected (a projector plane the shader doesn't know). One entry per patch/base - // vertex, already through apply_uv_transform(). Empty for Triplanar/Cylindrical/Spherical, which - // the shader projects on its own. Shared by the shaded preview and the UV-check overlay. + // unwrap), ModelUV (the model's own channel) and ViewProjected (a projector plane the shader + // doesn't know). One entry per patch/base vertex, already through apply_uv_transform(). Empty for + // Triplanar/Cylindrical/Spherical, which the shader projects on its own. Shared by the shaded + // preview and the UV-check overlay. + // + // `patch_source` maps each patch triangle back to its base-mesh triangle + // (TriangleSelector::get_facets_strict()'s out_source). Only ModelUV needs it - its channel is per + // base triangle - and without it a ModelUV layer reports no uvs and the preview falls back. std::vector compute_layer_vertex_uvs(const indexed_triangle_set &patch, - const TextureDisplacementLayer &layer) const; + const TextureDisplacementLayer &layer, + const std::vector *patch_source = nullptr) const; // The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what // the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different // UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined // seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is // single-valued per point, so there a corner's UV is just its vertex's. std::vector compute_layer_corner_uvs(const indexed_triangle_set &patch, - const TextureDisplacementLayer &layer) const; + const TextureDisplacementLayer &layer, + const std::vector *patch_source = nullptr) const; + // The model's own channel over `local_patch`, already through apply_uv_transform() - three per + // patch triangle. Empty when the volume has no channel, or when `patch_source` is missing. Shared + // by the two functions above so the per-vertex and per-corner forms can never disagree. + std::vector model_uv_corners(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const; // The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two // wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the // shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index e769800749..a5d8f4038b 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -26,6 +26,7 @@ #include "slic3r/Utils/UndoRedo.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/TriangleMesh.hpp" +#include namespace Slic3r::GUI { @@ -74,7 +75,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) } return true; }, - color, m_input.volume_to_world, nullptr, &m_stats)); + color, m_input.volume_to_world, nullptr, &m_stats, + m_input.model_uv.empty() ? nullptr : &m_input.model_uv)); // 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. @@ -126,10 +128,73 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept { mix.a_percent(), 100 - mix.a_percent() }); }); + // 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(); + } + } + + // The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started + // from: a displaced vertex came from its foot point on that surface, which is the position its + // uv belongs to - so no distance limit, the relief depth is exactly how far it has moved. + if (!m_input.model_uv.empty()) + volume->texture_uv = topology_changed ? transfer_uv_channel(m_input.base_mesh, m_input.model_uv, + volume->mesh().its) : + m_input.model_uv; + // 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 @@ -159,7 +224,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; }); @@ -219,6 +283,8 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol input.take_snapshot = take_snapshot; input.volume_id = volume.id(); input.base_mesh = volume.mesh().its; + if (volume.has_texture_uv()) + input.model_uv = volume.texture_uv; input.layers = volume.texture_displacement_layers; input.options = volume.texture_displacement_options; input.volume_to_world = texture_displacement_volume_to_world(volume); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp index 40d7ab7b2b..5e1a586736 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp @@ -34,6 +34,9 @@ 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 volume's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV + // layer maps through, and what the commit puts back on the baked mesh. + std::vector model_uv; // 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. diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp index 12701ef3c9..1a0ecdd37d 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp @@ -52,13 +52,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl) indexed_triangle_set mesh = m_input.base_mesh; TextureDisplacementFacetsData masks = m_input.facets_data; + std::vector uv = m_input.model_uv; // The default pipeline can only move vertices the mesh already has, so its recipe starts with the // preparation - which is where "first it remeshes, then it refines" actually happens. The // experimental pipeline refines as part of the bake and has nothing to prepare. if (m_input.run_prepare && !m_input.options.pipeline_v2) { const TextureDisplacementPrepareResult prepared = - GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params, + GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_params, m_input.color, // Preparation is roughly half the run; the bake // takes the progress bar from there. @@ -77,12 +78,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl) if (!prepared.mesh.indices.empty()) { mesh = prepared.mesh; masks = prepared.masks; + uv = prepared.uv; // resampled onto the prepared mesh, as the prepare commit stores it } } build_texture_displacement(mesh, m_input.layers, masks, m_input.options, [&report](int pct) { return report(50 + pct / 2); }, - /* color */ nullptr, m_input.volume_to_world, &recorder); + /* color */ nullptr, m_input.volume_to_world, &recorder, /* stats */ nullptr, + uv.empty() ? nullptr : &uv); m_stages = recorder.take(); ctl.update_status(100, status); // always finish at 100: this is what closes the notification @@ -115,6 +118,8 @@ void queue_texture_displacement_debug(const ModelVolume &volume, const TextureCo input.check_topology = check_topology; input.color = color; input.volume_to_world = texture_displacement_volume_to_world(volume); + if (volume.has_texture_uv()) + input.model_uv = volume.texture_uv; 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/TextureDisplacementDebugJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp index f4f9f88f4e..981a17773f 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp @@ -47,6 +47,9 @@ struct TextureDisplacementDebugInput // Only the quantizer is used, and only by the refinement's colour criterion - the debug run // produces no colour of its own. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), so the recipe captured here is the one a + // ModelUV layer actually bakes through. Empty when the volume has none. + std::vector model_uv; // The edge scan behind the open / non-manifold counts is a sort over every half-edge, which on a // multi-million triangle stage costs more than the stage did. On by default because a stage that diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp index 8a956af98d..6c582d3746 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp @@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl) // idle loop. int last_reported = 1; m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers, - m_input.params, m_input.color, + m_input.model_uv, m_input.params, m_input.color, [&ctl, &status, &last_reported](int percent) { if (ctl.was_canceled()) return false; @@ -106,6 +106,10 @@ void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr & volume->restore_painting(saved_painting); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)])); + // The model's own UV channel, resampled onto the new mesh by the run. Also put back after + // restore_painting(), which resets it along with the extra facets for the same reason: it is + // indexed by triangle and the triangles have changed. + volume->texture_uv = std::move(m_result.uv); if (ObjectList *obj_list = wxGetApp().obj_list()) { const ModelObjectPtrs &objs = plater->model().objects; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp index 5fc173975f..8e6e81e237 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp @@ -47,6 +47,10 @@ struct TextureDisplacementPrepareInput // skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows // perceived colour, never the interleaving that realises a mix. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh() + // resamples it onto the mesh it produces, and the commit puts it back - like the masks, it is + // indexed by triangle and so cannot survive the stages on its own. + std::vector model_uv; // The undo step the commit opens. Standard mode's Bake names it after the bake, because the // displacement job that follows commits into this same step rather than pushing its own. std::string snapshot_name; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 4243cdd9d3..32b88aa951 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -48,7 +48,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) (!m_current_generation || m_current_generation->load() == m_generation); }, - color, m_input.volume_to_world); + color, m_input.volume_to_world, nullptr, nullptr, + m_input.model_uv.empty() ? nullptr : &m_input.model_uv); } void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index 07fcca8664..7f7941e828 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -29,6 +29,9 @@ struct TextureDisplacementPreviewInput // Empty unless a layer is colouring, in which case the preview reports the palette entry per // triangle alongside the mesh, so the Normal view shows the colours the bake will produce. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the + // same layout it will bake through. Empty when the volume has none. + std::vector model_uv; }; // A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 485f4f2a0f..06a08f6f79 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2174,3 +2174,353 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } + +// --------------------------------------------------------------------------------------------- +// The model's own UV channel, and the Override blend mode +// --------------------------------------------------------------------------------------------- + +// A grayscale image that is black in its left half and white in its right half, so where a sample +// lands along u is readable straight off the height it comes back with. +static std::shared_ptr> make_left_right_split_png(size_t w = 16, size_t h = 16) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (x < w / 2) ? 0 : 255; + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +// Where a sheet coordinate lands in uv: across [UV_LO, UV_LO + UV_SPAN] rather than the full [0, 1]. +// The inset keeps the tests off the exact edge of an untiled placement, where there is deliberately no +// texture at all rather than its border texel (see DecodedHeightTexture::sample()) - a real model's +// channel does reach 1.0, and a vertex sitting exactly there displaces by nothing on purpose. +static constexpr float UV_LO = 0.1f; +static constexpr float UV_SPAN = 0.8f; + +static float sheet_uv_at(float coord, float size, bool mirror = false) +{ + const float t = coord / size; + return UV_LO + UV_SPAN * (mirror ? 1.f - t : t); +} + +// One UV per triangle corner: u follows x, v follows y. `mirror_u` mirrors u, which is what the tests +// use to show the bake is really reading the channel rather than projecting the position. +static std::vector sheet_uv_channel(const indexed_triangle_set &sheet, float size, bool mirror_u = false) +{ + std::vector uv(sheet.indices.size() * 3); + for (size_t f = 0; f < sheet.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = sheet.vertices[size_t(sheet.indices[f][k])]; + uv[f * 3 + size_t(k)] = Vec2f(sheet_uv_at(p.x(), size, mirror_u), sheet_uv_at(p.y(), size)); + } + return uv; +} + +TEST_CASE("Override blend replaces the displacement under it instead of combining with it", + "[TextureDisplacement]") +{ + CHECK_THAT(blend_displacement(3.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f)); + // Nothing underneath yet is still just the value, so an Override layer at the bottom of the + // stack behaves exactly like the Add the bake forces there. + CHECK_THAT(blend_displacement(0.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f)); + // The sign carries, and a zero sample really does flatten what is under it - that is the point. + CHECK_THAT(blend_displacement(3.f, -1.f, TextureBlendMode::Override), WithinAbs(-1.f, 1e-6f)); + CHECK_THAT(blend_displacement(3.f, 0.f, TextureBlendMode::Override), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("an Override layer leaves only its own relief where two layers overlap", "[TextureDisplacement]") +{ + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + + TextureDisplacementLayer base; + base.slot = 0; + base.depth_mm = 2.0f; + base.tiling_scale = 5.0f; + base.image_data = make_flat_gray_png(255); // full depth everywhere + + TextureDisplacementLayer second = base; + second.slot = 1; + second.depth_mm = 0.5f; + second.blend_mode = TextureBlendMode::Override; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(cube); + facets[1] = facets[0]; + + const indexed_triangle_set overridden = build_texture_displacement(cube, {base, second}, facets, classic_options()); + REQUIRE(overridden.vertices.size() == cube.vertices.size()); + + // Add would have piled the two up to 2.5 mm; Override keeps the upper layer's 0.5 mm alone. + TextureDisplacementLayer added = second; + added.blend_mode = TextureBlendMode::Add; + const indexed_triangle_set summed = build_texture_displacement(cube, {base, added}, facets, classic_options()); + REQUIRE(summed.vertices.size() == cube.vertices.size()); + + for (size_t i = 0; i < cube.vertices.size(); ++i) { + if (!above_resting_plane(cube, i)) + continue; + CHECK_THAT((overridden.vertices[i] - cube.vertices[i]).norm(), WithinAbs(0.5f, 1e-3f)); + CHECK_THAT((summed.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.5f, 1e-3f)); + } +} + +TEST_CASE("the ModelUV mapping displaces through the model's own UV channel", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 1.f); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.image_data = make_left_right_split_png(); + layer.tile_enabled = false; // the layout is the placement; nothing should repeat + layer.projection_method = TextureProjectionMethod::ModelUV; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(sheet); + + const std::vector uv = sheet_uv_channel(sheet, size); + const std::vector flipped = sheet_uv_channel(sheet, size, /* mirror_u */ true); + + const indexed_triangle_set out = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, nullptr, + Transform3d::Identity(), nullptr, nullptr, &uv); + REQUIRE(out.vertices.size() == sheet.vertices.size()); + + // The image is black for u < 0.5 and white above it, and the channel's u rises with x across the + // sheet, so it comes out flat on its left half and raised by the full depth on its right. + size_t low = 0, high = 0; + for (size_t i = 0; i < sheet.vertices.size(); ++i) { + const float x = sheet.vertices[i].x(); + const float dz = out.vertices[i].z() - sheet.vertices[i].z(); + if (x < 0.4f * size) { + CHECK_THAT(dz, WithinAbs(0.f, 1e-3f)); + ++low; + } else if (x > 0.6f * size) { + CHECK_THAT(dz, WithinAbs(layer.depth_mm, 1e-3f)); + ++high; + } + } + REQUIRE(low > 0); + REQUIRE(high > 0); + + // Mirroring the channel mirrors the relief: the mapping really is the channel's, not a planar + // projection of the position (which would be unchanged by it). + const indexed_triangle_set mirrored = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, + nullptr, Transform3d::Identity(), nullptr, nullptr, + &flipped); + REQUIRE(mirrored.vertices.size() == sheet.vertices.size()); + for (size_t i = 0; i < sheet.vertices.size(); ++i) { + const float x = sheet.vertices[i].x(); + if (x < 0.4f * size) + CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(layer.depth_mm, 1e-3f)); + else if (x > 0.6f * size) + CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(0.f, 1e-3f)); + } +} + +TEST_CASE("a ModelUV layer falls back to Triplanar when the volume carries no UV channel", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 2.f); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_gray_png(255); + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(sheet); + + layer.projection_method = TextureProjectionMethod::Triplanar; + const indexed_triangle_set as_triplanar = build_texture_displacement(sheet, {layer}, facets, classic_options()); + layer.projection_method = TextureProjectionMethod::ModelUV; + const indexed_triangle_set no_channel = build_texture_displacement(sheet, {layer}, facets, classic_options()); + + REQUIRE(no_channel.vertices.size() == as_triplanar.vertices.size()); + for (size_t i = 0; i < as_triplanar.vertices.size(); ++i) + CHECK_THAT((no_channel.vertices[i] - as_triplanar.vertices[i]).norm(), WithinAbs(0.f, 1e-5f)); +} + +TEST_CASE("a bake with colour off on every layer reports no colour at all", "[TextureDisplacement]") +{ + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_rgb_png(200, 40, 10); // has colour to give... + layer.color_enabled = false; // ...but is not asked for it + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(cube); + + std::vector triangle_color; + TextureColorRequest request; + request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked + request.out_triangle = &triangle_color; + + const indexed_triangle_set out = build_texture_displacement(cube, {layer}, facets, classic_options(), {}, &request); + REQUIRE_FALSE(out.vertices.empty()); + // Every entry 0 ("this triangle takes no colour from the texture"), so the commit leaves whatever + // the model was already painted with exactly as it was. + for (const uint8_t entry : triangle_color) + CHECK(entry == 0); +} + +TEST_CASE("transfer_uv_channel resamples a UV channel onto a subdivided copy of the same surface", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 5.f); + const indexed_triangle_set fine = make_flat_sheet(size, 1.f); + const std::vector uv = sheet_uv_channel(coarse, size); + + const std::vector moved = transfer_uv_channel(coarse, uv, fine); + REQUIRE(moved.size() == fine.indices.size() * 3); + + // The channel is affine in position here (u = x/size, v = y/size), so a correct transfer + // reproduces it exactly at every corner of the finer mesh. + for (size_t f = 0; f < fine.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = fine.vertices[size_t(fine.indices[f][k])]; + CHECK_THAT(moved[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f)); + CHECK_THAT(moved[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f)); + } + + // A mismatched channel is refused rather than read past its end. + CHECK(transfer_uv_channel(coarse, {Vec2f::Zero()}, fine).empty()); + CHECK(transfer_uv_channel(coarse, uv, indexed_triangle_set{}).empty()); +} + +TEST_CASE("the model's per-corner UVs survive a brush split and collapse to one per vertex", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 5.f); + const std::vector uv = sheet_uv_channel(sheet, size); + + // The whole sheet as a patch, with the source map the channel is read through. + const TriangleMesh tm(sheet); + TriangleSelector selector(tm); + for (int f = 0; f < int(sheet.indices.size()); ++f) + selector.set_facet(f, EnforcerBlockerType::ENFORCER); + std::vector source; + const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, &source); + REQUIRE(patch.indices.size() == source.size()); + + const std::vector corner = compute_model_corner_uvs(sheet, uv, patch, source); + REQUIRE(corner.size() == patch.indices.size() * 3); + // An unsplit patch triangle *is* its source triangle, so its corners reproduce the channel. + for (size_t f = 0; f < patch.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = patch.vertices[size_t(patch.indices[f][k])]; + CHECK_THAT(corner[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f)); + CHECK_THAT(corner[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f)); + } + + const std::vector per_vertex = collapse_corner_uvs(patch, corner); + REQUIRE(per_vertex.size() == patch.vertices.size()); + for (size_t i = 0; i < patch.vertices.size(); ++i) { + CHECK_THAT(per_vertex[i].x(), WithinAbs(sheet_uv_at(patch.vertices[i].x(), size), 1e-4f)); + CHECK_THAT(per_vertex[i].y(), WithinAbs(sheet_uv_at(patch.vertices[i].y(), size), 1e-4f)); + } + + // Mismatched inputs report nothing rather than guessing. + CHECK(compute_model_corner_uvs(sheet, {Vec2f::Zero()}, patch, source).empty()); + CHECK(compute_model_corner_uvs(sheet, uv, patch, {}).empty()); + CHECK(collapse_corner_uvs(patch, {Vec2f::Zero()}).empty()); +} + +TEST_CASE("facet paint is carried onto a rebuilt mesh by each facet's foot point", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f); + + // Two filaments, split down the middle - the pattern the transfer has to reproduce. + const TriangleMesh coarse_mesh(coarse); + TriangleSelector painter(coarse_mesh); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + painter.set_facet(int(t), x < 0.5f * size ? EnforcerBlockerType::Extruder1 : EnforcerBlockerType::Extruder2); + } + const TriangleSelector::TriangleSplittingData paint = painter.serialize(); + + // A finer mesh, lifted clear of the original surface the way a bake's relief lifts it. This is + // what ModelVolume::restore_painting()'s 0.01 mm overlap remap cannot follow. + indexed_triangle_set lifted = make_flat_sheet(size, 1.f); + for (Vec3f &v : lifted.vertices) + v.z() += 0.5f; + + const std::vector state = transfer_facet_paint(coarse, paint, lifted); + REQUIRE(state.size() == lifted.indices.size()); + + size_t left = 0, right = 0; + for (size_t t = 0; t < lifted.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = lifted.indices[t]; + const float x = (lifted.vertices[size_t(tri[0])].x() + lifted.vertices[size_t(tri[1])].x() + + lifted.vertices[size_t(tri[2])].x()) / 3.f; + // Only away from the split: a facet straddling it may legitimately land on either side. + if (x < 0.4f * size) { + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder1)); + ++left; + } else if (x > 0.6f * size) { + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder2)); + ++right; + } + } + REQUIRE(left > 0); + REQUIRE(right > 0); + + // An unpainted source has nothing to carry, which is not the same as carrying NONE everywhere: + // the caller has to tell those apart to decide between resetting the annotation and writing one. + CHECK(transfer_facet_paint(coarse, TriangleSelector::TriangleSplittingData{}, lifted).empty()); + CHECK(transfer_facet_paint(coarse, paint, indexed_triangle_set{}).empty()); +} + +TEST_CASE("an unpainted part of the source carries no paint onto the rebuilt mesh", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f); + + // Only the left half is painted at all; the right half must come back NONE rather than borrowing + // the nearest painted colour from across the model. + const TriangleMesh coarse_mesh(coarse); + TriangleSelector painter(coarse_mesh); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + if (x < 0.5f * size) + painter.set_facet(int(t), EnforcerBlockerType::Extruder3); + } + + const std::vector state = transfer_facet_paint(coarse, painter.serialize(), coarse); + REQUIRE(state.size() == coarse.indices.size()); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + if (x < 0.4f * size) + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder3)); + else if (x > 0.6f * size) + CHECK(state[t] == uint8_t(EnforcerBlockerType::NONE)); + } +}