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