Add color suport for textures

This commit is contained in:
ExPikaPaka
2026-08-31 07:58:48 +02:00
parent 045504c880
commit d855bab19d
14 changed files with 1728 additions and 79 deletions
+482 -37
View File
@@ -23,10 +23,11 @@
namespace Slic3r {
float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
bool DecodedHeightTexture::texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method,
TexelTap &tap) const
{
if (empty())
return 0.f;
return false;
auto repeat01 = [](float x) {
x = std::fmod(x, 1.f);
@@ -43,7 +44,7 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
// Outside the single, non-repeating placement entirely: no texture there, not "smeared
// edge pixel" - clamping the *coordinate* to [0, 1] would otherwise keep returning the
// border row/column's height forever in every direction, stretching it out to infinity.
return 0.f;
return false;
float u, v;
if (!tile_enabled) {
@@ -59,14 +60,26 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
const float fx = u * float(width);
const float fy = v * float(height);
int x0 = std::clamp(int(std::floor(fx)), 0, width - 1);
int y0 = std::clamp(int(std::floor(fy)), 0, height - 1);
const int x0 = std::clamp(int(std::floor(fx)), 0, width - 1);
const int y0 = std::clamp(int(std::floor(fy)), 0, height - 1);
// Neighbour for bilinear filtering: wrap for tiling methods, clamp at the edge otherwise (a
// repeating neighbour would incorrectly blend against the opposite edge of the image).
const int x1 = tile_enabled ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
const int y1 = tile_enabled ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
const float tx = fx - std::floor(fx);
const float ty = fy - std::floor(fy);
tap.x0 = x0;
tap.y0 = y0;
tap.x1 = tile_enabled ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
tap.y1 = tile_enabled ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
tap.tx = fx - std::floor(fx);
tap.ty = fy - std::floor(fy);
return true;
}
float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
{
TexelTap tap;
if (!texel_tap(uv, tile_enabled, tile_method, tap))
return 0.f;
const int x0 = tap.x0, y0 = tap.y0, x1 = tap.x1, y1 = tap.y1;
const float tx = tap.tx, ty = tap.ty;
auto at = [this](int x, int y) { return float(pixels[size_t(y) * size_t(width) + size_t(x)]) / 255.f; };
const float top = at(x0, y0) * (1.f - tx) + at(x1, y0) * tx;
@@ -74,6 +87,21 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
return top * (1.f - ty) + bottom * ty;
}
Vec3f DecodedHeightTexture::sample_color(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
{
TexelTap tap;
if (!has_color() || !texel_tap(uv, tile_enabled, tile_method, tap))
return Vec3f::Zero();
auto at = [this](int x, int y) {
const size_t i = (size_t(y) * size_t(width) + size_t(x)) * 3;
return Vec3f(float(rgb[i]) / 255.f, float(rgb[i + 1]) / 255.f, float(rgb[i + 2]) / 255.f);
};
const Vec3f top = at(tap.x0, tap.y0) * (1.f - tap.tx) + at(tap.x1, tap.y0) * tap.tx;
const Vec3f bottom = at(tap.x0, tap.y1) * (1.f - tap.tx) + at(tap.x1, tap.y1) * tap.tx;
return top * (1.f - tap.ty) + bottom * tap.ty;
}
namespace {
// Decoding a PNG (zlib inflate + defilter) is real work, and image_data never changes in place
// once assigned to a layer (a new texture always gets a brand new image_data), so the decoded
@@ -156,18 +184,40 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
if (!have_raw) {
const png::ReadBuf rbuf{ layer.image_data->data(), layer.image_data->size() };
if (!png::is_png(rbuf))
// Only 8-bit grayscale PNG height maps are supported. The GUI is responsible for
// converting any imported image (jpg, color png, ...) to that format on import, so this
// code never needs a dependency on wxWidgets/libjpeg to decode arbitrary user images.
// PNG only. The GUI converts any other imported format (jpg, bmp, ...) on import, so this
// code needs no dependency on wxWidgets/libjpeg to read arbitrary user images.
return result;
png::ImageGreyscale img;
if (!png::decode_png(rbuf, img) || img.cols == 0 || img.rows == 0)
return result;
if (png::decode_png(rbuf, img) && img.cols > 0 && img.rows > 0) {
// The shipped library, and anything imported before colour was kept.
result.width = int(img.cols);
result.height = int(img.rows);
result.pixels = std::move(img.buf);
} else {
// A colour source: keep the colour, and take the height from its luminance. The
// coefficients are wxImage::ConvertToGreyscale()'s, which is what the importer used to
// apply on the way in - so a texture that used to be flattened to grey at import time
// displaces identically now that its colour is preserved.
png::ImageColorscale col;
if (!png::decode_colored_png(rbuf, col) || col.cols == 0 || col.rows == 0 ||
col.bytes_per_pixel < 3)
return result;
result.width = int(img.cols);
result.height = int(img.rows);
result.pixels = std::move(img.buf);
const size_t n = size_t(col.cols) * size_t(col.rows);
const size_t bpp = size_t(col.bytes_per_pixel);
result.width = int(col.cols);
result.height = int(col.rows);
result.pixels.resize(n);
result.rgb.resize(n * 3);
for (size_t i = 0; i < n; ++i) {
const uint8_t r = col.buf[i * bpp], g = col.buf[i * bpp + 1], b = col.buf[i * bpp + 2];
result.rgb[i * 3] = r;
result.rgb[i * 3 + 1] = g;
result.rgb[i * 3 + 2] = b;
result.pixels[i] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b));
}
}
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
// Opportunistically drop entries for image_data that no longer exists anywhere, so the
@@ -184,6 +234,20 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
const int max_radius = std::clamp(int(std::lround(0.02f * std::min(result.width, result.height))), 1, 32);
const int radius = std::max(1, int(std::lround(layer.smoothing * float(max_radius))));
smooth_height_pixels(result.pixels, result.width, result.height, radius);
// Colour gets the same blur, per channel. It is the same knob for the same reason: detail in
// the image finer than the mesh can carry is noise either way, and low-passing it here is the
// cheapest place to remove it - one blur of the texture, rather than a fight per triangle.
if (result.has_color()) {
const size_t n = size_t(result.width) * size_t(result.height);
std::vector<uint8_t> channel(n);
for (int c = 0; c < 3; ++c) {
for (size_t i = 0; i < n; ++i)
channel[i] = result.rgb[i * 3 + size_t(c)];
smooth_height_pixels(channel, result.width, result.height, radius);
for (size_t i = 0; i < n; ++i)
result.rgb[i * 3 + size_t(c)] = channel[i];
}
}
}
return result;
}
@@ -1029,6 +1093,109 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
w.z() * sample_at(Vec2f(position.x(), position.y()));
}
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
const Vec3f &position, const Vec3f &normal, Vec3f &out, const Vec3f &patch_center,
const Vec3f &patch_axis, const Vec2f *lscm_uv)
{
if (!texture.has_color())
return false;
// Deliberately a transcription of sample_layer_height()'s dispatch rather than a shared template:
// 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;
auto sample_at = [&](const Vec2f &planar) {
return texture.sample_color(apply_uv_transform(planar, layer, aspect), layer.tile_enabled,
layer.tile_method);
};
// Outside a non-tiled placement there is no texture at all - the same hard edge sample() gives the
// height. Checked explicitly because sample_color() reports it as black, which is a real colour.
auto covered = [&](const Vec2f &planar) {
if (layer.tile_enabled)
return true;
const Vec2f uv = apply_uv_transform(planar, layer, aspect);
return uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f;
};
if (lscm_uv != nullptr) {
if (!covered(*lscm_uv))
return false;
out = sample_at(*lscm_uv);
return true;
}
switch (layer.projection_method) {
case TextureProjectionMethod::Cylindrical: {
const Vec2f p = project_cylindrical(position, patch_center, patch_axis);
if (!covered(p))
return false;
out = sample_at(p);
return true;
}
case TextureProjectionMethod::Spherical: {
const Vec2f p = project_spherical(position, patch_center);
if (!covered(p))
return false;
out = sample_at(p);
return true;
}
case TextureProjectionMethod::ViewProjected:
if (layer.view_project_projective) {
// The frame's own rectangle is the placement, so no apply_uv_transform() - see
// sample_layer_height(). A point behind the projector has no uv, hence no colour.
Vec2f uv;
if (!project_uv_projective(layer.view_project_matrix, position, uv))
return false;
if (!layer.tile_enabled && (uv.x() < 0.f || uv.x() >= 1.f || uv.y() < 0.f || uv.y() >= 1.f))
return false;
out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method);
return true;
} else {
const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up));
if (!covered(p))
return false;
out = sample_at(p);
return true;
}
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar:
default: break;
}
// Blended tri-planar, weighted exactly as the height is, so colour and relief stay registered
// across the cross-fade band at a 90-degree edge.
Vec3f w = normal.cwiseAbs();
w = Vec3f(std::pow(w.x(), TRIPLANAR_BLEND_SHARPNESS), std::pow(w.y(), TRIPLANAR_BLEND_SHARPNESS),
std::pow(w.z(), TRIPLANAR_BLEND_SHARPNESS));
const float w_sum = w.x() + w.y() + w.z();
if (w_sum < 1e-8f) {
const Vec2f p(position.x(), position.y());
if (!covered(p))
return false;
out = sample_at(p);
return true;
}
w /= w_sum;
// A blend of three planes is only "not covered" where *every* contributing plane is outside the
// placement; where some are, the covered ones are renormalised so the colour does not fade toward
// black at the edge of an untiled tri-planar layer.
const std::array<Vec2f, 3> planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()),
Vec2f(position.x(), position.y()) };
Vec3f acc = Vec3f::Zero();
float acc_w = 0.f;
for (int i = 0; i < 3; ++i)
if (w[i] > 0.f && covered(planes[size_t(i)])) {
acc += w[i] * sample_at(planes[size_t(i)]);
acc_w += w[i];
}
if (acc_w <= 0.f)
return false;
out = acc / acc_w;
return true;
}
indexed_triangle_set extract_painted_patch(const indexed_triangle_set &base_mesh,
const TriangleSelector::TriangleSplittingData &facet_data)
{
@@ -1135,11 +1302,71 @@ std::vector<float> patch_boundary_distance(const indexed_triangle_set &patch, co
}
} // namespace
namespace {
// Majority filter over face adjacency: each triangle takes the most common colour among itself and
// the (up to three) triangles across its edges. Ties, and a triangle whose own colour is already the
// most common, keep what they had - so the filter only ever removes a facet that disagrees with its
// whole neighbourhood, and cannot drift a large region.
//
// Read from a snapshot of the previous pass, so the result does not depend on triangle order.
// Uncoloured triangles (-1) neither vote nor get voted on: the paint boundary is not noise.
void despeckle_triangle_colors(const indexed_triangle_set &mesh, std::vector<int> &color, int passes)
{
if (passes <= 0 || color.size() != mesh.indices.size())
return;
const std::vector<Vec3i32> neighbors = its_face_neighbors(mesh);
if (neighbors.size() != mesh.indices.size())
return;
std::vector<int> prev;
for (int pass = 0; pass < passes; ++pass) {
prev = color;
tbb::parallel_for(tbb::blocked_range<size_t>(0, color.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t i = range.begin(); i < range.end(); ++i) {
if (prev[i] < 0)
continue;
// At most four candidates (self plus three neighbours), so counting by a linear scan
// is cheaper than any map.
int cand[4] = { prev[i], -1, -1, -1 };
int count[4] = { 1, 0, 0, 0 };
int n = 1;
for (int e = 0; e < 3; ++e) {
const int nb = neighbors[i][e];
if (nb < 0 || size_t(nb) >= prev.size() || prev[size_t(nb)] < 0)
continue;
const int c = prev[size_t(nb)];
int k = 0;
for (; k < n; ++k)
if (cand[k] == c) {
++count[k];
break;
}
if (k == n && n < 4) {
cand[n] = c;
count[n] = 1;
++n;
}
}
// Strictly greater, so a tie leaves the triangle alone.
int best = 0;
for (int k = 1; k < n; ++k)
if (count[k] > count[best])
best = k;
if (count[best] > count[0])
color[i] = cand[best];
}
});
}
}
} // namespace
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
const TextureDisplacementOptions &options,
const DisplacementProgressFn &progress)
const DisplacementProgressFn &progress,
const TextureColorRequest *color)
{
// Returns true to keep going. An aborted run returns {} (see the header): an empty mesh is the
// one result no caller can mistake for a finished bake and commit onto the volume.
@@ -1223,6 +1450,19 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
std::vector<bool> on_patch_border(mesh.vertices.size(), false);
bool any_displacement = false;
// Colour is accumulated per *triangle*, not per vertex: it ends up in the volume's
// mmu_segmentation_facets, which assigns one filament to a whole facet. Layers are visited in
// ascending slot order, so a higher layer simply overwrites a lower one's colour where they
// overlap - the painter's-algorithm reading of a layer stack, and the one that matches how the
// panel lists them.
const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize);
// Palette indices, not filament indices: -1 for "no colour here". Kept in perceived-colour space
// for the whole pass so the despeckle filter below operates on what the eye sees, and the
// interleaving that turns a mixed entry into two real filaments happens once, at the very end.
std::vector<int> triangle_palette;
if (want_color)
triangle_palette.assign(mesh.indices.size(), -1);
const TriangleMesh selector_mesh(mesh);
// One selector for the whole stack, re-deserialized per layer. Its constructor computes
// its_face_neighbors() and its_face_normals() over the *entire* mesh, which on a subdivided model
@@ -1251,7 +1491,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
selector.deserialize(data, selector_dirty);
selector_dirty = true;
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER);
const bool color_this_layer = want_color && layer->color_enabled;
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 ? &patch_source : nullptr);
if (patch.indices.empty())
continue;
// get_facets_strict() returns the same vertex array whichever state is asked for (only the
@@ -1309,6 +1552,65 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
compute_lscm_uvs(patch, *layer) :
std::vector<Vec2f>{};
// 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
// than of the whole triangle's worth of texture - and so a triangle straddling a colour
// boundary lands on whichever side covers more of it, instead of on whichever sub-triangle
// happened to be emitted first. One quantize call per triangle, after the averaging.
if (color_this_layer) {
const DecodedHeightTexture &tex = height;
if (tex.has_color()) {
std::vector<Vec3f> sum(mesh.indices.size(), Vec3f::Zero());
std::vector<float> sum_area(mesh.indices.size(), 0.f);
for (size_t j = 0; j < patch.indices.size() && j < patch_source.size(); ++j) {
const size_t S = size_t(patch_source[j]);
if (S >= mesh.indices.size())
continue;
const stl_triangle_vertex_indices &t = patch.indices[j];
const Vec3f &pa = patch.vertices[size_t(t[0])];
const Vec3f &pb = patch.vertices[size_t(t[1])];
const Vec3f &pc = patch.vertices[size_t(t[2])];
const float area2 = (pb - pa).cross(pc - pa).norm();
if (area2 <= 0.f)
continue;
const Vec3f centroid = (pa + pb + pc) / 3.f;
// The normal the triplanar blend weights by, and the unwrap coordinate the LSCM
// path needs, both averaged over the sub-triangle's corners - the same quantities
// the per-vertex height sampling uses, evaluated at the centroid instead.
Vec3f n = Vec3f::Zero();
Vec2f uv = Vec2f::Zero();
bool have_uv = !lscm_uvs.empty();
for (int k = 0; k < 3; ++k) {
const int vi = t[k];
if (vi < int(vertex_normals.size()))
n += vertex_normals[size_t(vi)];
if (have_uv && size_t(vi) < lscm_uvs.size())
uv += lscm_uvs[size_t(vi)];
else
have_uv = false;
}
n = (n.norm() > 1e-8f) ? Vec3f(n.normalized()) : average_normal;
uv /= 3.f;
Vec3f rgb;
if (sample_layer_color(tex, *layer, centroid, n, rgb, patch_centroid, patch_axis,
have_uv ? &uv : nullptr)) {
sum[S] += area2 * rgb;
sum_area[S] += area2;
}
}
for (size_t i = 0; i < mesh.indices.size(); ++i)
if (sum_area[i] > 0.f) {
const int idx = color->quantize(sum[i] / sum_area[i]);
// A quantizer that declines this colour leaves whatever a lower layer put
// there, rather than punching a hole in it.
if (idx >= 0)
triangle_palette[i] = idx;
}
}
}
// Edge smoothing: a per-vertex weight in [0, 1] that fades the displacement to zero toward the
// patch boundary. amount->0 leaves only the very edge softened; amount->1 fades the whole patch
// flat. k = (1-a)/a turns the normalized boundary distance into that weight (see the header).
@@ -1424,6 +1726,29 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
if (!report(99))
return {};
if (want_color) {
// Despeckle in perceived-colour space, then resolve each entry to a real filament. The order
// matters both ways round: filtering after the interleave would erase the bands it is supposed
// to keep, and interleaving before the filter would have the filter treat two halves of one
// blended colour as a disagreement.
despeckle_triangle_colors(mesh, triangle_palette, color->despeckle_passes);
std::vector<uint8_t> out_color(mesh.indices.size(), 0);
for (size_t i = 0; i < mesh.indices.size(); ++i) {
if (triangle_palette[i] < 0)
continue;
const stl_triangle_vertex_indices &t = mesh.indices[i];
const Vec3f centroid = (mesh.vertices[size_t(t[0])] + mesh.vertices[size_t(t[1])] +
mesh.vertices[size_t(t[2])]) / 3.f;
const int filament = color->resolve ? color->resolve(triangle_palette[i], centroid)
: triangle_palette[i];
if (filament >= 0)
out_color[i] = uint8_t(std::min(filament + 1, 255));
}
// Handed over only on a run that completed: every early return above is a cancellation, and
// the caller must not commit a half-computed colouring any more than a half-displaced mesh.
*color->out_triangle = std::move(out_color);
}
return mesh;
}
@@ -1495,22 +1820,26 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t>
}
}
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data)
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.
struct PreparedLayer {
DecodedHeightTexture tex;
TextureDisplacementLayer layer; // a copy of the params (depth/tiling/rotation/offset/blend/...)
Vec3f center; // patch centroid, for Cylindrical/Spherical
Vec3f axis; // cylinder axis, for Cylindrical
};
// Shared by both point samplers, so the height field and the colour field can never disagree about
// 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)
{
// 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.
struct Prepared {
DecodedHeightTexture tex;
TextureDisplacementLayer layer; // a copy of the params (depth/tiling/rotation/offset/blend/...)
Vec3f center; // patch centroid, for Cylindrical/Spherical
Vec3f axis; // cylinder axis, for Cylindrical
};
auto prepared = std::make_shared<std::vector<Prepared>>();
auto prepared = std::make_shared<std::vector<PreparedLayer>>();
if (base_mesh.indices.empty())
return nullptr;
return prepared;
std::vector<const TextureDisplacementLayer *> ordered;
for (const TextureDisplacementLayer &l : layers)
@@ -1525,11 +1854,13 @@ HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set
for (const TextureDisplacementLayer *layer : ordered) {
if (layer->projection_method == TextureProjectionMethod::LSCM)
continue; // no per-point UV -> not sampleable here (caller falls back to uniform for these)
if (need_color && !layer->color_enabled)
continue;
const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)];
if (data.triangles_to_split.empty())
continue;
const DecodedHeightTexture tex = decode_height_texture(*layer);
if (tex.empty())
if (tex.empty() || (need_color && !tex.has_color()))
continue;
TriangleSelector selector(selector_mesh);
@@ -1563,14 +1894,47 @@ HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set
prepared->push_back({ tex, *layer, centroid, axis });
}
return prepared;
}
} // namespace
ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize)
{
if (!quantize)
return nullptr;
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true);
if (prepared->empty())
return nullptr;
return [prepared, quantize = std::move(quantize)](const Vec3f &pos, const Vec3f &normal) -> int {
// Last one wins: `prepared` is in ascending slot order and the bake lets a higher layer
// overwrite a lower one's colour, so the sampler has to resolve overlaps the same way.
int result = -1;
for (const PreparedLayer &p : *prepared) {
Vec3f rgb;
if (sample_layer_color(p.tex, p.layer, pos, normal, rgb, p.center, p.axis, nullptr))
if (const int idx = quantize(rgb); idx >= 0)
result = idx;
}
return result;
};
}
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data)
{
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false);
if (prepared->empty())
return nullptr;
return [prepared](const Vec3f &pos, const Vec3f &normal) -> float {
float total = 0.f;
bool any = false;
for (const Prepared &p : *prepared) {
for (const PreparedLayer &p : *prepared) {
const float h = sample_layer_height(p.tex, p.layer, pos, normal, p.center, p.axis, nullptr);
const float sign = p.layer.invert ? -1.f : 1.f;
const float signed_h = (h - p.layer.midlevel) * p.layer.depth_mm * sign;
@@ -1646,7 +2010,8 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
std::vector<int> *out_source, const HeightFieldSampler &sampler,
float chord_tolerance_mm, float min_edge_length_mm,
float border_edge_length_mm,
const DisplacementProgressFn &progress)
const DisplacementProgressFn &progress,
const ColorFieldSampler &color, float color_edge_length_mm)
{
// Neighbour slots that are not a triangle index.
constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone
@@ -1682,6 +2047,8 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
// Feature-adaptive when a sampler and a positive tolerance are supplied; otherwise refinement is
// driven by the length baseline alone.
const bool feature_mode = bool(sampler) && chord_tolerance_mm > 0.f;
const bool color_mode = bool(color) && color_edge_length_mm > 0.f;
const float color_sq = color_edge_length_mm > 0.f ? color_edge_length_mm * color_edge_length_mm : 0.f;
const float min_floor_sq = min_edge_length_mm > 0.f ? min_edge_length_mm * min_edge_length_mm : 0.f;
const float target_sq = target_edge_length_mm > 0.f ? target_edge_length_mm * target_edge_length_mm : 0.f;
const float border_sq = border_edge_length_mm > 0.f ? border_edge_length_mm * border_edge_length_mm : 0.f;
@@ -1690,7 +2057,7 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
// (children inherit their parent's src), so a wrong size would be an out-of-bounds read. Guard it.
if (refine_region.size() != mesh.indices.size() || int(tris.size()) + 2 > max_triangles)
return emit();
if (!feature_mode && target_sq <= 0.f && border_sq <= 0.f)
if (!feature_mode && !color_mode && target_sq <= 0.f && border_sq <= 0.f)
return emit(); // no criterion at all
if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; }))
return emit(); // nothing flagged: no-op
@@ -1757,6 +2124,34 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
return vheight[v];
};
// Per-vertex filament index, sampled lazily and cached the same way the heights are. Needs the
// vertex normals, which feature mode also builds - so colour mode builds them when it is on alone.
std::vector<int> vcolor;
std::vector<uint8_t> vcolor_valid;
if (color_mode) {
if (vnormal.empty()) {
vnormal.assign(verts.size(), Vec3f::Zero());
for (const Tri &t : tris) {
const Vec3f fn = (verts[t.v[1]] - verts[t.v[0]]).cross(verts[t.v[2]] - verts[t.v[0]]);
for (int i = 0; i < 3; ++i)
vnormal[t.v[i]] += fn;
}
for (Vec3f &n : vnormal) {
const float l = n.norm();
n = (l > 1e-12f) ? Vec3f(n / l) : Vec3f(Vec3f::UnitZ());
}
}
vcolor.assign(verts.size(), -2); // -2 = not sampled yet; -1 = sampled, no colour there
vcolor_valid.assign(verts.size(), 0);
}
auto color_of = [&](int v) -> int {
if (!vcolor_valid[v]) {
vcolor[v] = color(verts[v], vnormal[v]);
vcolor_valid[v] = 1;
}
return vcolor[v];
};
auto elen_sq = [&](int a, int b) -> float { return (verts[a] - verts[b]).squaredNorm(); };
// The one edge of a triangle taken as its "longest": greatest squared length, exact ties broken by
@@ -1788,6 +2183,38 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
std::vector<float> tri_err;
if (feature_mode)
tri_err.assign(tris.size(), -1.f);
// True when this triangle straddles a colour boundary: its corners, its edge midpoints and its
// centroid do not all take the same filament. The midpoints and centroid matter for the same
// reason they do in detail_error() - a boundary can cross a triangle without separating any two of
// its corners. Cached per triangle; a split invalidates both children.
std::vector<uint8_t> tri_color_split; // 0 = unknown, 1 = straddles, 2 = uniform
if (color_mode)
tri_color_split.assign(tris.size(), 0);
auto straddles_color = [&](int ti) -> bool {
if (tri_color_split[ti] != 0)
return tri_color_split[ti] == 1;
const Tri &t = tris[ti];
const Vec3f pa = verts[t.v[0]], pb = verts[t.v[1]], pc = verts[t.v[2]];
const Vec3f na = vnormal[t.v[0]], nb = vnormal[t.v[1]], nc = vnormal[t.v[2]];
const int ca = color_of(t.v[0]);
bool split = color_of(t.v[1]) != ca || color_of(t.v[2]) != ca;
if (!split) {
static const float BARY[4][3] = { { 0.5f, 0.5f, 0.f }, { 0.f, 0.5f, 0.5f },
{ 0.5f, 0.f, 0.5f }, { 1.f / 3, 1.f / 3, 1.f / 3 } };
for (const auto &w : BARY) {
Vec3f n = w[0] * na + w[1] * nb + w[2] * nc;
const float nl = n.norm();
n = (nl > 1e-12f) ? Vec3f(n / nl) : na;
if (color(w[0] * pa + w[1] * pb + w[2] * pc, n) != ca) {
split = true;
break;
}
}
}
tri_color_split[ti] = split ? 1 : 2;
return split;
};
auto detail_error = [&](int ti) -> float {
if (tri_err[ti] >= 0.f)
return tri_err[ti];
@@ -1828,6 +2255,10 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
p = (target_sq > 0.f) ? ll / target_sq : 0.f;
if (feature_mode)
p = std::max(p, detail_error(ti) / chord_tolerance_mm);
// Colour is per facet, so a colour boundary can only be drawn where there are edges along
// it. Length target, floored by min_edge_length_mm above, exactly like the border band.
if (color_mode && straddles_color(ti))
p = std::max(p, ll / color_sq);
}
// The band straddling the paint's edge, refined by plain edge length. Deliberately *not* run
// through detail_error(): outside the paint the sampler still reports full relief (it has no
@@ -1878,13 +2309,19 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
const int m = int(verts.size());
verts.push_back(0.5f * (verts[a] + verts[b]));
if (feature_mode) {
if (feature_mode || color_mode) {
const Vec3f mn = vnormal[a] + vnormal[b];
const float ml = mn.norm();
vnormal.push_back(ml > 1e-12f ? Vec3f(mn / ml) : vnormal[a]);
}
if (feature_mode) {
vheight.push_back(0.f);
vheight_valid.push_back(0);
}
if (color_mode) {
vcolor.push_back(-2);
vcolor_valid.push_back(0);
}
// Near side: ti becomes (a, m, c), the new triangle is (m, b, c). Both keep the original
// a->b->c winding.
@@ -1903,6 +2340,10 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
tri_err.push_back(-1.f);
tri_err[ti] = -1.f;
}
if (color_mode) {
tri_color_split.push_back(0);
tri_color_split[ti] = 0;
}
touched.assign({ ti, t2 });
if (n < 0) {
@@ -1926,6 +2367,10 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
tri_err.push_back(-1.f);
tri_err[n] = -1.f;
}
if (color_mode) {
tri_color_split.push_back(0);
tri_color_split[n] = 0;
}
// Stitch the two sides back together: whichever far child holds `a` borders the near child
// that holds `a`. (Which one that is depends on how n happens to be wound.)
+190 -10
View File
@@ -274,6 +274,17 @@ struct TextureDisplacementLayer
// it as the "Base" layer and hides the control).
TextureBlendMode blend_mode = TextureBlendMode::Add;
// Colour this layer's painted area from the texture's own colours, on top of displacing by its
// height. Only meaningful when the texture actually has colour (DecodedHeightTexture::has_color()):
// the shipped library is grayscale, so this does nothing there.
//
// Colour lands in the volume's mmu_segmentation_facets - the same per-triangle filament assignment
// the MMU paint gizmo writes - so its resolution is the *mesh's*, not the image's, and a triangle
// gets exactly one filament. That is why the adaptive subdivision has a colour criterion of its
// own (see subdivide_mesh_adaptive()): without triangles along a colour boundary there is nothing
// for the boundary to be drawn on.
bool color_enabled = false;
bool empty() const { return !image_data || image_data->empty(); }
template<class Archive> void save(Archive &ar) const
@@ -283,7 +294,7 @@ struct TextureDisplacementLayer
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
lscm_uv_overrides, view_project_projective, view_project_matrix);
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled);
}
template<class Archive> void load(Archive &ar)
{
@@ -295,7 +306,7 @@ struct TextureDisplacementLayer
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
view_project_matrix);
view_project_matrix, color_enabled);
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
@@ -303,6 +314,22 @@ struct TextureDisplacementLayer
}
};
// How a *mixed* palette entry - one that names two filaments rather than one - is turned into real
// per-facet paint. An MMU extrudes one filament at a time, so an intermediate colour exists only by
// interleaving two of them finely enough that the eye does the blending.
enum class ColorMixMode : int
{
// Horizontal bands: which of the two filaments a point takes depends on its height, so
// consecutive print layers alternate. This is how filament-blend prints actually work, and on a
// vertical-ish surface it reads as a genuinely smooth colour. On a near-horizontal surface a whole
// layer is one band, so the blend disappears - that is what XYDither is for.
ZBands = 0,
// An ordered (Bayer) checkerboard across the surface, at any orientation. Independent of layer
// height, but its cell is around the size of one facet, so a fine mix can read as texture rather
// than as a clean blend.
XYDither = 1,
};
// Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next
// to texture_displacement_layers and consumed by build_texture_displacement().
struct TextureDisplacementOptions
@@ -338,33 +365,118 @@ struct TextureDisplacementOptions
// deliberately (which is a blunter version of the per-layer edge-smoothing falloff).
bool smooth_skip_border = true;
// Colour, all of which belongs to the stack rather than to any one layer: it is about how the
// printer will realise the colours, not about which image they came from.
// Interleave pairs of filaments to get colours between them - so four loaded filaments offer far
// more than four colours. Off means every triangle takes one of the loaded filaments exactly.
bool color_mix_enabled = true;
ColorMixMode color_mix_mode = ColorMixMode::ZBands;
// Majority-filter passes over the assigned colours. See TextureColorRequest::despeckle_passes -
// this is the control for it, and 2 is enough to clear the salt-and-pepper an image with detail
// finer than the mesh leaves behind, without eating features that are genuinely a facet wide.
int color_despeckle = 2;
template<class Archive> void serialize(Archive &ar)
{
ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border);
int mix_mode = int(color_mix_mode);
ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border,
color_mix_enabled, mix_mode, color_despeckle);
color_mix_mode = ColorMixMode(mix_mode);
}
};
// Decoded 8-bit grayscale height sample, independent of any GUI/OpenGL texture object so it can
// be evaluated from a background bake Job as well as from GUI-side preview code.
// Decoded height (and, for a colour source image, colour) samples, independent of any GUI/OpenGL
// texture object so they can be evaluated from a background bake Job as well as from GUI-side
// preview code.
struct DecodedHeightTexture
{
std::vector<uint8_t> pixels; // row-major, top-to-bottom, one byte per pixel
std::vector<uint8_t> pixels; // height: row-major, top-to-bottom, one byte per pixel
// Colour: the same grid, three bytes per pixel, or empty when the source image was grayscale.
// A grayscale height map has no colour to give - `pixels` is not a colour, it is a height - so
// has_color() is what the whole colour feature keys off: a layer set to colour a model with a
// grayscale texture on it simply colours nothing.
std::vector<uint8_t> rgb;
int width = 0;
int height = 0;
bool empty() const { return width <= 0 || height <= 0 || pixels.empty(); }
bool has_color() const { return !empty() && rgb.size() == size_t(width) * size_t(height) * 3; }
// Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is false,
// a uv outside [0, 1) samples as 0 - the texture simply is not there, rather than its border
// row/column being smeared outward forever (which is what clamping the coordinate would do, and
// was a real reported bug). Callers rely on this to get a hard edge: it is how the projection
// frame's border becomes the edge of the displacement.
float sample(const Vec2f &uv, bool tile_enabled = true, TextureTileMethod tile_method = TextureTileMethod::Repeat) const;
// The same sample, in colour: linear RGB components in [0, 1]. Outside a non-tiled placement, and
// for a grayscale source, this is (0, 0, 0) - callers pair it with has_color() and with the
// height's own coverage rather than trying to read "no texture here" out of the colour itself.
Vec3f sample_color(const Vec2f &uv, bool tile_enabled = true,
TextureTileMethod tile_method = TextureTileMethod::Repeat) const;
// Where a uv lands on the texel grid: the four texels of the bilinear tap and their weights.
// Shared by sample() and sample_color(), so a layer's height and its colour can never end up
// read from different places in the image. False means the uv is outside a non-tiled placement -
// no texture there at all (see sample()).
struct TexelTap
{
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
float tx = 0.f, ty = 0.f;
};
bool texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &out) const;
};
// Decode a layer's raw image bytes into sampleable grayscale height data. Returns an empty
// DecodedHeightTexture if image_data is empty or is not an 8-bit grayscale PNG.
// Decode a layer's raw image bytes into sampleable height data, plus colour when the source has any.
// Both 8-bit grayscale PNGs (the shipped library, and anything imported before colour was kept) and
// colour PNGs are accepted; for a colour source the height is its luminance, using the same
// coefficients wxImage::ConvertToGreyscale() uses, so a texture imported as colour displaces exactly
// as it did when the importer flattened it to grey on the way in. Returns an empty
// DecodedHeightTexture if image_data is empty or is not a PNG at all.
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer);
// Maps a linear RGB colour in [0, 1] to an index into the caller's palette, or -1 for "no colour".
//
// Deliberately a callback rather than a function here: matching a colour to a filament is a
// *perceptual* question (CIEDE2000 over CIELAB), and that machinery - slic3r/Utils/ColorSpaceConvert
// and GuiColor - lives on the GUI side along with the list of filaments actually loaded. libslic3r
// samples the image and decides *where* colour changes; the GUI decides *which* filament each colour
// is. See GLGizmoTextureDisplacement::make_palette_quantizer().
using ColorQuantizeFn = std::function<int(const Vec3f &)>;
// Resolves a palette index plus a surface position to the filament index that position should print
// in. A pure entry ignores the position; a mixed one interleaves its two filaments per ColorMixMode.
//
// Deliberately separate from ColorQuantizeFn, and deliberately *not* used by the subdivision's colour
// criterion: that criterion asks where the **perceived** colour changes, and must not see the
// interleaving. Refining on every band or dither-cell boundary would spend the whole triangle budget
// drawing a pattern the eye is supposed to blend away.
using ColorResolveFn = std::function<int(int palette_index, const Vec3f &pos)>;
// One printable colour: either a loaded filament on its own, or a blend of two of them realised by
// interleaving (see ColorMixMode). Plain data, so it can be captured into a background job.
struct PrintableColor
{
Vec3f rgb = Vec3f::Zero(); // what it looks like; for a mix, the perceptual average of the two
int a = 0; // filament index
int b = 0; // the second filament; == a for a pure entry
int num = 1; // a's share of the interleave, out of `den`
int den = 1;
bool is_mix() const { return a != b; }
};
// Everything needed to colour a mesh, captured on the main thread and handed to a job. An empty
// palette means nothing is colouring, which is the state every one of these paths starts in.
struct TextureColorSettings
{
std::vector<PrintableColor> palette;
ColorMixMode mix_mode = ColorMixMode::ZBands;
float layer_height = 0.2f; // sizes the Z bands
float dither_cell_mm = 0.4f; // sizes the XY dither cells
int despeckle_passes = 2;
bool empty() const { return palette.empty(); }
};
// Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any
// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather
// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas
@@ -409,6 +521,16 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
const Vec3f &patch_center = Vec3f::Zero(), const Vec3f &patch_axis = Vec3f::UnitZ(),
const Vec2f *lscm_uv = nullptr);
// The same sample, in colour, through the identical projection/tiling/placement path - so a layer's
// colour lands on the model exactly where its relief does, whatever projection it is using. Returns
// false (leaving `out` untouched) when the texture has no colour, or when the point falls outside a
// non-tiled placement, or behind a projective "from view" projector: all three mean "this layer does
// not colour this point", which is different from "this layer colours it black".
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
const Vec3f &position, const Vec3f &normal, Vec3f &out,
const Vec3f &patch_center = Vec3f::Zero(), const Vec3f &patch_axis = Vec3f::UnitZ(),
const Vec2f *lscm_uv = nullptr);
// Area-weighted centroid and average normal of a layer's currently painted patch, in mesh-local
// coordinates - the same measurements build_texture_displacement() uses to pick its dominant
// projection axis. Used by the GUI to anchor the on-canvas "adjust texture placement" gizmo to
@@ -551,11 +673,38 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
// committed. It exists because this is the one call in the feature that can take seconds on a
// subdivided mesh, and without it the progress notification the Job framework puts on screen sits at
// 0% for the whole run and offers no way to close it (its close button only appears at 100%).
//
// `color`, when given, also reports which filament each triangle should print in - see
// TextureColorRequest.
struct TextureColorRequest
{
// RGB -> palette index. Supplied by the GUI, which owns both the perceptual matching and the list
// of filaments actually loaded (see ColorQuantizeFn).
ColorQuantizeFn quantize;
// Palette index + position -> filament. Optional: without it a palette index is taken to be a
// filament index directly, which is the no-mixing case.
ColorResolveFn resolve;
// Majority-filter passes over the *perceived* colour, before any interleaving is resolved.
//
// Sampling a detailed image once per triangle leaves salt-and-pepper wherever the image's own
// detail is finer than the mesh: two neighbouring facets land either side of some contour and flip
// colour independently. Replacing each facet's colour with the most common one among itself and
// its edge neighbours removes exactly that, and leaves any feature wider than a facet alone. 0
// turns it off.
int despeckle_passes = 0;
// 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 is not arbitrary - it lines up with EnforcerBlockerType, where 0 is NONE
// ("use the volume's own filament") and 1..16 are Extruder1..16, so the caller can hand these
// straight to a TriangleSelector without a second mapping table.
std::vector<uint8_t> *out_triangle = nullptr;
};
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
const TextureDisplacementOptions &options = {},
const DisplacementProgressFn &progress = {});
const DisplacementProgressFn &progress = {},
const TextureColorRequest *color = nullptr);
// Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from
// `volume` and forwards to the overload above.
@@ -591,6 +740,22 @@ using HeightFieldSampler = std::function<float(const Vec3f &pos, const Vec3f &no
// and edge-smoothing's boundary falloff is ignored. LSCM layers have no per-point UV and are skipped.
// Returns a null sampler (bool false) when no layer can be sampled - the caller then falls back to
// uniform adaptive subdivision.
// Which filament the texture stack would put at a point, as a palette index (or -1 for "no colour
// here"). The colour analogue of HeightFieldSampler, and used the same way: to decide where the
// adaptive subdivision needs triangles. Colour lands per *facet*, so a colour boundary is a step the
// mesh can only draw if there are edges along it - the chord-error test that drives the height
// refinement is blind to it, exactly as it is blind to the paint's own border.
using ColorFieldSampler = std::function<int(const Vec3f &pos, const Vec3f &normal)>;
// The colour counterpart of make_combined_displacement_sampler(), over the same layers, and skipping
// the same ones (LSCM has no per-point UV). Layers without color_enabled, and layers whose texture is
// grayscale, contribute nothing; a higher slot wins over a lower one where they overlap, matching the
// bake. Returns null when no layer can colour anything, in which case there is nothing to refine for.
ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize);
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data);
@@ -678,6 +843,15 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl
// plain edge length is bounded (it is a thin ring, and a length target always terminates) and needs
// no paint-aware sampler.
//
// `color`, with a positive `color_edge_length_mm`, adds a third criterion inside the painted area: a
// triangle whose corners, edge midpoints and centroid do not all map to the *same* filament straddles
// a colour boundary, and is refined by plain edge length down to that target. Length rather than any
// error measure, for the same reason the border band uses length - the thing being fixed is the size
// of the triangles spanning a step, not the curvature of anything - and because a step's error never
// falls however fine the mesh gets, so only a length target (floored by min_edge_length_mm) is
// guaranteed to terminate. Without this a colour boundary lands on whatever triangles the *height*
// happened to need, which on a flat surface is none at all.
//
// `progress`, when given, is called with a 0..100 percentage of the triangle budget spent; returning
// false stops the refinement early. What it hands back then is still a complete, conformal mesh - the
// loop only ever finishes whole bisections - so a caller that wants to discard it has to do so itself.
@@ -688,7 +862,9 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
const HeightFieldSampler &sampler = nullptr,
float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f,
float border_edge_length_mm = 0.f,
const DisplacementProgressFn &progress = nullptr);
const DisplacementProgressFn &progress = nullptr,
const ColorFieldSampler &color = nullptr,
float color_edge_length_mm = 0.f);
// The recipe for getting a mesh ready to receive displacement: even out the triangle density, then
// refine it where the texture bends. Either stage is skipped when its target is <= 0. Pure data, and
@@ -709,6 +885,10 @@ struct TextureDisplacementPrepareParams
float subdiv_border_mm = 0.f; // "Edge detail": the band straddling the paint's edge, 0 = off
bool subdiv_feature = false; // follow texture curvature, not just edge length
int subdiv_added_triangles = 0; // budget, *added* to the mesh's own count
// Edge length triangles straddling a *colour* boundary are refined to, 0 = do not look at colour.
// Separate from the height criteria because colour lands per facet: a flat surface carrying a
// sharp colour edge needs triangles along that edge even though its height is perfectly smooth.
float subdiv_color_edge_mm = 0.f;
};
// What a preparation run produced. An empty `mesh` means there was nothing to do and the caller must