Colour only the faces the paint actually covered

A bake coloured anything within the relief depth of the painted area, matching
it back by proximity. On a part thinner than that depth the surface nearest the
opposite face is the painted one, so the texture appeared there too, and on the
sides as well.

Proximity cannot answer this: the result is displaced geometry, so a face is no
longer where its base surface was. Inverting the relief makes a pushed-in face
and the far side of a thin wall indistinguishable by distance or by normal.

The pipeline now records, per face, whether the paint covered the geometry it
came from. That is taken on the refined mesh, where exclude_weight still says
exactly which faces the paint left out, and carried through decimation, the
T-junction repair and the weld rather than sampled again.

Only the painted/unpainted split is reliable in that record. The colour beside
it is sampled at displaced positions while the sampler answers for the base
surface, so a painted face can come back without one - FACE_NO_COLOUR, not
FACE_UNPAINTED. Where to sample still comes from the base surface, as before.

The old proximity test remains as a fallback when the per-face record does not
line up with the output.
This commit is contained in:
ExPikaPaka
2026-10-07 08:52:54 +02:00
parent fb74c8710c
commit 1f7a8ec91b
9 changed files with 118 additions and 25 deletions
@@ -551,9 +551,13 @@ DecimateResult decimate(const TriSoup &geometry, size_t target_triangles, bool h
// Rebuild from the surviving faces, with per-face normals.
TriSoup &out = result.geometry;
if (!face_color.empty())
result.face_color.reserve(active_faces);
for (size_t f = 0; f < face_count; ++f) {
if (faces[f * 3] < 0)
continue;
if (!face_color.empty())
result.face_color.push_back(f < face_color.size() ? face_color[f] : -1);
const Vec3f a = pos[size_t(faces[f * 3])].cast<float>();
const Vec3f b = pos[size_t(faces[f * 3 + 1])].cast<float>();
const Vec3f c = pos[size_t(faces[f * 3 + 2])].cast<float>();
@@ -46,6 +46,13 @@ using DecimateProgressFn = std::function<bool(double fraction)>;
struct DecimateResult
{
TriSoup geometry;
// One entry per output face, carried from the `face_color` handed in: a colour difference is a
// crease, so no collapse ever merges two faces of different colour and every survivor keeps exactly
// the colour it came with. Empty when no `face_color` was given.
//
// This is what lets the caller colour the simplified mesh by *provenance* rather than by sampling it
// again: the input colours were masked by the paint on the fine mesh, where that mask is exact.
std::vector<int> face_color;
// The locked faces alone met the target, so it was unreachable without touching preserved
// geometry.
bool locked_over_budget = false;
+11 -1
View File
@@ -42,7 +42,7 @@ TriSoup to_soup(const indexed_triangle_set &its, const std::vector<uint8_t> &fac
return out;
}
indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup)
indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup, std::vector<int> *face_color)
{
indexed_triangle_set out;
const size_t n = soup.pos.size();
@@ -54,12 +54,22 @@ indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup)
if (map.inserted())
out.vertices.push_back(soup.pos[i]);
}
const bool track_color = face_color != nullptr && !face_color->empty();
std::vector<int> kept_color;
if (track_color)
kept_color.reserve(face_color->size());
for (size_t t = 0; t + 2 < n; t += 3) {
// Welded-together corners carry no area.
if (id[t] == id[t + 1] || id[t + 1] == id[t + 2] || id[t] == id[t + 2])
continue;
out.indices.emplace_back(id[t], id[t + 1], id[t + 2]);
if (track_color) {
const size_t src = t / 3;
kept_color.push_back(src < face_color->size() ? (*face_color)[src] : -1);
}
}
if (track_color)
*face_color = std::move(kept_color);
return out;
}
@@ -15,7 +15,10 @@ namespace TextureBake {
TriSoup to_soup(const indexed_triangle_set &its, const std::vector<uint8_t> &face_excluded = {});
// Welds at the geometry grid.
indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup);
// `face_color`, when given, is read as one entry per soup triangle and rewritten to match the output.
// Welding can leave a triangle with no area, and those are dropped here, so the two would otherwise
// fall out of step.
indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup, std::vector<int> *face_color = nullptr);
} // namespace TextureBake
} // namespace Slic3r
@@ -290,6 +290,34 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
return result;
}
// Colour per face, taken here and carried from here on. This is the only point where the paint mask
// is exact: `exclude_weight` says which faces the paint left out, and the mesh is still the refined
// one the displacement produced. Everything downstream (the collapse, the T-junction repair) carries
// these along rather than sampling again, and the caller uses them as they are.
//
// It also gives the collapse its crease criterion: an edge between two colours is never collapsed
// across, which is what keeps a survivor's colour well defined.
if (color_sample) {
const size_t nf = displaced.triangle_count();
result.face_color.assign(nf, -1);
const bool have_w = !displaced.exclude_weight.empty();
tbb::parallel_for(tbb::blocked_range<size_t>(0, nf), [&](const tbb::blocked_range<size_t> &r) {
for (size_t t = r.begin(); t < r.end(); ++t) {
// Unpainted faces take no colour at all, which is what stops the texture appearing on
// surfaces the paint never covered.
if (have_w && (displaced.exclude_weight[t * 3] + displaced.exclude_weight[t * 3 + 1] +
displaced.exclude_weight[t * 3 + 2]) / 3.f > 0.99f)
continue; // stays FACE_UNPAINTED
const Vec3f &a = displaced.pos[t * 3], &b = displaced.pos[t * 3 + 1], &c = displaced.pos[t * 3 + 2];
const int sampled = color_sample((a + b + c) / 3.f, displaced.nrm[t * 3]);
// Painted either way. The sampler expects a point on the base surface and these are on
// the displaced one, so off the patch by more than its tolerance it simply says "no
// colour" - which must not be confused with "not painted".
result.face_color[t] = (sampled >= 0) ? sampled : FACE_NO_COLOUR;
}
});
}
// 4. Decimate - export only. A bake needs the face-parent map, which a collapse destroys.
std::vector<int> parent = std::move(sub.face_parent_id);
const size_t displaced_before_decimate = displaced.triangle_count();
@@ -323,24 +351,8 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
// unless the budget was lowered until decimation had to run. Only collapses costing less than
// harvest_tol are taken, so this does not reach the relief.
const bool harvest_only = !over_budget && settings.harvest_flat && displaced.triangle_count() > 0;
std::vector<int> &face_color = result.face_color;
if (over_budget || harvest_only) {
// Colour per face on the fine mesh, so colour boundaries become creases the collapse
// respects. Excluded (unpainted) faces take no colour.
std::vector<int> face_color;
if (color_sample) {
const size_t nf = displaced.triangle_count();
face_color.assign(nf, -1);
const bool have_w = !displaced.exclude_weight.empty();
tbb::parallel_for(tbb::blocked_range<size_t>(0, nf), [&](const tbb::blocked_range<size_t> &r) {
for (size_t t = r.begin(); t < r.end(); ++t) {
if (have_w && (displaced.exclude_weight[t * 3] + displaced.exclude_weight[t * 3 + 1] +
displaced.exclude_weight[t * 3 + 2]) / 3.f > 0.99f)
continue;
const Vec3f &a = displaced.pos[t * 3], &b = displaced.pos[t * 3 + 1], &c = displaced.pos[t * 3 + 2];
face_color[t] = color_sample((a + b + c) / 3.f, displaced.nrm[t * 3]);
}
});
}
// Harvesting alone is asked for by handing it the count it already has: nothing is then
// over the target, so the loop only ever pops collapses under the tolerance.
const size_t before = displaced.triangle_count();
@@ -350,6 +362,7 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
result.locked_over_budget = dec.locked_over_budget;
result.budget_limited = result.simplified = dec.target_cost_detail;
displaced = std::move(dec.geometry);
face_color = std::move(dec.face_color);
lap("decimate", displaced, over_budget ? "over budget, simplified" : "flat faces harvested");
BOOST_LOG_TRIVIAL(info) << "TextureBake decimate: " << before << " -> " << displaced.triangle_count()
<< (over_budget ? " (budget " : " (flat harvest, budget ") << target << ")";
@@ -377,7 +390,7 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
// 6. Close the T-junctions decimation left behind. Only meaningful when it ran.
if (mode == PipelineMode::Export && parent.empty()) {
displaced = resolve_t_junctions(displaced);
displaced = resolve_t_junctions(displaced, {}, &result.face_color);
lap("repair", displaced);
}
@@ -109,9 +109,27 @@ using PipelineProgressFn = std::function<bool(const char *stage, double fraction
// colour-boundary creases. Only consulted when the mesh is over budget.
using ColorSampleFn = std::function<int(const Vec3f &centroid, const Vec3f &normal)>;
// Sentinels for PipelineResult::face_color.
static constexpr int FACE_UNPAINTED = -1; // the paint did not cover this face's origin
static constexpr int FACE_NO_COLOUR = -2; // painted, but the sampler returned nothing at this point
struct PipelineResult
{
TriSoup geometry;
// One entry per output face, carried through decimation, the T-junction repair and the weld.
// FACE_UNPAINTED means the paint never covered the geometry this face came from; anything else means
// it did, and is the palette index `color_sample` returned there (FACE_NO_COLOUR when it returned
// none). The distinction matters: the sampler answers for points on the *base* surface, and these
// are sampled on the displaced one, so a painted face can easily come back without a colour. Only
// the painted/unpainted split is reliable here, and that is what a caller should use it for.
//
// Empty unless the caller gave a `color_sample`.
//
// A caller that needs per-face colour must use this rather than sampling the result again. The
// result is displaced geometry: a point on it is no longer where its base surface was, so matching
// it back by proximity colours whatever base surface happens to be nearest - which on a part thinner
// than the relief depth is the *opposite* face, picking up the texture meant for the painted one.
std::vector<int> face_color;
// Output face -> input face. Empty in Export mode, where decimation invalidates it.
std::vector<int> face_parent_id;
bool safety_cap_hit = false;
@@ -72,7 +72,8 @@ size_t count_area_slivers(const TriSoup &geometry)
return n;
}
TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts)
TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts,
std::vector<int> *face_color)
{
const size_t n_tri = geometry.triangle_count();
const double on_tol2 = opts.on_seg_tol * opts.on_seg_tol;
@@ -98,7 +99,12 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts)
// grid. A needle reads as watertight yet is deleted downstream, and dropping it leaves exactly
// the on-edge-vertex topology the pass below closes.
std::vector<std::array<int, 3>> faces;
// Parallel to `faces` throughout, so a split or a dropped degenerate keeps the two in step.
const bool track_color = face_color != nullptr && !face_color->empty();
std::vector<int> colors;
faces.reserve(n_tri);
if (track_color)
colors.reserve(n_tri);
for (size_t t = 0; t < n_tri; ++t) {
const int a = vid[t * 3], b = vid[t * 3 + 1], c = vid[t * 3 + 2];
if (a == b || b == c || a == c)
@@ -108,6 +114,8 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts)
if (u.cross(w).squaredNorm() < DEGENERATE_AREA_SQ)
continue;
faces.push_back({ a, b, c });
if (track_color)
colors.push_back(t < face_color->size() ? (*face_color)[t] : -1);
}
for (int iter = 0; iter < opts.max_iters; ++iter) {
@@ -166,11 +174,16 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts)
break;
std::vector<std::array<int, 3>> next;
std::vector<int> next_colors;
next.reserve(faces.size() + splits.size() * 2);
if (track_color)
next_colors.reserve(next.capacity());
for (size_t fi = 0; fi < faces.size(); ++fi) {
const auto it = splits.find(fi);
if (it == splits.end()) {
next.push_back(faces[fi]);
if (track_color)
next_colors.push_back(colors[fi]);
continue;
}
const auto &f = faces[fi];
@@ -195,11 +208,18 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts)
seq.insert(seq.end(), sp.mids.rbegin(), sp.mids.rend());
seq.push_back(sp.a);
}
for (size_t s = 0; s + 1 < seq.size(); ++s)
for (size_t s = 0; s + 1 < seq.size(); ++s) {
next.push_back({ seq[s], seq[s + 1], apex });
if (track_color)
next_colors.push_back(colors[fi]); // every piece of a split face keeps its colour
}
}
faces.swap(next);
if (track_color)
colors.swap(next_colors);
}
if (track_color)
*face_color = std::move(colors);
TriSoup out;
out.pos.reserve(faces.size() * 3);
@@ -43,7 +43,12 @@ struct RepairOptions
int max_iters = 16;
};
TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts = {});
// `face_color`, when given, is read as one entry per input face and rewritten to match the output: a
// face split to close a T-junction hands its colour to every piece, and a degenerate face dropped on
// the way takes its entry with it. Without this the caller would have no way to keep a per-face colour
// across this pass, which changes the triangle count.
TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts = {},
std::vector<int> *face_color = nullptr);
} // namespace TextureBake
} // namespace Slic3r
+15 -2
View File
@@ -2313,7 +2313,7 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
stats->triangles_budget = result.triangles_budget;
stats->budget_limited = result.budget_limited;
}
indexed_triangle_set out = TextureBake::to_indexed_triangle_set(result.geometry);
indexed_triangle_set out = TextureBake::to_indexed_triangle_set(result.geometry, &result.face_color);
if (out.indices.empty())
return mesh;
if (flip_normals)
@@ -2334,6 +2334,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
max_depth = std::max(max_depth, std::abs(layer.depth_mm));
const float relief_tol = max_depth + paint_tol;
std::vector<int> palette(out.indices.size(), -1);
const std::vector<int> &face_mask = result.face_color;
const bool have_face_mask = face_mask.size() == out.indices.size();
tbb::parallel_for(tbb::blocked_range<size_t>(0, out.indices.size()), [&](const tbb::blocked_range<size_t> &r) {
for (size_t i = r.begin(); i < r.end(); ++i) {
const stl_triangle_vertex_indices &t = out.indices[i];
@@ -2349,8 +2351,19 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
// reason; this path was the inconsistent one.
Vec3f foot = centroid, base_n = Vec3f::UnitZ();
const float d2 = painted_closest(centroid, &foot, &base_n);
if (!all_painted && d2 >= relief_tol * relief_tol)
// Which faces may be coloured comes from the pipeline, which recorded it on the
// refined mesh where the paint mask is exact, and carried it through the collapse,
// the T-junction repair and the weld. Proximity cannot answer this: a displaced face
// is no longer where its base was, so on a part thinner than the relief depth the
// nearest painted surface to the *opposite* face is the painted one, and the texture
// appeared there too. Only the position to sample at still comes from the base
// surface, for the projection reason above.
if (have_face_mask) {
if (face_mask[i] == TextureBake::FACE_UNPAINTED)
continue;
} else if (!all_painted && d2 >= relief_tol * relief_tol) {
continue;
}
palette[i] = sampler(foot, base_n);
}
});