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)); + } +}