Compare commits

..
Author SHA1 Message Date
ExPikaPaka dfab16c2f4 Carry existing colour across a bake instead of reindexing it
Baking, painting colour, then baking again left colour debris on surfaces
nothing had painted and destroyed what the earlier bake had laid down.

TextureDisplacementBakeJob::finalize() deserialized the volume's pre-bake
mmu_segmentation_facets into a TriangleSelector built on the post-bake mesh,
on the assumption that the bake is topology-preserving. That holds for the
classic path, which moves vertices and leaves the topology alone, but not for
the one-run pipeline, which rebuilds the mesh. TriangleSelector::deserialize()
only checks that the stored triangle ids are in range, and a bake almost
always grows the triangle count, so every stale id passed that check and
landed on an unrelated face.

Measured on a 26 mm slab baked twice at 0.19 mm with a 750 k budget: 7580
triangles ended up carrying paint neither bake produced, as far as 31 mm from
any colour that was actually laid down, while the first pass's colour was lost
where the second bake left the surface alone.

TriangleSelector::remap_painting() already transfers paint between meshes by
position, and ModelVolume::restore_painting() uses it for the same reason, so
the bake job now uses it too. It runs in process(), on the worker thread: it
builds an AABB tree over both meshes and took around a second on the case
above, which would have frozen the UI in finalize(). The bake's own colour is
still applied last, so it wins where it painted and the rest is kept, which is
what makes successive bakes accumulate rather than overwrite. If the volume is
painted while the bake is running, a timestamp check falls back to the live
mask so that stroke is not dropped.

The out_triangle comment in TextureColorRequest claimed the bake is
topology-preserving, which is what justified the original indexing; it now
says which mesh the colours belong to.

The regression test bakes twice, checks both results are closed, and checks
the first bake's colour survives the second at the positions where it was
laid down and nowhere else.
2026-10-12 09:43:59 +02:00
17 changed files with 295 additions and 1036 deletions
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2.5,2.5h11v11h-11z" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><path d="M8,2.5v11M2.5,8h11" style="fill:none;stroke:#949494;stroke-dasharray:1.5,1.5"/><path d="M4.5,11.5l3.5-6l3.5,6z" style="fill:#009688"/></svg>

Before

Width:  |  Height:  |  Size: 340 B

-22
View File
@@ -353,24 +353,6 @@ 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.
@@ -2151,10 +2133,6 @@ 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<TriangleSelector::SavedPainting> ModelVolume::save_painting() const
+2 -23
View File
@@ -974,26 +974,6 @@ 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<Vec2f> 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).
@@ -1256,7 +1236,6 @@ 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());
@@ -1355,7 +1334,7 @@ private:
cereal::load_by_value(ar, f);
mesh_changed |= tf != f.timestamp();
}
ar(texture_displacement_layers, texture_displacement_options, texture_uv);
ar(texture_displacement_layers, texture_displacement_options);
cereal::load_by_value(ar, config);
cereal::load(ar, text_configuration);
cereal::load(ar, emboss_shape);
@@ -1379,7 +1358,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, texture_uv);
ar(texture_displacement_layers, texture_displacement_options);
cereal::save_by_value(ar, config);
cereal::save(ar, text_configuration);
cereal::save(ar, emboss_shape);
+61 -270
View File
@@ -1672,165 +1672,6 @@ std::vector<Vec2f> 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<Vec2f> compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
const std::vector<Vec2f> &model_uv,
const indexed_triangle_set &patch,
const std::vector<int> &patch_source)
{
if (patch.indices.empty() || model_uv.size() != base_mesh.indices.size() * 3 ||
patch_source.size() != patch.indices.size())
return {};
std::vector<Vec2f> 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<Vec2f> collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector<Vec2f> &corner)
{
if (patch.indices.empty() || corner.size() != patch.indices.size() * 3)
return {};
std::vector<Vec2f> per_vertex(patch.vertices.size(), Vec2f::Zero());
std::vector<char> 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<Vec2f> transfer_uv_channel(const indexed_triangle_set &src_mesh,
const std::vector<Vec2f> &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<float>::max();
std::vector<Vec2f> out(dst_mesh.indices.size() * 3, Vec2f::Zero());
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst_mesh.indices.size()),
[&](const tbb::blocked_range<size_t> &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<uint8_t> 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<indexed_triangle_set> per_state;
selector.get_facets(per_state);
indexed_triangle_set soup;
std::vector<uint8_t> 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<uint8_t> out(dst_mesh.indices.size(), 0);
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst_mesh.indices.size()),
[&](const tbb::blocked_range<size_t> &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
@@ -1840,16 +1681,13 @@ struct UVTransform
float scale, cs, sn, aspect;
Vec2f offset;
// `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)
UVTransform(const TextureDisplacementLayer &layer, float aspect_)
{
scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
cs = std::cos(rad);
sn = std::sin(rad);
aspect = normalized_uv ? 1.f : aspect_;
aspect = aspect_;
offset = layer.offset;
}
Vec2f operator()(const Vec2f &planar) const
@@ -1874,11 +1712,9 @@ inline Vec3f triplanar_weights(const Vec3f &normal)
}
} // namespace
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv)
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect)
{
// 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 float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
const Vec2f scaled = planar * scale;
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
@@ -1896,7 +1732,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 (!normalized_uv && aspect > 0.f && aspect != 1.f)
if (aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
return rotated + layer.offset;
@@ -1921,9 +1757,6 @@ 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;
}
@@ -1953,21 +1786,14 @@ 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;
// 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);
const UVTransform xf(layer, aspect);
auto sample_at = [&](const Vec2f &planar) {
return texture.sample(xf(planar), layer.tile_enabled, layer.tile_method);
};
// 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.
// 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.
if (lscm_uv != nullptr)
return sample_at(*lscm_uv);
@@ -1990,8 +1816,7 @@ 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::ModelUV: // the volume carries no UV channel - same fallback
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar:
default: break;
}
@@ -2026,12 +1851,8 @@ 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;
// 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);
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
const UVTransform xf(layer, aspect);
auto sample_at = [&](const Vec2f &planar) {
return texture.sample_color(xf(planar), layer.tile_enabled, layer.tile_method);
};
@@ -2045,8 +1866,6 @@ 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);
@@ -2086,8 +1905,7 @@ 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::ModelUV: // the volume carries no UV channel - same fallback
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar:
default: break;
}
@@ -2392,10 +2210,9 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
TextureBakeStats *stats)
{
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data, model_uv);
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data);
if (!combined)
return mesh; // nothing decodable to displace with
@@ -2565,8 +2382,7 @@ 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, model_uv) :
ColorFieldSampler{};
want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure) : 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**
@@ -2698,8 +2514,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
TextureBakeStats *stats)
{
// The classic path moves the vertices the mesh already has, so there is no budget to report on.
(void) stats;
@@ -2736,7 +2551,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, model_uv);
debug, stats);
// 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).
@@ -2854,19 +2669,13 @@ 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<int> patch_source; // sub-triangle -> base mesh triangle; built when colouring or mapping by ModelUV
std::vector<int> patch_source; // sub-triangle -> base mesh triangle, only built when colouring
const indexed_triangle_set patch =
selector.get_facets_strict(EnforcerBlockerType::ENFORCER,
(color_this_layer || model_uv_layer) ? &patch_source : nullptr);
selector.get_facets_strict(EnforcerBlockerType::ENFORCER, color_this_layer ? &patch_source : nullptr);
if (patch.indices.empty())
continue;
// get_facets_strict() returns the same vertex array whichever state is asked for (only the
@@ -2897,25 +2706,18 @@ 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()).
// 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<Vec2f> model_corner_uvs = model_uv_layer ?
compute_model_corner_uvs(mesh, *model_uv, patch, patch_source) :
std::vector<Vec2f>{};
const std::vector<Vec2f> patch_uvs = model_uv_layer ? collapse_corner_uvs(patch, model_corner_uvs) :
(layer->projection_method == TextureProjectionMethod::LSCM) ?
const std::vector<Vec2f> lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_uvs(patch, *layer) :
std::vector<Vec2f>{};
// 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 patch_uvs - a vertex has one position, so one height.)
const std::vector<Vec2f> patch_corner_uvs = model_uv_layer ? model_corner_uvs :
(layer->projection_method == TextureProjectionMethod::LSCM) ?
// (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.)
const std::vector<Vec2f> lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_corner_uvs(patch, *layer) :
std::vector<Vec2f>{};
const bool corner_uv_ok = !patch_corner_uvs.empty() &&
patch_corner_uvs.size() == patch.indices.size() * 3;
const bool corner_uv_ok = !lscm_corner_uvs.empty() &&
lscm_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
@@ -2945,7 +2747,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 = !patch_uvs.empty();
bool have_uv = !lscm_uvs.empty();
for (int k = 0; k < 3; ++k) {
const int vi = t[k];
if (vi < int(vertex_normals.size()))
@@ -2953,9 +2755,9 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (!have_uv)
continue;
if (corner_uv_ok)
uv += patch_corner_uvs[j * 3 + size_t(k)];
else if (size_t(vi) < patch_uvs.size())
uv += patch_uvs[size_t(vi)];
uv += lscm_corner_uvs[j * 3 + size_t(k)];
else if (size_t(vi) < lscm_uvs.size())
uv += lscm_uvs[size_t(vi)];
else
have_uv = false;
}
@@ -3029,7 +2831,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
[&](const tbb::blocked_range<size_t> &range) {
for (size_t k = range.begin(); k < range.end(); ++k) {
const size_t vi = size_t(layer_vertices[k]);
const Vec2f *lscm_uv = patch_uvs.empty() ? nullptr : &patch_uvs[vi];
const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_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
@@ -3162,8 +2964,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const TextureColorRequest *color,
const Transform3d &volume_to_world,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
TextureBakeStats *stats)
{
// 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
@@ -3173,7 +2974,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, model_uv);
false, debug, stats);
const Transform3d to_local = frame.inverse();
// A mirroring placement leaves the positions correct but every winding-derived normal pointing
@@ -3187,10 +2988,8 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
v = (frame * v.cast<double>()).cast<float>();
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, model_uv);
progress, color, mirrored, debug, stats);
// 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)
@@ -3262,8 +3061,7 @@ 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), nullptr, nullptr,
volume.has_texture_uv() ? &volume.texture_uv : nullptr);
texture_displacement_volume_to_world(volume));
}
void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t> &movable, float strength,
@@ -3327,18 +3125,16 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t>
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.
// 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.
// 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.
struct LscmLookup {
indexed_triangle_set patch; // the layer's painted patch, as the uvs were computed on
indexed_triangle_set patch; // the layer's painted patch, as the unwrap was solved on
AABBTreeIndirect::Tree3f tree;
// compute_lscm_corner_uvs(patch, layer), or compute_model_corner_uvs() for a ModelUV layer
std::vector<Vec2f> corner;
std::vector<Vec2f> corner; // compute_lscm_corner_uvs(patch, layer)
// 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.
@@ -3351,9 +3147,15 @@ struct LscmLookup {
if (d2 < 0.f || d2 > tol * tol || hit >= patch.indices.size())
return false;
const stl_triangle_vertex_indices &t = patch.indices[hit];
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 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;
}
const Vec2f *c3 = &corner[hit * 3];
uv = (1.f - w1 - w2) * c3[0] + w1 * c3[1] + w2 * c3[2];
return true;
@@ -3412,7 +3214,7 @@ struct PreparedLayer {
// where a layer is placed. `need_color` additionally drops layers that cannot contribute colour.
std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
const indexed_triangle_set &base_mesh, const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data, bool need_color, const std::vector<Vec2f> *model_uv)
const TextureDisplacementFacetsData &facets_data, bool need_color)
{
auto prepared = std::make_shared<std::vector<PreparedLayer>>();
@@ -3443,14 +3245,9 @@ std::shared_ptr<std::vector<PreparedLayer>> 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);
std::vector<int> 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);
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
continue;
@@ -3460,16 +3257,12 @@ std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal);
std::shared_ptr<const LscmLookup> lscm;
if (model_uv_layer || layer->projection_method == TextureProjectionMethod::LSCM) {
if (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. 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.
// shows it, hand-placed islands and UV edits included.
auto l = std::make_shared<LscmLookup>();
l->corner = model_uv_layer ? compute_model_corner_uvs(base_mesh, *model_uv, patch, patch_source) :
compute_lscm_corner_uvs(patch, *layer);
l->corner = 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);
@@ -3495,12 +3288,11 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
ColorQuantizeFn quantize_pure,
const std::vector<Vec2f> *model_uv)
ColorQuantizeFn quantize_pure)
{
if (!quantize)
return nullptr;
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true, model_uv);
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true);
if (prepared->empty())
return nullptr;
// Per layer: a flat-colour image is matched against the filaments alone, when that quantizer
@@ -3531,10 +3323,9 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
const std::vector<Vec2f> *model_uv)
const TextureDisplacementFacetsData &facets_data)
{
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false, model_uv);
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false);
if (prepared->empty())
return nullptr;
+10 -117
View File
@@ -86,17 +86,6 @@ 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
@@ -132,11 +121,6 @@ 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`.
@@ -557,13 +541,7 @@ 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.
//
// `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);
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f);
// 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 -
@@ -713,80 +691,6 @@ std::vector<Vec2f> 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<Vec2f> 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<Vec2f> compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
const std::vector<Vec2f> &model_uv,
const indexed_triangle_set &patch,
const std::vector<int> &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<Vec2f> collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector<Vec2f> &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<Vec2f> transfer_uv_channel(const indexed_triangle_set &src_mesh,
const std::vector<Vec2f> &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<uint8_t> 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); }
@@ -845,10 +749,6 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
//
// `color`, when given, also reports which filament each triangle should print in - see
// TextureColorRequest.
//
// `model_uv`, when given, is the volume's own UV channel (three uvs per base-mesh triangle, see
// ModelVolume::texture_uv) and is what a TextureProjectionMethod::ModelUV layer maps through. A layer
// set to ModelUV without it falls back to Triplanar.
struct TextureColorRequest
{
// RGB -> palette index. Supplied by the GUI, which owns both the perceptual matching and the list
@@ -871,9 +771,12 @@ struct TextureColorRequest
// single facets; an image detail a few facets wide still leaves thousands of pinhead islands
// that the slicer's multi-material segmentation cannot digest. 0 turns it off.
float min_color_region_mm2 = 0.5f;
// Filled per *base mesh* triangle (the bake is topology-preserving, so this indexes the returned
// mesh too): the quantize callback's index plus one, or 0 for "this triangle takes no colour from
// the texture". The +1 lines up with EnforcerBlockerType, where 0 is NONE ("use the volume's own
// Filled per triangle of the *returned* mesh: the quantize callback's index plus one, or 0 for
// "this triangle takes no colour from the texture". That is the same indexing as the base mesh
// only on the classic path, which moves vertices and leaves the topology alone; the one-run
// pipeline rebuilds the mesh, so nothing recorded against the base mesh - an existing paint mask,
// say - may be carried over by triangle id (see TriangleSelector::remap_painting()).
// The +1 lines up with EnforcerBlockerType, where 0 is NONE ("use the volume's own
// filament"): where every palette entry is a filament, these go straight to a TriangleSelector. A
// palette with mixes maps each index to the mix's filament slot first (see
// GLGizmoTextureDisplacement::palette_filaments()).
@@ -923,9 +826,7 @@ 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,
// The model's own UV channel; see above.
const std::vector<Vec2f> *model_uv = nullptr);
TextureBakeStats *stats = 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
@@ -1000,15 +901,11 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
ColorQuantizeFn quantize_pure = nullptr,
// The model's own UV channel, for ModelUV layers; see
// build_texture_displacement().
const std::vector<Vec2f> *model_uv = nullptr);
ColorQuantizeFn quantize_pure = nullptr);
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
const std::vector<Vec2f> *model_uv = nullptr);
const TextureDisplacementFacetsData &facets_data);
// 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
@@ -1213,10 +1110,6 @@ 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<Vec2f> 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;
-21
View File
@@ -25,7 +25,6 @@
#include "TextureToColor/ColorUtils.hpp"
#include "Model.hpp"
#include "TextureDisplacement.hpp"
#include "TextureToColor/TriMesh.hpp"
#include "TriangleMesh.hpp"
#include "TriangleSelector.hpp"
@@ -555,29 +554,9 @@ 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<Vec2f> 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
@@ -1221,33 +1221,9 @@ indexed_triangle_set GLGizmoTextureDisplacement::patch_in_world(const indexed_tr
return world;
}
std::vector<Vec2f> GLGizmoTextureDisplacement::model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *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<Vec2f> 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<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
const TextureDisplacementLayer &layer) 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 -
@@ -1307,11 +1283,8 @@ int GLGizmoTextureDisplacement::layer_projection_frame(const indexed_triangle_se
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
const TextureDisplacementLayer &layer) 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);
@@ -1321,7 +1294,7 @@ std::vector<Vec2f> 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<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer, patch_source);
const std::vector<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer);
if (per_vertex.size() != local_patch.vertices.size())
return {};
std::vector<Vec2f> corner(local_patch.indices.size() * 3);
@@ -1342,11 +1315,7 @@ 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.
// The source map comes along because a ModelUV layer's uvs are read per base-mesh triangle; every
// other mapping ignores it.
std::vector<int> patch_source;
const indexed_triangle_set patch =
m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source);
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
return;
@@ -1370,8 +1339,7 @@ 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<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active, &patch_source) :
std::vector<Vec2f>{};
std::vector<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector<Vec2f>{};
m_shaded_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3;
if (!m_shaded_preview_uses_vertex_uv)
corner_uv.clear();
@@ -2028,9 +1996,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
const ModelVolume *mv = texture_volume();
if (mv == nullptr || m_triangle_selectors.empty())
return;
std::vector<int> 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);
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
return;
// Everything below works in the *patch's* vertex space, not the mesh's. Those agree only until a
@@ -2044,7 +2010,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<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer, &patch_source);
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer);
const bool have_uvs = uv.size() == patch.vertices.size();
m_uvcheck_uses_vertex_uv = have_uvs;
m_uvcheck_projection_mode = have_uvs ? 0 :
@@ -2053,7 +2019,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<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer, &patch_source);
const std::vector<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer);
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.
@@ -2329,8 +2295,6 @@ 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
@@ -3712,8 +3676,7 @@ 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_map_model_uv.svg",
"texture_displacement_tile_repeat.svg", "menu_mirror_x.svg",
"texture_displacement_map_view.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",
@@ -3760,11 +3723,6 @@ 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.
@@ -4910,8 +4868,7 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve
TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const std::vector<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings, const DisplacementProgressFn &progress,
BakeStageRecorder *debug)
{
@@ -4938,13 +4895,6 @@ 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;
@@ -4991,11 +4941,9 @@ 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<Vec2f> uv_stage = uv_now();
const std::vector<Vec2f> *uv_ptr = uv_stage.empty() ? nullptr : &uv_stage;
HeightFieldSampler sampler;
if (params.subdiv_feature)
sampler = make_combined_displacement_sampler(mesh.its, layers, current, uv_ptr);
sampler = make_combined_displacement_sampler(mesh.its, layers, current);
// 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;
@@ -5003,7 +4951,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), uv_ptr);
make_palette_quantizer(color_settings.palette_pure));
// 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
@@ -5062,10 +5010,8 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
std::vector<uint8_t> 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<Vec2f> uv_refined = uv_now();
const HeightFieldSampler refined_sampler =
make_combined_displacement_sampler(mesh.its, layers, current,
uv_refined.empty() ? nullptr : &uv_refined);
make_combined_displacement_sampler(mesh.its, layers, current);
std::vector<int> cut_source;
size_t cuts = 0;
indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler,
@@ -5089,7 +5035,6 @@ 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;
@@ -5741,8 +5686,6 @@ 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);
@@ -6511,22 +6454,10 @@ 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."));
// 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("##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."));
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."));
@@ -6633,7 +6564,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
}
// Mapping, as one icon per TextureProjectionMethod, in the enum's own order.
// Mapping, as five icons in TextureProjectionMethod's own order.
{
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
@@ -6661,26 +6592,13 @@ 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<TextureProjectionMethod>(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, na) &&
mappings[mi].tip) &&
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.
@@ -6810,33 +6728,18 @@ 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_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");
const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(), blend_divide.c_str() };
// 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 = 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;
int blend_mode = base_layer ? 0 : static_cast<int>(layer.blend_mode);
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 &&
blend_mode >= 0 && blend_mode < int(IM_ARRAYSIZE(blend_modes))) {
layer.blend_mode = blend_modes[blend_mode];
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) {
layer.blend_mode = static_cast<TextureBlendMode>(blend_mode);
m_preview_params_dirty = true;
}
m_imgui->disabled_end();
@@ -6844,11 +6747,10 @@ 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. 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."),
"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."),
wrap_w);
ImGui::SameLine(0.f, gap_s);
@@ -61,12 +61,6 @@ public:
static TextureDisplacementPrepareResult prepare_mesh(const indexed_triangle_set &base,
const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &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<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings,
const DisplacementProgressFn &progress,
@@ -824,31 +818,18 @@ private:
// Merges chart `b`'s join group into chart `a`'s (materialising `groups` to `chart_count` first).
static void join_island_groups(std::vector<int> &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), 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.
// 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.
std::vector<Vec2f> compute_layer_vertex_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source = nullptr) const;
const TextureDisplacementLayer &layer) 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<Vec2f> compute_layer_corner_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *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<Vec2f> model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const;
const TextureDisplacementLayer &layer) 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
@@ -8,12 +8,12 @@
#include "libslic3r/TextureDisplacement.hpp"
#include <exception>
#include <cstddef>
#include <cstdint>
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
#include "libslic3r/Model.hpp"
#include "libslic3r/format.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "slic3r/GUI/GLCanvas3D.hpp"
@@ -27,7 +27,6 @@
#include "slic3r/Utils/UndoRedo.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <array>
namespace Slic3r::GUI {
@@ -76,8 +75,24 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
}
return true;
},
color, m_input.volume_to_world, nullptr, &m_stats,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv));
color, m_input.volume_to_world, nullptr, &m_stats));
// Bring the colour that was already painted onto the mesh this bake just built, while still on
// the worker thread. Triangle ids only mean the same thing on both meshes when the bake preserved
// the topology - the classic path moves vertices and nothing else - and the one-run pipeline
// rebuilds the mesh from scratch. Deserializing the old mask against the new one only checks that
// the ids are in range, and a bake almost always *grows* the triangle count, so every stale id
// passes that check and lands on an unrelated face: colour debris where nothing was painted, and
// the earlier pass's colour destroyed rather than kept. remap_painting() transfers it by position
// instead, which is what lets successive bakes accumulate. It is spatial work over both meshes,
// which is why it belongs here and not in finalize().
if (!m_input.mmu_segmentation.bitstream.empty() && !m_result.empty() && !ctl.was_canceled()) {
if (m_result.its.indices.size() == m_input.base_mesh.indices.size())
m_carried_mmu = m_input.mmu_segmentation;
else
m_carried_mmu = TriangleSelector::remap_painting(m_input.base_mesh, m_input.mmu_segmentation,
m_result.its, Transform3d::Identity(), {});
}
// Always finish at 100: this is what closes the notification. Reported even on cancel, where
// build_texture_displacement() returns an empty mesh and finalize() commits nothing.
@@ -129,84 +144,39 @@ 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<TriangleSelector::TriangleSplittingData, 4> 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();
}
// Read while the volume still holds the mesh the paint was recorded against, and after the
// filament slots above - adding one can clamp the paint, and that clamp has to survive.
// Normally process() has already carried the colour onto the baked topology; it only has to
// be redone here when the paint changed after the job was queued, because the snapshot
// process() worked from is then not what the user is looking at.
const bool mmu_changed = volume->mmu_segmentation_facets.timestamp() != m_input.mmu_segmentation_timestamp;
const indexed_triangle_set pre_bake_mesh = mmu_changed ? volume->mesh().its
: indexed_triangle_set{};
const TriangleSelector::TriangleSplittingData live_mmu = mmu_changed
? volume->mmu_segmentation_facets.get_data()
: TriangleSelector::TriangleSplittingData{};
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
if (topology_changed) {
const std::array<FacetsAnnotation *, 4> 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<uint8_t> 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
// filament". That is what confines the effect to the painted area without having to invent a
// colour for everything outside it. Safe to index straight onto the new mesh - the bake is
// topology-preserving, so triangle i is still triangle i.
// colour for everything outside it. What was already painted comes from m_carried_mmu, which
// process() brought onto this mesh's topology; this bake's own colour goes on top of it, so it
// wins wherever it painted and leaves the rest alone.
if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) {
if (mmu_changed && !live_mmu.bitstream.empty())
m_carried_mmu = pre_bake_mesh.indices.size() == volume->mesh().its.indices.size()
? live_mmu
: TriangleSelector::remap_painting(pre_bake_mesh, live_mmu,
volume->mesh().its,
Transform3d::Identity(), {});
TriangleSelector selector(volume->mesh());
const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
if (!existing.bitstream.empty())
selector.deserialize(existing, false);
if (!m_carried_mmu.bitstream.empty())
selector.deserialize(m_carried_mmu, false);
for (size_t i = 0; i < m_triangle_color.size(); ++i) {
const size_t entry = size_t(m_triangle_color[i]);
if (entry > 0 && entry <= filament_of.size() && filament_of[entry - 1] >= 0)
@@ -225,6 +195,7 @@ 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; });
@@ -284,11 +255,11 @@ 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);
input.mmu_segmentation = volume.mmu_segmentation_facets.get_data();
input.mmu_segmentation_timestamp = volume.mmu_segmentation_facets.timestamp();
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
@@ -10,6 +10,7 @@
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "Job.hpp"
@@ -34,9 +35,13 @@ 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<Vec2f> model_uv;
// The colour already painted on the volume, indexed against `base_mesh`. Carried here rather than
// read at commit time because the one-run pipeline rebuilds the mesh, and bringing this across to
// the new topology is spatial work (see process()) that has no business on the UI thread.
TriangleSelector::TriangleSplittingData mmu_segmentation;
// What `mmu_segmentation` was taken from, so the commit can tell whether the volume was painted
// while the bake ran and fall back to the live data if it was.
ObjectBase::Timestamp mmu_segmentation_timestamp = 0;
// Whether this job pushes its own undo step when it commits. False when the caller has already
// taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes
// and subdivides first and has to undo as a single action.
@@ -63,6 +68,9 @@ private:
// Empty unless a layer asked for colour. See TextureColorRequest; finalize() turns these into
// filaments.
std::vector<uint8_t> m_triangle_color;
// m_input.mmu_segmentation brought onto m_result's topology, so finalize() can lay this bake's
// colour over the colour that was already there. Indexed against m_result, not base_mesh.
TriangleSelector::TriangleSplittingData m_carried_mmu;
std::function<void()> m_on_finished;
};
@@ -17,7 +17,6 @@
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TriangleMesh.hpp"
namespace Slic3r::GUI {
@@ -53,14 +52,13 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
indexed_triangle_set mesh = m_input.base_mesh;
TextureDisplacementFacetsData masks = m_input.facets_data;
std::vector<Vec2f> 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, uv, m_input.prepare_params,
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params,
m_input.color,
// Preparation is roughly half the run; the bake
// takes the progress bar from there.
@@ -79,14 +77,12 @@ 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, /* stats */ nullptr,
uv.empty() ? nullptr : &uv);
/* color */ nullptr, m_input.volume_to_world, &recorder);
m_stages = recorder.take();
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
@@ -119,8 +115,6 @@ 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();
@@ -47,9 +47,6 @@ 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<Vec2f> 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
@@ -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.model_uv, m_input.params, m_input.color,
m_input.params, m_input.color,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
@@ -106,10 +106,6 @@ 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;
@@ -9,7 +9,6 @@
#include "Job.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TextureDisplacement.hpp"
namespace Slic3r {
@@ -48,10 +47,6 @@ 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<Vec2f> 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;
@@ -48,8 +48,7 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
(!m_current_generation ||
m_current_generation->load() == m_generation);
},
color, m_input.volume_to_world, nullptr, nullptr,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv);
color, m_input.volume_to_world);
}
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
@@ -29,9 +29,6 @@ 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<Vec2f> model_uv;
};
// A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per
+131 -331
View File
@@ -35,10 +35,12 @@
#include <boost/filesystem.hpp>
#include "libslic3r/AABBMesh.hpp"
#include "libslic3r/AABBTreeIndirect.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TextureBake/TextureBakeDecimate.hpp"
#include "libslic3r/TextureBake/TextureBakeFlip.hpp"
#include "libslic3r/TextureBake/TextureBakeMesh.hpp"
#include "libslic3r/TextureBake/TextureBakeRepair.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/PNGReadWrite.hpp"
@@ -2175,352 +2177,150 @@ 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<std::vector<unsigned char>> make_left_right_split_png(size_t w = 16, size_t h = 16)
TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colours across",
"[TextureDisplacement][Regression]")
{
std::vector<uint8_t> 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<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(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<Vec2f> sheet_uv_channel(const indexed_triangle_set &sheet, float size, bool mirror_u = false)
{
std::vector<Vec2f> 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<Vec2f> uv = sheet_uv_channel(sheet, size);
const std::vector<Vec2f> 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.);
// Bake, colour part of what came back, bake again. The one-run pipeline rebuilds the topology, so
// the colour recorded against the first bake's mesh indexes triangles that no longer mean the same
// thing. Taken across by triangle id it lands on unrelated faces - a bake almost always grows the
// triangle count, so every stale id passes the only bound check deserialize() makes - which both
// scatters colour over surfaces nothing painted and destroys what the first pass laid down.
// TriangleSelector::remap_painting() carries it by position instead, which is what makes successive
// bakes accumulate. See TextureDisplacementBakeJob, which is where the two meet.
constexpr float side = 20.f;
constexpr float refine = 0.5f;
constexpr float radius = 4.f;
const indexed_triangle_set cube = its_make_cube(side, side, side);
const Vec2f first_stroke(6.f, 6.f), second_stroke(14.f, 14.f);
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
layer.image_data = make_rgb_png_2x2();
layer.tiling_scale = 5.f;
layer.depth_mm = 0.5f;
layer.color_enabled = true;
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(cube);
TextureDisplacementOptions options;
options.pipeline_v2 = true;
options.v2_refine_mm = refine;
options.v2_max_triangles_k = 750;
std::vector<uint8_t> triangle_color;
TextureColorRequest request;
request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked
request.out_triangle = &triangle_color;
// Three primaries, so a palette entry is legible in the assertions below.
const std::array<Vec3f, 3> palette = { Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(0, 0, 1) };
TextureColorRequest request;
request.quantize = [&palette](const Vec3f &rgb) {
int best = 0;
float bd = std::numeric_limits<float>::max();
for (int i = 0; i < 3; ++i)
if (const float d = (palette[size_t(i)] - rgb).squaredNorm(); d < bd) { bd = d; best = i; }
return best;
};
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);
}
const auto bake = [&](const indexed_triangle_set &mesh, const Vec2f &stroke, std::vector<uint8_t> &color) {
TextureDisplacementFacetsData facets;
facets[0] = paint_top_disc(mesh, side, stroke, radius);
TextureColorRequest req = request;
req.out_triangle = &color;
return build_texture_displacement(mesh, { layer }, facets, options, {}, &req);
};
// The pipeline's own counters, over welded positions, so a mesh watertight only after welding
// still reads as closed.
const auto defects = [](const indexed_triangle_set &mesh) {
return TextureBake::count_edge_defects(TextureBake::to_soup(mesh));
};
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<Vec2f> uv = sheet_uv_channel(coarse, size);
std::vector<uint8_t> color1;
const indexed_triangle_set first = bake(cube, first_stroke, color1);
REQUIRE(first.indices.size() > cube.indices.size());
REQUIRE(color1.size() == first.indices.size());
const TextureBake::EdgeDefects d1 = defects(first);
INFO("first bake: " << d1.triangles << " triangles, " << d1.open << " open, " << d1.non_manifold << " non-manifold");
CHECK(d1.open == 0);
CHECK(d1.non_manifold == 0);
const std::vector<Vec2f> 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<Vec2f> 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<int> source;
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, &source);
REQUIRE(patch.indices.size() == source.size());
const std::vector<Vec2f> 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<Vec2f> 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));
// The colour the first bake committed, as TextureDisplacementBakeJob writes it.
TriangleSelector::TriangleSplittingData painted_first;
{
const TriangleMesh mesh(first);
TriangleSelector selector(mesh);
for (size_t i = 0; i < color1.size(); ++i)
if (color1[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(color1[i]));
painted_first = selector.serialize();
}
REQUIRE_FALSE(painted_first.bitstream.empty());
// 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());
}
std::vector<uint8_t> color2;
const indexed_triangle_set second = bake(first, second_stroke, color2);
REQUIRE(color2.size() == second.indices.size());
REQUIRE(second.indices.size() != first.indices.size()); // the topology really did change
const TextureBake::EdgeDefects d2 = defects(second);
INFO("second bake: " << d2.triangles << " triangles, " << d2.open << " open, " << d2.non_manifold << " non-manifold");
CHECK(d2.open == 0);
CHECK(d2.non_manifold == 0);
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<uint8_t> 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;
// The commit: the earlier colour carried onto the new topology, this bake's own colour over it.
std::vector<uint8_t> committed(second.indices.size(), 0);
{
const TriangleMesh mesh(second);
TriangleSelector selector(mesh);
const TriangleSelector::TriangleSplittingData carried = TriangleSelector::remap_painting(
first, painted_first, second, Transform3d::Identity(), {});
REQUIRE_FALSE(carried.bitstream.empty());
selector.deserialize(carried, false);
for (size_t i = 0; i < color2.size(); ++i)
if (color2[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(color2[i]));
for (int state = 1; state <= int(palette.size()); ++state) {
std::vector<int> src;
if (selector.get_facets_strict(EnforcerBlockerType(state), &src).indices.empty())
continue;
for (const int s : src)
if (s >= 0 && size_t(s) < committed.size() && committed[size_t(s)] == 0)
committed[size_t(s)] = uint8_t(state);
}
}
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());
}
// Just the triangles the first bake coloured, to ask "how far is this from colour that was really
// laid down" of anything the commit painted.
indexed_triangle_set coloured_first;
coloured_first.vertices = first.vertices;
for (size_t i = 0; i < color1.size(); ++i)
if (color1[i] != 0)
coloured_first.indices.push_back(first.indices[i]);
const AABBTreeIndirect::Tree3f tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(
coloured_first.vertices, coloured_first.indices);
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<uint8_t> 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));
// A triangle may carry colour the second bake did not produce only where the first bake's colour
// actually was. Carrying the mask by position can widen that region by a triangle, so the margin
// is the edge length the bake was told to refine to; landing anywhere else means the mask was read
// against a mesh it no longer matches.
size_t own = 0, carried_over = 0, stranded = 0;
float worst = 0.f;
for (size_t i = 0; i < second.indices.size(); ++i) {
if (committed[i] == 0)
continue;
if (i < color2.size() && color2[i] != 0) {
CHECK(committed[i] == color2[i]); // this bake's own colour wins where it painted
++own;
continue;
}
const stl_triangle_vertex_indices &t = second.indices[i];
const Vec3f g = (second.vertices[size_t(t[0])] + second.vertices[size_t(t[1])] +
second.vertices[size_t(t[2])]) / 3.f;
size_t hit = 0;
Vec3f foot;
const float d = std::sqrt(std::max(0.f, AABBTreeIndirect::squared_distance_to_indexed_triangle_set(
coloured_first.vertices, coloured_first.indices, tree, g, hit, foot)));
worst = std::max(worst, d);
if (d <= 2.f * refine)
++carried_over;
else
++stranded;
}
INFO("committed: " << own << " from this bake, " << carried_over << " carried over, " << stranded
<< " stranded, worst distance from the first bake's colour " << worst << " mm");
CHECK(own > 0);
CHECK(carried_over > 0); // the first bake's colour survived the second
CHECK(stranded == 0); // and nothing was painted where neither bake put colour
}