diff --git a/resources/shaders/110/texture_displacement_bump.fs b/resources/shaders/110/texture_displacement_bump.fs index fe0e32ef96..4686668467 100644 --- a/resources/shaders/110/texture_displacement_bump.fs +++ b/resources/shaders/110/texture_displacement_bump.fs @@ -21,6 +21,15 @@ const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074); const vec3 ZERO = vec3(0.0, 0.0, 0.0); uniform vec4 uniform_color; +// The printable palette, in **CIELAB** as well as RGB, and how many entries are real. Lab because the +// match has to be perceptual - the same reason the CPU side uses CIEDE2000 - and converting the +// palette once on the CPU is what lets the fragment shader match with a plain squared distance. +// Count 0 means nothing is colouring, and every fragment falls back to uniform_color as before. +uniform vec3 palette_lab[64]; +uniform vec3 palette_rgb[64]; +uniform int palette_count; +uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height +uniform bool has_color_tex; uniform bool volume_mirrored; uniform mat4 view_model_matrix; @@ -79,6 +88,47 @@ vec2 project_uv(vec3 p, vec3 n) return r + uv_offset; } +// sRGB -> CIELAB, matching slic3r/Utils/ColorSpaceConvert's RGB2Lab so this picks the same entry the +// bake does. +vec3 srgb_to_lab(vec3 c) +{ + vec3 v = vec3(c.r > 0.04045 ? pow((c.r + 0.055) / 1.055, 2.4) : c.r / 12.92, + c.g > 0.04045 ? pow((c.g + 0.055) / 1.055, 2.4) : c.g / 12.92, + c.b > 0.04045 ? pow((c.b + 0.055) / 1.055, 2.4) : c.b / 12.92); + vec3 xyz = vec3(dot(v, vec3(0.4124, 0.3576, 0.1805)) / 0.95047, + dot(v, vec3(0.2126, 0.7152, 0.0722)), + dot(v, vec3(0.0193, 0.1192, 0.9505)) / 1.08883); + vec3 f = vec3(xyz.x > 0.008856 ? pow(xyz.x, 1.0 / 3.0) : (7.787 * xyz.x) + 16.0 / 116.0, + xyz.y > 0.008856 ? pow(xyz.y, 1.0 / 3.0) : (7.787 * xyz.y) + 16.0 / 116.0, + xyz.z > 0.008856 ? pow(xyz.z, 1.0 / 3.0) : (7.787 * xyz.z) + 16.0 / 116.0); + return vec3(116.0 * f.y - 16.0, 500.0 * (f.x - f.y), 200.0 * (f.y - f.z)); +} + +// Nearest printable colour to a sampled one. Quantizing per *fragment* rather than per facet is the +// whole point of this path: it shows the image at the texture's resolution instead of the mesh's, +// which is what you need while choosing a texture and placing it. The Normal view is where the +// facet-resolution truth - what actually bakes - is shown. +// +// Squared distance in Lab (CIE76) rather than the CPU's CIEDE2000: the two agree except on near-ties, +// and CIEDE2000 per fragment across 64 entries is not worth its cost in a preview. +vec3 quantize_to_palette(vec3 rgb) +{ + vec3 lab = srgb_to_lab(rgb); + int best = 0; + float bd = 1.0e20; + for (int i = 0; i < 64; ++i) { + if (i >= palette_count) + break; + vec3 d = lab - palette_lab[i]; + float d2 = dot(d, d); + if (d2 < bd) { + bd = d2; + best = i; + } + } + return palette_rgb[best]; +} + void main() { if (any(lessThan(clipping_planes_dots, ZERO))) @@ -88,6 +138,12 @@ void main() if (volume_mirrored) triangle_normal = -triangle_normal; + // Where the colour is read from. Both branches below already compute the uv this fragment's + // *height* came from - including the parallax-marched one on the triplanar path - and the colour + // has to follow it exactly, or the colour would slide off the relief as the camera orbits. + vec2 color_uv = vec2(0.0); + bool have_uv = false; + if (use_vertex_uv) { // Mikkelsen surface-gradient bump; see the 140 variant for the full rationale. Scale-exact // for a conformal LSCM map (no global 1/tiling assumption), and gated by the paint weight @@ -95,6 +151,8 @@ void main() vec2 uv = (island_active > 0.5) ? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr : vertex_uv; + color_uv = uv; + have_uv = true; float h = texture2D(height_tex, uv).r; float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0); vec3 sigmaS = dFdx(model_pos.xyz); @@ -151,6 +209,9 @@ void main() } } + color_uv = uv; // after the parallax march, so colour and relief stay registered + have_uv = true; + float hL = texture2D(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r; float hR = texture2D(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r; float hD = texture2D(height_tex, uv - vec2(0.0, height_tex_texel.y)).r; @@ -183,5 +244,11 @@ void main() NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; - gl_FragColor = vec4(vec3(intensity.y) + uniform_color.rgb * intensity.x, uniform_color.a); + // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour. + // Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as + // the same material under the same light, and the relief this preview exists to show is unaffected. + vec3 albedo = uniform_color.rgb; + if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) + albedo = quantize_to_palette(texture2D(color_tex, color_uv).rgb); + gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/resources/shaders/140/texture_displacement_bump.fs b/resources/shaders/140/texture_displacement_bump.fs index 351452f0e9..4f8e3c8186 100644 --- a/resources/shaders/140/texture_displacement_bump.fs +++ b/resources/shaders/140/texture_displacement_bump.fs @@ -80,6 +80,15 @@ const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074); const vec3 ZERO = vec3(0.0, 0.0, 0.0); uniform vec4 uniform_color; +// The printable palette, in **CIELAB** as well as RGB, and how many entries are real. Lab because the +// match has to be perceptual - the same reason the CPU side uses CIEDE2000 - and converting the +// palette once on the CPU is what lets the fragment shader match with a plain squared distance. +// Count 0 means nothing is colouring, and every fragment falls back to uniform_color as before. +uniform vec3 palette_lab[64]; +uniform vec3 palette_rgb[64]; +uniform int palette_count; +uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height +uniform bool has_color_tex; uniform bool volume_mirrored; uniform mat4 view_model_matrix; @@ -145,6 +154,47 @@ vec2 project_uv(vec3 p, vec3 n) return r + uv_offset; } +// sRGB -> CIELAB, matching slic3r/Utils/ColorSpaceConvert's RGB2Lab so this picks the same entry the +// bake does. +vec3 srgb_to_lab(vec3 c) +{ + vec3 v = vec3(c.r > 0.04045 ? pow((c.r + 0.055) / 1.055, 2.4) : c.r / 12.92, + c.g > 0.04045 ? pow((c.g + 0.055) / 1.055, 2.4) : c.g / 12.92, + c.b > 0.04045 ? pow((c.b + 0.055) / 1.055, 2.4) : c.b / 12.92); + vec3 xyz = vec3(dot(v, vec3(0.4124, 0.3576, 0.1805)) / 0.95047, + dot(v, vec3(0.2126, 0.7152, 0.0722)), + dot(v, vec3(0.0193, 0.1192, 0.9505)) / 1.08883); + vec3 f = vec3(xyz.x > 0.008856 ? pow(xyz.x, 1.0 / 3.0) : (7.787 * xyz.x) + 16.0 / 116.0, + xyz.y > 0.008856 ? pow(xyz.y, 1.0 / 3.0) : (7.787 * xyz.y) + 16.0 / 116.0, + xyz.z > 0.008856 ? pow(xyz.z, 1.0 / 3.0) : (7.787 * xyz.z) + 16.0 / 116.0); + return vec3(116.0 * f.y - 16.0, 500.0 * (f.x - f.y), 200.0 * (f.y - f.z)); +} + +// Nearest printable colour to a sampled one. Quantizing per *fragment* rather than per facet is the +// whole point of this path: it shows the image at the texture's resolution instead of the mesh's, +// which is what you need while choosing a texture and placing it. The Normal view is where the +// facet-resolution truth - what actually bakes - is shown. +// +// Squared distance in Lab (CIE76) rather than the CPU's CIEDE2000: the two agree except on near-ties, +// and CIEDE2000 per fragment across 64 entries is not worth its cost in a preview. +vec3 quantize_to_palette(vec3 rgb) +{ + vec3 lab = srgb_to_lab(rgb); + int best = 0; + float bd = 1.0e20; + for (int i = 0; i < 64; ++i) { + if (i >= palette_count) + break; + vec3 d = lab - palette_lab[i]; + float d2 = dot(d, d); + if (d2 < bd) { + bd = d2; + best = i; + } + } + return palette_rgb[best]; +} + void main() { if (any(lessThan(clipping_planes_dots, ZERO))) @@ -154,6 +204,12 @@ void main() if (volume_mirrored) triangle_normal = -triangle_normal; + // Where the colour is read from. Both branches below already compute the uv this fragment's + // *height* came from - including the parallax-marched one on the triplanar path - and the colour + // has to follow it exactly, or the colour would slide off the relief as the camera orbits. + vec2 color_uv = vec2(0.0); + bool have_uv = false; + if (use_vertex_uv) { // Precomputed-uv (LSCM) path - Mikkelsen's surface-gradient bump ("Bump Mapping // Unparametrized Surfaces on the GPU"). The perturbed normal is derived straight from the @@ -171,6 +227,8 @@ void main() vec2 uv = (island_active > 0.5) ? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr : vertex_uv; + color_uv = uv; + have_uv = true; float h = texture(height_tex, uv).r; float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0); vec3 sigmaS = dFdx(model_pos.xyz); @@ -230,6 +288,9 @@ void main() } } + color_uv = uv; // after the parallax march, so colour and relief stay registered + have_uv = true; + float hL = texture(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r; float hR = texture(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r; float hD = texture(height_tex, uv - vec2(0.0, height_tex_texel.y)).r; @@ -267,5 +328,11 @@ void main() NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; - out_color = vec4(vec3(intensity.y) + uniform_color.rgb * intensity.x, uniform_color.a); + // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour. + // Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as + // the same material under the same light, and the relief this preview exists to show is unaffected. + vec3 albedo = uniform_color.rgb; + if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) + albedo = quantize_to_palette(texture(color_tex, color_uv).rgb); + out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index e1a03a9eaf..74485c7919 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -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 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 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 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 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 &color, int passes) +{ + if (passes <= 0 || color.size() != mesh.indices.size()) + return; + const std::vector neighbors = its_face_neighbors(mesh); + if (neighbors.size() != mesh.indices.size()) + return; + + std::vector prev; + for (int pass = 0; pass < passes; ++pass) { + prev = color; + tbb::parallel_for(tbb::blocked_range(0, color.size()), + [&](const tbb::blocked_range &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 &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 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 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 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{}; + // 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 sum(mesh.indices.size(), Vec3f::Zero()); + std::vector 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 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 } } -HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, - const std::vector &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> prepare_sampleable_layers( + const indexed_triangle_set &base_mesh, const std::vector &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>(); + auto prepared = std::make_shared>(); if (base_mesh.indices.empty()) - return nullptr; + return prepared; std::vector 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 &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 &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 *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 vcolor; + std::vector 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 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 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.) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 10e30f9788..7abf1d656c 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -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 void save(Archive &ar) const @@ -283,7 +294,7 @@ struct TextureDisplacementLayer static_cast(tile_method), static_cast(projection_method), lscm_seam_angle_deg, islands, static_cast(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 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>(blob.begin(), blob.end()); tile_method = static_cast(tile_method_int); projection_method = static_cast(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 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 pixels; // row-major, top-to-bottom, one byte per pixel + std::vector 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 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; + +// 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; + +// 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 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 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 *out_triangle = nullptr; +}; indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, const std::vector &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; + +// 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 &layers, + const TextureDisplacementFacetsData &facets_data, + ColorQuantizeFn quantize); + HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, const std::vector &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 diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index bff54e791e..b0bfefddbd 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -3,6 +3,8 @@ #include #include "libslic3r/AABBTreeIndirect.hpp" +#include "libslic3r/Color.hpp" +#include "libslic3r/PresetBundle.hpp" #include "libslic3r/MeshBoolean.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/Utils.hpp" @@ -15,6 +17,7 @@ #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" +#include "slic3r/GUI/GuiColor.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/MainFrame.hpp" // wxGetApp().mainframe, as the projector window's parent #include "slic3r/GUI/MsgDialog.hpp" @@ -212,6 +215,14 @@ TriangleSelector::TriangleSplittingData remap_texture_paint_spatial( return dst_sel.serialize(); } +// Edge of the RGB lookup cube make_palette_quantizer() builds. 24 gives 13824 cells - far finer than +// the difference between any two printable colours - and costs one DeltaE00 per cell per palette +// entry to fill. +constexpr int PALETTE_LUT_EDGE = 24; + +// Ceiling on the printable palette, which bounds that fill cost (and the shader's uniform array). +constexpr int PALETTE_MAX_ENTRIES = 64; + std::unique_ptr upload_height_thumbnail(const DecodedHeightTexture &decoded, int max_px = THUMBNAIL_MAX_PX) { if (decoded.empty()) @@ -247,6 +258,46 @@ std::unique_ptr upload_height_thumbnail(const DecodedHeightTexture &d return texture; } +// The same upload, of the texture's *colour* rather than its height, for the fast preview to quantize +// per fragment. Null for a grayscale texture - there is nothing to show. +// +// Box-filtered down like the height is, and for a sharper reason: the fast preview quantizes every +// fragment independently, so any texel-scale noise left in the image becomes a scatter of single-pixel +// colour flips on screen. Filtering on the way to the GPU is where that is cheapest to remove. +std::unique_ptr upload_color_texture(const DecodedHeightTexture &decoded, int max_px) +{ + if (!decoded.has_color()) + return nullptr; + + const int scale = std::max(1, (std::max(decoded.width, decoded.height) + max_px - 1) / max_px); + const int w = std::max(1, decoded.width / scale); + const int h = std::max(1, decoded.height / scale); + + std::vector rgba(size_t(w) * size_t(h) * 4); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + const int x0 = x * decoded.width / w, x1 = std::max(x0 + 1, (x + 1) * decoded.width / w); + const int y0 = y * decoded.height / h, y1 = std::max(y0 + 1, (y + 1) * decoded.height / h); + unsigned int sum[3] = { 0, 0, 0 }; + unsigned int n = 0; + for (int sy = y0; sy < y1 && sy < decoded.height; ++sy) + for (int sx = x0; sx < x1 && sx < decoded.width; ++sx, ++n) { + const size_t si = (size_t(sy) * size_t(decoded.width) + size_t(sx)) * 3; + for (int c = 0; c < 3; ++c) + sum[c] += decoded.rgb[si + size_t(c)]; + } + const size_t di = (size_t(y) * size_t(w) + size_t(x)) * 4; + for (int c = 0; c < 3; ++c) + rgba[di + size_t(c)] = (n > 0) ? static_cast(sum[size_t(c)] / n) : 0; + rgba[di + 3] = 255; + } + + auto texture = std::make_unique(); + if (!texture->load_from_raw_data(std::move(rgba), (unsigned int) w, (unsigned int) h, false, false)) + return nullptr; + return texture; +} + // Intersects the camera ray through `mouse_pos` (screen coords) with the plane passing through // `plane_point_local`/`plane_normal_local` (mesh-local coords, transformed to world by `trafo`). // Returns false if the ray is parallel to the plane or the plane is behind the camera. @@ -887,7 +938,16 @@ void GLGizmoTextureDisplacement::render_preview_mesh() shader->set_uniform("projection_matrix", camera.get_projection_matrix()); const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) * trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose(); shader->set_uniform("view_normal_matrix", view_normal_matrix); - m_preview_glmodel.render(); + if (m_preview_color_runs.empty()) { + m_preview_glmodel.render(); + } else { + // set_color() writes the uniform the shader reads, so one call per group is all the + // per-triangle colour this needs. + for (const PreviewColorRun &run : m_preview_color_runs) { + m_preview_glmodel.set_color(run.color); + m_preview_glmodel.render(run.range, shader); + } + } shader->stop_using(); } @@ -972,6 +1032,13 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() if (!m_bump_preview_uses_vertex_uv) vertex_uv.clear(); + // Colour is quantized per *fragment* in the shader now (see the .fs), so this mesh carries no + // colour of its own - the palette and the colour texture are uniforms, and every pixel matches the + // image rather than the facet it landed on. What the *bake* will produce, at facet resolution, is + // what the Normal view shows. + m_bump_preview_palette = (active != nullptr && active->color_enabled) ? cached_palette() + : std::vector{}; + GLModel::Geometry init_data; // P3N3T2: normal.x carries the paint weight, tex_coord the precomputed uv (see the vertex shader). init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 }; @@ -1003,8 +1070,10 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() vcount += 3; } }; - emit_triangles(patch, 1.f); // painted -> bumped - emit_triangles(rest, 0.f); // untouched surface -> flat, so it still shows but isn't bumped + emit_triangles(patch, 1.f); // painted -> bumped, and coloured by the shader + // Untouched surface: flat, so it still shows but isn't bumped - and uncoloured, which is what the + // bake leaves it as (EnforcerBlockerType::NONE, i.e. the volume's own filament). + emit_triangles(rest, 0.f); m_bump_preview_glmodel.init_from(std::move(init_data)); // GLModel::render() unconditionally re-sets the shader's "uniform_color" from this internal @@ -1189,6 +1258,29 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh() // When set, the shader samples at the per-vertex uv baked into the mesh (LSCM) rather than // projecting; see rebuild_bump_preview_mesh(). shader->set_uniform("use_vertex_uv", m_bump_preview_uses_vertex_uv); + + // The filament palette the mesh's per-triangle indices refer to. Count 0 means "no layer is + // colouring", and the shader keeps the model's own colour for every fragment. + // The printable palette, in RGB for display and in Lab for the match. Uploaded rather than + // matched on the CPU because the quantization is per fragment here. + const GLTexture *color_tex = get_layer_color_texture(*layer); + const int palette_count = + (color_tex != nullptr) ? int(std::min(m_bump_preview_palette.size(), size_t(PALETTE_MAX_ENTRIES))) : 0; + shader->set_uniform("palette_count", palette_count); + shader->set_uniform("has_color_tex", color_tex != nullptr); + for (int i = 0; i < palette_count; ++i) { + const Vec3f &rgb = m_bump_preview_palette[size_t(i)].rgb; + float l, a, b; + RGB2Lab(rgb.x() * 255.f, rgb.y() * 255.f, rgb.z() * 255.f, &l, &a, &b); + shader->set_uniform(("palette_rgb[" + std::to_string(i) + "]").c_str(), rgb); + shader->set_uniform(("palette_lab[" + std::to_string(i) + "]").c_str(), Vec3f(l, a, b)); + } + if (color_tex != nullptr) { + shader->set_uniform("color_tex", 1); + glsafe(::glActiveTexture(GL_TEXTURE1)); + glsafe(::glBindTexture(GL_TEXTURE_2D, (GLuint) color_tex->get_id())); + glsafe(::glActiveTexture(GL_TEXTURE0)); + } // The live UV-editor island drag rides this 2x3 affine (identity except mid-drag); only the flagged // island's vertices apply it, so a drag is a uniform update rather than a mesh rebuild. const Eigen::Matrix &d = m_bump_island_delta; @@ -1564,11 +1656,20 @@ void GLGizmoTextureDisplacement::queue_preview_job() input.options = mv->texture_displacement_options; 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: get_extruders_colors() is main-thread state and + // the preview has to be grouped against the same palette it was computed with, not whatever is + // loaded by the time it lands. + input.color = color_settings_for(*mv); + // The filament list the result's indices refer to, captured with the job rather than read back + // when it lands - loading a filament meanwhile must not recolour a preview computed against a + // different list. + const std::vector filaments = m_palette_filaments; m_preview_job_running = true; auto &worker = wxGetApp().plater()->get_ui_job_worker(); queue_job(worker, std::make_unique(std::move(input), generation, m_preview_generation, - [this](indexed_triangle_set its, uint64_t result_generation) { + [this, filaments](TextureDisplacementPreviewResult result, uint64_t result_generation) { + indexed_triangle_set its = std::move(result.mesh); m_preview_job_running = false; if (result_generation != m_preview_generation->load()) { // Superseded while this was computing (it will have aborted early and come back @@ -1580,7 +1681,29 @@ void GLGizmoTextureDisplacement::queue_preview_job() // rather than blanking it; there is no new result to show, not a new empty one. } else { m_preview_glmodel.reset(); - m_preview_glmodel.init_from(its); + m_preview_color_runs.clear(); + if (result.triangle_color.size() == its.indices.size() && !filaments.empty()) { + // Group by *filament*, not by palette entry: what the bake wrote is the resolved + // filament, interleaving already applied, so this shows the real banding rather + // than the flat average the eye will turn it into. + indexed_triangle_set sorted; + sorted.vertices = its.vertices; + sorted.indices.reserve(its.indices.size()); + for (int want = 0; want <= int(filaments.size()); ++want) { + const size_t first = sorted.indices.size(); + for (size_t i = 0; i < its.indices.size(); ++i) + if (int(result.triangle_color[i]) == want) + sorted.indices.push_back(its.indices[i]); + if (sorted.indices.size() == first) + continue; + m_preview_color_runs.push_back( + { { first * 3, sorted.indices.size() * 3 }, + want == 0 ? GLVolume::NEUTRAL_COLOR : filaments[size_t(want - 1)] }); + } + m_preview_glmodel.init_from(sorted); + } else { + m_preview_glmodel.init_from(its); + } m_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR); // Keep the displaced mesh so the wireframe overlay can be drawn on it (the // true-displacement view), then refresh the wireframe from it. @@ -3277,10 +3400,199 @@ TextureDisplacementFacetsData GLGizmoTextureDisplacement::facets_data_of(const M return out; } +bool GLGizmoTextureDisplacement::any_layer_colors(const ModelVolume &mv) +{ + for (const TextureDisplacementLayer &layer : mv.texture_displacement_layers) + if (layer.color_enabled && !layer.empty() && decode_height_texture(layer).has_color()) + return true; + return false; +} + +TextureColorSettings GLGizmoTextureDisplacement::color_settings_for(const ModelVolume &mv) +{ + TextureColorSettings out; + if (!any_layer_colors(mv)) + return out; // nothing is colouring: every colour path stays switched off + out.palette = cached_palette(); + out.mix_mode = mv.texture_displacement_options.color_mix_mode; + out.despeckle_passes = mv.texture_displacement_options.color_despeckle; + out.layer_height = print_layer_height(); + // The dither cell is tied to the colour-detail target: a cell much smaller than a facet cannot be + // drawn at all, and one much larger stops reading as a blend and starts reading as a check. + out.dither_cell_mm = std::max(m_subdivide_color_mm, 0.05f) * 2.f; + return out; +} + +const std::vector &GLGizmoTextureDisplacement::cached_palette() +{ + // Rebuilt only when the loaded filaments or the mixing setting actually change. The bump preview + // rebuilds on every paint stroke and the subdivide preview on every slider frame, and filling the + // quantizer's lookup cube for a 64-entry palette is tens of milliseconds - paying that per stroke + // is the difference between painting that keeps up and painting that stutters. + const ModelVolume *mv = texture_volume(); + const bool mixing = mv != nullptr && mv->texture_displacement_options.color_mix_enabled; + std::vector filaments = filament_palette(); + if (m_palette_cache.empty() || filaments != m_palette_filaments || mixing != m_palette_mixing) { + m_palette_filaments = std::move(filaments); + m_palette_mixing = mixing; + m_palette_cache = make_palette(m_palette_filaments, mixing); + m_palette_quantizer = make_palette_quantizer(m_palette_cache); + } + return m_palette_cache; +} + +std::vector GLGizmoTextureDisplacement::filament_palette() +{ + std::vector palette = wxGetApp().plater()->get_extruders_colors(); + // mmu_segmentation_facets encodes the filament in a 6-bit prefix code and stops at Extruder16. + if (palette.size() > size_t(EnforcerBlockerType::ExtruderMax)) + palette.resize(size_t(EnforcerBlockerType::ExtruderMax)); + return palette; +} + +float GLGizmoTextureDisplacement::print_layer_height() +{ + try { + const DynamicPrintConfig &cfg = wxGetApp().preset_bundle->prints.get_edited_preset().config; + if (const ConfigOptionFloat *opt = cfg.option("layer_height"); opt != nullptr) + if (opt->value > 1e-3) + return float(opt->value); + } catch (...) { + } + return 0.2f; +} + +std::vector GLGizmoTextureDisplacement::make_palette( + const std::vector &filaments, bool mixing) +{ + std::vector out; + const int n = int(filaments.size()); + for (int i = 0; i < n; ++i) + out.push_back({ Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b()), + i, i, 1, 1 }); + if (!mixing || n < 2) + return out; + + // How many intermediate steps each pair gets, chosen so the whole palette stays under + // PALETTE_MAX_ENTRIES. Fewer filaments means more room for mixes, which is also what you want: + // with two filaments the mixes are the only way to get anywhere, and with sixteen there is little + // point mixing at all. `den` is also the band/dither repeat, so a small one is a short pattern. + const int pairs = n * (n - 1) / 2; + int steps = 0; + for (int s = 5; s >= 1; --s) + if (n + pairs * s <= PALETTE_MAX_ENTRIES) { + steps = s; + break; + } + if (steps == 0) + return out; + const int den = steps + 1; + + for (int i = 0; i < n; ++i) + for (int j = i + 1; j < n; ++j) { + float la, aa, ba, lb, ab, bb; + RGB2Lab(filaments[size_t(i)].r() * 255.f, filaments[size_t(i)].g() * 255.f, + filaments[size_t(i)].b() * 255.f, &la, &aa, &ba); + RGB2Lab(filaments[size_t(j)].r() * 255.f, filaments[size_t(j)].g() * 255.f, + filaments[size_t(j)].b() * 255.f, &lb, &ab, &bb); + for (int k = 1; k <= steps; ++k) { + // k/den of filament i, the rest of j - averaged in Lab, which is what the eye does + // when the two are interleaved too finely to resolve. + const float t = float(k) / float(den); + float r, g, b; + Lab2RGB(la * t + lb * (1.f - t), aa * t + ab * (1.f - t), ba * t + bb * (1.f - t), &r, &g, &b); + out.push_back({ Vec3f(std::clamp(r / 255.f, 0.f, 1.f), std::clamp(g / 255.f, 0.f, 1.f), + std::clamp(b / 255.f, 0.f, 1.f)), + i, j, k, den }); + } + } + return out; +} + +ColorResolveFn GLGizmoTextureDisplacement::make_mix_resolver(const std::vector &palette, + ColorMixMode mode, float layer_height, + float cell_mm) +{ + if (palette.empty()) + return nullptr; + auto entries = std::make_shared>(palette); + const float band = std::max(layer_height, 0.01f); + const float cell = std::max(cell_mm, 0.01f); + + return [entries, mode, band, cell](int index, const Vec3f &pos) -> int { + if (index < 0 || size_t(index) >= entries->size()) + return -1; + const PaletteEntry &e = (*entries)[size_t(index)]; + if (!e.is_mix()) + return e.a; + + // Which of the two filaments this point falls on. Both patterns are *ordered*, never random: + // the eye blends a regular pattern into a flat colour, and turns a random one into noise. + if (mode == ColorMixMode::ZBands) { + // One band per print layer. floorf, not a cast, so this stays correct below z = 0. + const int slot = int(std::floor(pos.z() / band)); + const int phase = ((slot % e.den) + e.den) % e.den; + return phase < e.num ? e.a : e.b; + } + // Ordered 4x4 Bayer over the surface, indexed by position so the pattern is stable in space + // rather than in triangle order (which would move under any remesh, and read as noise). + static const int BAYER[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 }; + const int gx = ((int(std::floor(pos.x() / cell)) % 4) + 4) % 4; + const int gy = ((int(std::floor(pos.y() / cell)) % 4) + 4) % 4; + // A third axis would be ideal, but the two dominant ones are enough for a surface pattern and + // keep the cell square on the faces that matter. + const float threshold = (float(BAYER[gy * 4 + gx]) + 0.5f) / 16.f; + return (float(e.num) / float(e.den)) > threshold ? e.a : e.b; + }; +} + +ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::vector &palette) +{ + if (palette.empty()) + return nullptr; + + // Lab once per entry, not once per lookup. + struct Lab { float l, a, b; }; + std::vector palette_lab(palette.size()); + for (size_t i = 0; i < palette.size(); ++i) + RGB2Lab(palette[i].rgb.x() * 255.f, palette[i].rgb.y() * 255.f, palette[i].rgb.z() * 255.f, + &palette_lab[i].l, &palette_lab[i].a, &palette_lab[i].b); + + constexpr int E = PALETTE_LUT_EDGE; + auto lut = std::make_shared>(size_t(E) * E * E, 0); + tbb::parallel_for(tbb::blocked_range(0, E), [&](const tbb::blocked_range &range) { + for (int r = range.begin(); r < range.end(); ++r) + for (int g = 0; g < E; ++g) + for (int b = 0; b < E; ++b) { + // Cell centre, so the quantization error is symmetric across the cell. + float l0, a0, b0; + RGB2Lab((r + 0.5f) / E * 255.f, (g + 0.5f) / E * 255.f, (b + 0.5f) / E * 255.f, &l0, &a0, &b0); + int best = 0; + float best_d = std::numeric_limits::max(); + for (size_t i = 0; i < palette_lab.size(); ++i) { + const float d = DeltaE00(l0, a0, b0, palette_lab[i].l, palette_lab[i].a, palette_lab[i].b); + if (d < best_d) { + best_d = d; + best = int(i); + } + } + (*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best); + } + }); + + return [lut](const Vec3f &rgb) -> int { + constexpr int E = PALETTE_LUT_EDGE; + const int r = std::clamp(int(rgb.x() * E), 0, E - 1); + const int g = std::clamp(int(rgb.y() * E), 0, E - 1); + const int b = std::clamp(int(rgb.z() * E), 0, E - 1); + return int((*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)]); + }; +} + TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, const std::vector &layers, const TextureDisplacementPrepareParams ¶ms, - const DisplacementProgressFn &progress) + const std::vector &palette, const DisplacementProgressFn &progress) { TextureDisplacementPrepareResult out; const auto report = [&progress](int pct) { return !progress || progress(pct); }; @@ -3335,6 +3647,13 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( HeightFieldSampler sampler; if (params.subdiv_feature) 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; + if (params.subdiv_color_edge_mm > 0.f && !palette.empty()) + color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(palette)); + // Note the sampler is built on the *quantizer* alone - the refinement follows perceived + // colour, never the interleaving that realises a mix (see ColorResolveFn). // "Min edge" is a feature-mode control (it is the floor the curvature test refines down // to); in plain adaptive mode the target edge length is the only criterion, so the floor // must not be allowed to silently override a target the user set below it. @@ -3350,7 +3669,8 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( sampler, tol, floor, params.subdiv_border_mm, [&](int pct) { aborted = !report(50 + pct / 2); return !aborted; - }); + }, + color, params.subdiv_color_edge_mm); if (aborted) return {}; if (refined.indices.size() != mesh.its.indices.size()) { @@ -3387,6 +3707,7 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive() params.subdiv_detail_mm = m_subdivide_detail_mm; params.subdiv_min_edge_mm = m_subdivide_min_edge_mm; params.subdiv_border_mm = m_subdivide_border_mm; + params.subdiv_color_edge_mm = m_subdivide_color_mm; params.subdiv_feature = m_subdivide_feature; params.subdiv_added_triangles = m_subdivide_budget_k * 1000; queue_prepare(params, _u8L("Adaptive subdivide for texture displacement"), /* then_bake */ false, @@ -3560,9 +3881,17 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview() // Min-edge floor. Plain adaptive: tol 0, so only the target-edge-length criterion applies. const float tol = m_subdivide_feature ? m_subdivide_detail_mm : 0.f; const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f; + // Same colour criterion Apply will use, so the previewed wireframe is the mesh that commits. + ColorFieldSampler color; + if (m_subdivide_color_mm > 0.f && any_layer_colors(*mv)) { + cached_palette(); // refreshes m_palette_quantizer if the filaments changed + color = make_combined_color_sampler(mv->mesh().its, mv->texture_displacement_layers, facets, + m_palette_quantizer); + } its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000, - nullptr, sampler, tol, floor, m_subdivide_border_mm); + nullptr, sampler, tol, floor, m_subdivide_border_mm, nullptr, color, + m_subdivide_color_mm); } else { if (m_subdivide_count < 1) return; @@ -3634,6 +3963,25 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace return m_thumbnails[slot].get(); } +GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisplacementLayer &layer) +{ + if (layer.empty() || !layer.color_enabled) + return nullptr; + if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing) + return m_color_tex.get(); + + std::unique_ptr texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX); + if (!texture) { + m_color_tex.reset(); + m_color_tex_source = nullptr; + return nullptr; + } + m_color_tex = std::move(texture); + m_color_tex_source = layer.image_data.get(); + m_color_tex_smoothing = layer.smoothing; + return m_color_tex.get(); +} + GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDisplacementLayer &layer) { if (layer.empty()) @@ -3670,7 +4018,7 @@ void GLGizmoTextureDisplacement::bake(bool own_snapshot) } m_bake_in_progress = true; - queue_texture_displacement_bake(*mv, [this]() { + queue_texture_displacement_bake(*mv, color_settings_for(*mv), [this]() { m_bake_in_progress = false; // Baking replaces the volume's mesh (new id, new topology) without changing the object's // id or volume count, so GLGizmoPainterBase::data_changed()'s usual change-detection never @@ -3700,6 +4048,10 @@ static constexpr float STD_SUBDIV_MIN_EDGE_MM = 0.02f; // how clean the rim of an unpainted island looks: the bake steps the surface from full displacement to // zero across that band, and nothing else in the criteria can see the step (see collect_paint_region()). static constexpr float STD_SUBDIV_BORDER_MM = 0.4f; +// Edge length a colour boundary is refined to. Finer than the border band because a colour edge is +// what the eye actually lands on - a stepped outline around a printed decal reads as a defect in a +// way a slightly coarse relief transition does not - and it costs triangles along an outline only. +static constexpr float STD_SUBDIV_COLOR_MM = 0.25f; bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv) { @@ -3721,6 +4073,7 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv) pin(m_subdivide_detail_mm, STD_SUBDIV_DETAIL_MM); pin(m_subdivide_min_edge_mm, STD_SUBDIV_MIN_EDGE_MM); pin(m_subdivide_border_mm, STD_SUBDIV_BORDER_MM); + pin(m_subdivide_color_mm, STD_SUBDIV_COLOR_MM); // Deliberately *not* pinned: the triangle budget stays visible and editable in Standard mode, so // pinning it would fight the user's own slider every frame. pin(m_remesh_target_edge_mm, STD_REMESH_EDGE_MM); @@ -3752,6 +4105,7 @@ void GLGizmoTextureDisplacement::bake_standard() params.subdiv_detail_mm = STD_SUBDIV_DETAIL_MM; params.subdiv_min_edge_mm = STD_SUBDIV_MIN_EDGE_MM; params.subdiv_border_mm = STD_SUBDIV_BORDER_MM; + params.subdiv_color_edge_mm = STD_SUBDIV_COLOR_MM; params.subdiv_feature = true; params.subdiv_added_triangles = m_subdivide_budget_k * 1000; queue_prepare(params, _u8L("Bake texture displacement"), /* then_bake */ true, {}); @@ -3772,6 +4126,8 @@ void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareP input.layers = mv->texture_displacement_layers; input.params = params; input.snapshot_name = snapshot_name; + if (params.subdiv_color_edge_mm > 0.f) + input.color = color_settings_for(*mv); m_prepare_in_progress = true; queue_texture_displacement_prepare(std::move(input), [this, then_bake, unchanged_msg]( @@ -4216,6 +4572,84 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_preview_params_dirty |= ImGui::Checkbox(_u8L("Invert").c_str(), &layer->invert); + // Colour. Only offered for a texture that actually has some - the shipped library is + // grayscale, and a checkbox that silently does nothing on nine textures out of ten is + // worse than no checkbox. Disabled rather than hidden so it is clear the feature exists + // and what it wants. + { + const bool has_color = decode_height_texture(*layer).has_color(); + m_imgui->disabled_begin(!has_color); + bool color_enabled = layer->color_enabled && has_color; + if (ImGui::Checkbox(_u8L("Use image colours").c_str(), &color_enabled)) { + layer->color_enabled = color_enabled; + m_preview_params_dirty = true; + } + m_imgui->disabled_end(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(has_color ? + _u8L("Colours the painted area from the texture's own colours, as well as " + "displacing it. Each colour is matched to the closest printable " + "colour, and the result is written as multi-material paint - so the " + "area prints in those filaments. Everything you did not paint keeps " + "the object's own filament.") : + _u8L("This texture is a grayscale height map, so it has no colours to " + "apply. Import a colour image to use this."), + m_imgui->scaled(20.f)); + + // The rest of colour belongs to the whole stack, not to this layer, so it only appears + // once - under whichever layer turned colour on. + if (color_enabled && mv != nullptr) { + TextureDisplacementOptions &opts = mv->texture_displacement_options; + + if (ImGui::Checkbox(_u8L("Mix filaments").c_str(), &opts.color_mix_enabled)) + m_preview_params_dirty = true; + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Interleave pairs of filaments to reach colours between them, so a few " + "filaments cover far more than a few colours. Off means every triangle " + "prints in one of the filaments exactly."), + m_imgui->scaled(20.f)); + + if (opts.color_mix_enabled) { + m_imgui->text(_u8L("Mix by")); + ImGui::SameLine(); + const std::string mix_z = _u8L("Layers"); + const std::string mix_xy = _u8L("Surface"); + const char *mix_items[] = { mix_z.c_str(), mix_xy.c_str() }; + int mix_mode = int(opts.color_mix_mode); + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + if (scoped_combo("##color_mix_mode", &mix_mode, mix_items, IM_ARRAYSIZE(mix_items))) { + opts.color_mix_mode = ColorMixMode(mix_mode); + m_preview_params_dirty = true; + } + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Layers: the two filaments alternate between print layers, which " + "blends smoothly on upright surfaces but disappears on flat-facing " + "ones, where a whole layer is a single band.\n" + "Surface: a fine checkerboard across the surface, which works at " + "any angle but can read as texture rather than as a blend."), + m_imgui->scaled(20.f)); + + const int count = int(cached_palette().size()); + ImGui::TextDisabled("%s", from_u8(Slic3r::format( + _u8L("%1% printable colours from %2% filaments"), count, + int(m_palette_filaments.size()))).ToUTF8().data()); + } + + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + if (ImGui::SliderInt(_u8L("Denoise").c_str(), &opts.color_despeckle, 0, 6)) + m_preview_params_dirty = true; + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Removes stray single triangles of the wrong colour, which is what " + "detail in the image finer than the mesh leaves behind. Each step " + "replaces a triangle's colour with the one most of its neighbours " + "have, so features wider than a triangle are kept. Raise it if the " + "result looks speckled; lower it if fine detail is being eaten."), + m_imgui->scaled(20.f)); + } + } + // The lowest painted layer has nothing underneath it to combine with - it *is* the // base - so a blend mode would be meaningless (and Multiply/Divide against an implicit // zero would annihilate it). build_texture_displacement() forces the first layer to @@ -4784,6 +5218,21 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "0 turns it off."), m_imgui->scaled(20.f)); + // Only worth showing when a layer is actually colouring: with no colour there is no boundary + // for it to refine and the control would do nothing whatever it is set to. + if (mv != nullptr && any_layer_colors(*mv)) { + if (m_imgui->slider_float(std::string(_u8L("Colour detail (mm)")) + "##subdivcolor", + &m_subdivide_color_mm, 0.f, 5.f, "%.3f")) + preview_live(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Triangle size along a boundary between two colours. Colour is assigned per " + "triangle, so an edge between two filaments can only be as clean as the " + "triangles along it - and the relief criteria cannot see it at all, because " + "the surface is perfectly smooth across a change of colour. Costs triangles " + "along the boundaries only. 0 turns it off."), + m_imgui->scaled(20.f)); + } + ImGui::PopItemWidth(); budget_slider(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 92461fdd1b..93bbc61117 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -45,11 +45,80 @@ public: const TextureDisplacementFacetsData &masks, const std::vector &layers, const TextureDisplacementPrepareParams ¶ms, + const std::vector &palette, const DisplacementProgressFn &progress); // The volume's eight texture-displacement masks, gathered into the array every pure function here // (and every job input) takes. static TextureDisplacementFacetsData facets_data_of(const ModelVolume &mv); + using PaletteEntry = PrintableColor; + + // The printable palette: the loaded filaments (clamped to the sixteen mmu_segmentation_facets can + // address), plus - when `mixing` - every pair of them at evenly spaced ratios. + // + // Mixes are averaged in **CIELAB**, not RGB and not subtractively: two filaments interleaved too + // finely to resolve are averaged by the eye, which is what a perceptual space models. Yellow and + // blue banded together read as a desaturated grey-green, and that is what the preview must promise + // - blending them subtractively would show a green the printer cannot produce this way. + // + // How many ratios depends on how many filaments there are, so the palette stays bounded: the + // quantizer's lookup cube costs one DeltaE00 per cell per entry to fill, and with sixteen + // filaments there are already plenty of colours without mixing any of them. + static std::vector make_palette(const std::vector &filaments, bool mixing); + + // Maps an image colour to the closest entry of `palette`, perceptually (CIEDE2000 over CIELAB - a + // plain RGB distance picks visibly wrong filaments, most obviously between a saturated colour and + // a grey of similar brightness). + // + // Precomputed into a lookup cube rather than matched per call: the subdivision's colour criterion + // samples up to seven points per triangle and re-samples both children of every split, so a live + // match would dominate the refinement. The returned closure owns the cube, so it is safe to hand + // to a worker thread and outlives the palette it was built from. + static ColorQuantizeFn make_palette_quantizer(const std::vector &palette); + + // Turns a palette index plus a position into the filament to print there, interleaving the two + // filaments of a mixed entry per `mode`. `layer_height` sizes the Z bands; `cell_mm` the dither + // cells. See ColorResolveFn for why this is separate from the quantizer. + static ColorResolveFn make_mix_resolver(const std::vector &palette, ColorMixMode mode, + float layer_height, float cell_mm); + + // Everything the jobs need to colour with, for the current volume: palette, mix mode, layer + // height, despeckle. Empty when no layer is actually colouring. + TextureColorSettings color_settings_for(const ModelVolume &mv); + + // The printable palette for the current filaments and mixing setting, rebuilt only when either + // actually changes - see the definition for why that caching is not optional. + const std::vector &cached_palette(); + std::vector m_palette_cache; + std::vector m_palette_filaments; + bool m_palette_mixing = false; + ColorQuantizeFn m_palette_quantizer; + + // The loaded filaments, clamped to the sixteen mmu_segmentation_facets can address. + static std::vector filament_palette(); + // The print's layer height, which sizes ColorMixMode::ZBands. Falls back to 0.2 mm if it cannot be + // read - a wrong band size is a cosmetic error, not a reason to refuse to colour anything. + static float print_layer_height(); + + // The Normal preview's triangles, grouped by the filament they will print in. Colour is per facet + // and there are at most sixteen filaments, so the mesh is uploaded once with its index buffer + // sorted by colour and drawn as one GLModel::render(range) per group - which needs no per-vertex + // colour attribute, and so no change to GLModel's vertex layouts. + // + // The *index buffer* is what gets reordered, never m_preview_its: the paint overlay and the + // wireframe index into that by the volume's own triangle numbering (the bake is + // topology-preserving), and permuting it would silently misplace both. + struct PreviewColorRun + { + std::pair range; // into the GLModel's index buffer, in elements + ColorRGBA color; + }; + std::vector m_preview_color_runs; + // True if any of the volume's layers would actually colour something: colour turned on, and a + // texture that has colour to give. What decides whether a palette is captured into a job at all, + // and so whether the colour criterion and the mmu write ever run. + static bool any_layer_colors(const ModelVolume &mv); + void render_painter_gizmo() override; // Intercepts mouse input while "Adjust Texture" mode is on (dragging the on-canvas offset/ @@ -214,6 +283,9 @@ private: // The same texture at full resolution, for the fast-preview shader. One slot, shared by whichever // layer is active, because that is the only one the bump shader ever shades. GLTexture *get_layer_height_texture(const TextureDisplacementLayer &layer); + // The layer's colour texture for the fast preview's per-fragment quantization. Null when the + // layer is not colouring or its texture is grayscale. + GLTexture *get_layer_color_texture(const TextureDisplacementLayer &layer); // A texture from the picker's library (see slic3r/GUI/TextureLibrary.hpp), read and uploaded // once and then kept for the gizmo's lifetime. The decoded bytes are held alongside the GPU @@ -423,6 +495,13 @@ private: // transition keeps the input's density and the rim of an unpainted island comes out as a ring of // large, steeply tilted triangles. See collect_paint_region() and subdivide_mesh_adaptive(). float m_subdivide_border_mm = 0.4f; + // Edge length triangles straddling a *colour* boundary are refined to (0 = ignore colour). Its own + // control rather than a share of "Detail (mm)" because the two measure different things: Detail is + // a chord error in mm of surface deviation, this is a triangle size in mm along a step the chord + // test cannot see at all - the height field is perfectly smooth across a change of filament, so + // without this a colour boundary lands on whatever triangles the relief happened to need, which on + // a flat surface is none. Only ever costs anything where a boundary actually runs. + float m_subdivide_color_mm = 0.3f; // How many thousand triangles refinement may *add* (the mesh's own count is added on before it is // passed as subdivide_mesh_adaptive()'s absolute cap, so the control still means something on a // dense model). Refinement is worst-error-first, so hitting the budget still yields the best mesh @@ -504,6 +583,12 @@ private: // Whether the current bump mesh carries a precomputed per-vertex uv (LSCM) that the shader // should sample at directly, rather than projecting in-shader. Set by rebuild_bump_preview_mesh(). bool m_bump_preview_uses_vertex_uv = false; + // The palette the fast preview's per-triangle filament indices were built against, captured when + // the mesh was. Empty when the active layer is not colouring, which is what tells the shader to + // fall back to the model's own colour. Held rather than re-read at draw time so the indices baked + // into the mesh can never be resolved against a different set of filaments than they were computed + // from - loading a filament mid-session would otherwise recolour a stale preview at random. + std::vector m_bump_preview_palette; // GPU island drag: while an island is dragged in the UV editor, the bump mesh is baked once (with // the dragged island's vertices flagged, v_normal.y = 1) and then moved purely through the shader's @@ -621,6 +706,12 @@ private: const void *m_height_tex_source = nullptr; float m_height_tex_smoothing = -1.f; + // The same, for the layer's *colour*, which the fast preview quantizes per fragment so it shows + // the image at texel resolution rather than at the mesh's. Null for a grayscale texture. + std::unique_ptr m_color_tex; + const void *m_color_tex_source = nullptr; + float m_color_tex_smoothing = -1.f; + // Library textures the picker has shown at least once, keyed by file path (see LibraryTexture). std::map m_library_textures; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index 91493a9173..eac60eb392 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -3,10 +3,12 @@ #include #include "libslic3r/Model.hpp" +#include "libslic3r/TriangleSelector.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" +#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp" #include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/Utils/UndoRedo.hpp" @@ -30,6 +32,21 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) // grows a close button once it reaches 100%, so a job that reports 0 and nothing else leaves an // uncloseable notification pinned on screen. It also carries the Cancel button's effect into the // bake, which on a subdivided mesh can run for several seconds. + // Colour, when any layer asks for it, is computed in the same pass as the displacement: both need + // the same per-layer projection and UV work, and doing it twice would double the expensive part. + TextureColorRequest color_request; + TextureColorRequest *color = nullptr; + if (!m_input.color.empty()) { + color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette); + color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver( + m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height, + m_input.color.dither_cell_mm); + color_request.despeckle_passes = m_input.color.despeckle_passes; + color_request.out_triangle = &m_triangle_color; + if (color_request.quantize) + color = &color_request; + } + int last_reported = 1; m_result = TriangleMesh(build_texture_displacement( m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options, @@ -43,7 +60,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) ctl.update_status(percent, status); } return true; - })); + }, + color)); // 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. @@ -72,6 +90,23 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept volume->set_new_unique_id(); volume->calculate_convex_hull(); + // 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. + if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) { + TriangleSelector selector(volume->mesh()); + const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data(); + if (!existing.bitstream.empty()) + selector.deserialize(existing, false); + for (size_t i = 0; i < m_triangle_color.size(); ++i) + if (m_triangle_color[i] > 0) + selector.set_facet(int(i), EnforcerBlockerType(m_triangle_color[i])); + volume->mmu_segmentation_facets.set(selector); + } + // Clear the paint mask of every layer that was actually baked so a repeat bake (or the paint // overlay) doesn't act on triangles that no longer represent the same unbaked surface. The // texture layer definitions themselves (and paint outside the baked area, if any) are left @@ -107,10 +142,11 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept } } -void queue_texture_displacement_bake(const ModelVolume &volume, std::function on_finished, - bool take_snapshot) +void queue_texture_displacement_bake(const ModelVolume &volume, const TextureColorSettings &color, + std::function on_finished, bool take_snapshot) { TextureDisplacementBakeInput input; + input.color = color; input.take_snapshot = take_snapshot; input.volume_id = volume.id(); input.base_mesh = volume.mesh().its; @@ -119,6 +155,7 @@ void queue_texture_displacement_bake(const ModelVolume &volume, std::functionget_ui_job_worker(); queue_job(worker, std::make_unique(std::move(input), std::move(on_finished))); } diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp index bf0fcaa8bd..cd3772ae47 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp @@ -4,6 +4,7 @@ #include #include +#include "libslic3r/Color.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -22,6 +23,10 @@ struct TextureDisplacementBakeInput std::vector layers; TextureDisplacementFacetsData facets_data; TextureDisplacementOptions options; + // Captured on the main thread. Empty unless some layer is colouring, in which case the bake also + // writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU + // paint gizmo writes - alongside the displaced geometry. + TextureColorSettings color; // 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. @@ -42,6 +47,9 @@ public: private: TextureDisplacementBakeInput m_input; TriangleMesh m_result; + // Per triangle of m_result: the filament to print it in, as an EnforcerBlockerType value + // (0 = leave alone). Empty unless a layer asked for colour. See TextureColorRequest. + std::vector m_triangle_color; std::function m_on_finished; }; @@ -49,8 +57,8 @@ private: // the app's UI job worker. `on_finished` is always called once the job settles (success, failure, // or cancellation), so the caller can clear its own "bake in progress" UI state. Must be called // from the main thread. -void queue_texture_displacement_bake(const ModelVolume &volume, std::function on_finished, - bool take_snapshot = true); +void queue_texture_displacement_bake(const ModelVolume &volume, const TextureColorSettings &color, + std::function on_finished, bool take_snapshot = true); } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp index 5b6cdd246a..4494594c7d 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp @@ -35,7 +35,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl) // idle loop. int last_reported = 1; m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers, - m_input.params, + m_input.params, m_input.color.palette, [&ctl, &status, &last_reported](int percent) { if (ctl.was_canceled()) return false; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp index 90d723d88f..efe6a8ce75 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp @@ -7,6 +7,7 @@ #include "Job.hpp" +#include "libslic3r/Color.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -43,6 +44,10 @@ struct TextureDisplacementPrepareInput TextureDisplacementFacetsData masks; std::vector layers; TextureDisplacementPrepareParams params; + // Captured on the main thread. Empty when no layer is colouring, in which case the refinement + // 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 undo step the commit opens. Standard mode's Bake names it after the bake, because the // displacement job that follows commits into this same step rather than pushing its own. std::string snapshot_name; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 44bcf37725..adde26f884 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -1,12 +1,13 @@ #include "TextureDisplacementPreviewJob.hpp" #include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp" namespace Slic3r::GUI { TextureDisplacementPreviewJob::TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation, std::shared_ptr> current_generation, - std::function on_finished) + std::function on_finished) : m_input(std::move(input)), m_generation(generation), m_current_generation(std::move(current_generation)), m_on_finished(std::move(on_finished)) { @@ -19,7 +20,21 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) // Only ever touches m_input (captured by value before this job was queued) and local state - // never the live Model - so this is safe to run concurrently with the UI thread. - m_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options, + TextureColorRequest color_request; + TextureColorRequest *color = nullptr; + if (!m_input.color.empty()) { + color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette); + color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver( + m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height, + m_input.color.dither_cell_mm); + color_request.despeckle_passes = m_input.color.despeckle_passes; + color_request.out_triangle = &m_result.triangle_color; + if (color_request.quantize) + color = &color_request; + } + + m_result.mesh = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, + m_input.options, [this, &ctl](int) { // Bail the moment this preview stops being the current // one; build_texture_displacement() then returns an @@ -27,7 +42,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) return !ctl.was_canceled() && (!m_current_generation || m_current_generation->load() == m_generation); - }); + }, + color); } void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) @@ -40,7 +56,7 @@ void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr & // was ever queued again for the rest of the session. An empty result is the caller's signal that // nothing usable came back; it already handles that. if (canceled || eptr) - m_on_finished(indexed_triangle_set{}, m_generation); + m_on_finished(TextureDisplacementPreviewResult{}, m_generation); else m_on_finished(std::move(m_result), m_generation); } diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index f9e2a9f389..2a67b8c7cc 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -7,6 +7,7 @@ #include #include +#include "libslic3r/Color.hpp" #include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -22,6 +23,17 @@ struct TextureDisplacementPreviewInput std::vector layers; TextureDisplacementFacetsData facets_data; TextureDisplacementOptions options; + // Empty unless a layer is colouring, in which case the preview reports the filament per triangle + // alongside the mesh, so the Normal view shows what the bake will produce - interleaving included. + TextureColorSettings color; +}; + +// A preview result: the displaced mesh, and - when the input carried a palette - one filament index +// per triangle (an EnforcerBlockerType value; 0 means "no colour from the texture"). +struct TextureDisplacementPreviewResult +{ + indexed_triangle_set mesh; + std::vector triangle_color; }; // Computes the true (unbaked) displaced-mesh preview in the background. With several painted @@ -49,7 +61,7 @@ public: // order: without it, a Bake queued behind a handful of stale previews waits for every one of them. TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation, std::shared_ptr> current_generation, - std::function on_finished); + std::function on_finished); void process(Ctl &ctl) override; void finalize(bool canceled, std::exception_ptr &eptr) override; @@ -58,8 +70,8 @@ private: TextureDisplacementPreviewInput m_input; uint64_t m_generation; std::shared_ptr> m_current_generation; - indexed_triangle_set m_result; - std::function m_on_finished; + TextureDisplacementPreviewResult m_result; + std::function m_on_finished; }; } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp index 67df12c32b..3af043a462 100644 --- a/src/slic3r/GUI/TextureLibrary.cpp +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -10,6 +10,7 @@ #include #include +#include #include "libslic3r/PNGReadWrite.hpp" #include "libslic3r/Utils.hpp" @@ -47,6 +48,52 @@ void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector &out, std::string &error) +{ + if (image.GetWidth() <= 0 || image.GetHeight() <= 0) { + error = _u8L("The selected image is empty."); + return false; + } + wxMemoryOutputStream stream; + // Alpha would be lost on the way into DecodedHeightTexture anyway, and a partly transparent + // texture reading as black relief is worse than reading as its own colour over the background. + wxImage opaque = image; + if (opaque.HasAlpha()) + opaque.ClearAlpha(); + if (!opaque.SaveFile(stream, wxBITMAP_TYPE_PNG)) { + error = _u8L("Failed to prepare the texture for use."); + return false; + } + const size_t size = size_t(stream.GetLength()); + out.resize(size); + stream.CopyTo(out.data(), size); + if (out.empty()) { + error = _u8L("Failed to prepare the texture for use."); + return false; + } + return true; +} + // Slic3r::png only writes PNGs to a file, so the encode round-trips through a temp file rather than // staying in memory. It happens once per import / per texture pick, not per frame, so the I/O is // not worth avoiding with a second PNG encoder. @@ -87,14 +134,22 @@ bool encode_gray_png_bytes(const wxImage &image, std::vector &out return true; } +// Grayscale in, grayscale out; colour in, colour out. +bool encode_png_bytes(const wxImage &image, std::vector &out, std::string &error) +{ + return image_has_color(image) ? encode_color_png_bytes(image, out, error) + : encode_gray_png_bytes(image, out, error); +} + std::vector read_file_bytes(const std::string &path) { std::ifstream ifs(path, std::ios::binary); return std::vector(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); } -// True if these bytes are already the 8-bit grayscale PNG libslic3r can decode, i.e. can be stored -// on a layer as-is. Mirrors exactly what decode_height_texture() accepts. +// True if these bytes are a PNG libslic3r can decode, i.e. can be stored on a layer as-is. Mirrors +// exactly what decode_height_texture() accepts: 8-bit grayscale, or colour (whose luminance is the +// height and whose RGB is available to colour the model). bool is_supported_height_map(const std::vector &bytes) { if (bytes.empty()) @@ -102,8 +157,12 @@ bool is_supported_height_map(const std::vector &bytes) const png::ReadBuf rbuf{ bytes.data(), bytes.size() }; if (!png::is_png(rbuf)) return false; - png::ImageGreyscale img; - return png::decode_png(rbuf, img) && img.cols > 0 && img.rows > 0; + png::ImageGreyscale gray; + if (png::decode_png(rbuf, gray) && gray.cols > 0 && gray.rows > 0) + return true; + png::ImageColorscale color; + return png::decode_colored_png(rbuf, color) && color.cols > 0 && color.rows > 0 && + color.bytes_per_pixel >= 3; } std::vector g_library; @@ -153,7 +212,7 @@ std::optional import_texture_to_library(const std::string & } std::vector bytes; - if (!encode_gray_png_bytes(image, bytes, error)) + if (!encode_png_bytes(image, bytes, error)) return std::nullopt; // Never overwrite an existing texture (the user's or, if they picked the same name twice, their @@ -192,15 +251,15 @@ std::shared_ptr> load_texture_image_data(const std::s if (is_supported_height_map(bytes)) return std::make_shared>(std::move(bytes)); - // Not an 8-bit grayscale PNG (a colour image somebody copied into the folder by hand, say): - // convert it the same way an import would, but leave the file on disk alone. + // Not a PNG at all (a jpg somebody copied into the folder by hand, say): convert it the same way + // an import would - keeping its colour if it has any - but leave the file on disk alone. wxImage image; if (!image.LoadFile(from_u8(path)) || !image.IsOk()) { error = _u8L("Could not load the selected image."); return nullptr; } std::vector converted; - if (!encode_gray_png_bytes(image, converted, error)) + if (!encode_png_bytes(image, converted, error)) return nullptr; return std::make_shared>(std::move(converted)); } diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 5978418ae2..6d6fda7ca1 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -634,3 +634,216 @@ TEST_CASE("TextureDisplacement: feature-adaptive subdivision follows curvature, CHECK(worst <= 0.03f); } } + +// A 2x2 truecolour PNG: red, green / blue, white. Written out as bytes rather than encoded here +// because libslic3r only *writes* grayscale PNGs (png::write_gray_to_file) - which is also exactly +// why the colour path exists: the GUI importer stores colour images through wxImage instead. +static std::shared_ptr> make_rgb_png_2x2() +{ + static const unsigned char bytes[] = { + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, + 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, + 0x14, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xc0, + 0x00, 0xc2, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x1f, 0xee, 0x05, + 0xfb, 0x60, 0x6c, 0x70, 0xf2, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, + }; + return std::make_shared>(std::begin(bytes), std::end(bytes)); +} + +TEST_CASE("TextureDisplacement: a colour texture decodes to both colour and height", "[TextureDisplacement]") +{ + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_rgb_png_2x2(); + + const DecodedHeightTexture tex = decode_height_texture(layer); + REQUIRE_FALSE(tex.empty()); + REQUIRE(tex.has_color()); + REQUIRE(tex.width == 2); + REQUIRE(tex.height == 2); + REQUIRE(tex.rgb.size() == 2 * 2 * 3); + + // Row-major, top-to-bottom: red, green / blue, white. + CHECK(tex.rgb[0] == 255); CHECK(tex.rgb[1] == 0); CHECK(tex.rgb[2] == 0); + CHECK(tex.rgb[3] == 0); CHECK(tex.rgb[4] == 255); CHECK(tex.rgb[5] == 0); + CHECK(tex.rgb[6] == 0); CHECK(tex.rgb[7] == 0); CHECK(tex.rgb[8] == 255); + CHECK(tex.rgb[9] == 255); CHECK(tex.rgb[10] == 255); CHECK(tex.rgb[11] == 255); + + // Height is the luminance, with wxImage::ConvertToGreyscale()'s coefficients - which is what + // makes a texture displace identically whether it was imported before or after colour was kept. + CHECK(int(tex.pixels[0]) == int(std::lround(0.299 * 255))); // red + CHECK(int(tex.pixels[1]) == int(std::lround(0.587 * 255))); // green + CHECK(int(tex.pixels[2]) == int(std::lround(0.114 * 255))); // blue + CHECK(int(tex.pixels[3]) == 255); // white +} + +TEST_CASE("TextureDisplacement: a grayscale texture reports no colour", "[TextureDisplacement]") +{ + // The shipped library is all grayscale, and has_color() is what the whole colour feature keys + // off - a height map must never look like it has colours to apply. + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_flat_gray_png(128); + + const DecodedHeightTexture tex = decode_height_texture(layer); + REQUIRE_FALSE(tex.empty()); + CHECK_FALSE(tex.has_color()); + CHECK(tex.rgb.empty()); + + Vec3f out(9.f, 9.f, 9.f); + CHECK_FALSE(sample_layer_color(tex, layer, Vec3f::Zero(), Vec3f::UnitZ(), out)); + CHECK(out.x() == 9.f); // left untouched on a false return +} + +// Two triangles making a 10x10 quad in the z=0 plane. +static indexed_triangle_set color_test_quad() +{ + indexed_triangle_set quad; + quad.vertices = { Vec3f(0, 0, 0), Vec3f(10, 0, 0), Vec3f(10, 10, 0), Vec3f(0, 10, 0) }; + quad.indices = { { 0, 1, 2 }, { 0, 2, 3 } }; + return quad; +} + +TEST_CASE("TextureDisplacement: colour is reported per triangle and only where painted", "[TextureDisplacement]") +{ + const indexed_triangle_set quad = color_test_quad(); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_rgb_png_2x2(); + layer.color_enabled = true; + layer.depth_mm = 0.f; // colour only, so this isolates the colour path from the geometry + layer.tiling_scale = 100.f; + layer.projection_method = TextureProjectionMethod::Triplanar; + + TriangleMesh mesh(quad); + TriangleSelector selector(mesh); + selector.set_facet(0, EnforcerBlockerType::ENFORCER); // only the first triangle + + TextureDisplacementFacetsData facets; + facets[0] = selector.serialize(); + + // A three-entry palette matched in plain RGB: all this test needs is *an* index. The perceptual + // matching is the GUI's (make_palette_quantizer), and is deliberately not under test here. + const std::array palette = { Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(0, 0, 1) }; + TextureColorRequest request; + std::vector triangle_color; + request.out_triangle = &triangle_color; + request.quantize = [&palette](const Vec3f &rgb) { + int best = 0; + float bd = std::numeric_limits::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(quad, { layer }, facets, {}, {}, &request); + + REQUIRE_FALSE(out.indices.empty()); + REQUIRE(triangle_color.size() == quad.indices.size()); + // The painted triangle takes a filament; the unpainted one is left at 0, which is + // EnforcerBlockerType::NONE - "use 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. + CHECK(triangle_color[0] != 0); + CHECK(triangle_color[1] == 0); +} + +TEST_CASE("TextureDisplacement: a layer that is not colouring reports no colours", "[TextureDisplacement]") +{ + const indexed_triangle_set quad = color_test_quad(); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_rgb_png_2x2(); + layer.color_enabled = false; // the checkbox is off: colour stays off even on a colour texture + layer.depth_mm = 1.f; + + TriangleMesh mesh(quad); + TriangleSelector selector(mesh); + selector.set_facet(0, EnforcerBlockerType::ENFORCER); + selector.set_facet(1, EnforcerBlockerType::ENFORCER); + + TextureDisplacementFacetsData facets; + facets[0] = selector.serialize(); + + TextureColorRequest request; + std::vector triangle_color; + request.out_triangle = &triangle_color; + request.quantize = [](const Vec3f &) { return 0; }; + + build_texture_displacement(quad, { layer }, facets, {}, {}, &request); + + REQUIRE(triangle_color.size() == quad.indices.size()); + CHECK(triangle_color[0] == 0); + CHECK(triangle_color[1] == 0); +} + +TEST_CASE("TextureDisplacement: subdivision refines a colour boundary a flat height field hides", + "[TextureDisplacement]") +{ + // A cube with a flat height field, so *nothing* in the height criteria has any reason to refine + // it - which is exactly the case the colour criterion exists for. Closed, so every_edge_used_twice() + // is an exact crack detector: the colour criterion goes through the same conformal bisection as + // everything else and must not be able to open one. + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + const std::vector region(cube.indices.size(), REFINE_PAINTED); + + auto longest_edge = [](const indexed_triangle_set &its, const stl_triangle_vertex_indices &t) { + float m = 0.f; + for (int e = 0; e < 3; ++e) + m = std::max(m, (its.vertices[t[e]] - its.vertices[t[(e + 1) % 3]]).norm()); + return m; + }; + + // One filament on each side of x = 5: a step, with no gradient anywhere for a chord test to see. + ColorFieldSampler split_at_five = [](const Vec3f &p, const Vec3f &) { return p.x() < 5.f ? 0 : 1; }; + ColorFieldSampler all_one = [](const Vec3f &, const Vec3f &) { return 0; }; + + SECTION("a colour boundary gets triangles") + { + const indexed_triangle_set out = + subdivide_mesh_adaptive(cube, region, /*max edge*/ 0.f, 200000, nullptr, nullptr, 0.f, + /*min_edge*/ 0.05f, /*border*/ 0.f, nullptr, split_at_five, + /*colour edge*/ 0.5f); + + CHECK(out.indices.size() > cube.indices.size()); + CHECK(every_edge_used_twice(out)); // still watertight + + // Every triangle still straddling the boundary must be down at the target. + for (const auto &t : out.indices) { + bool straddles = false; + for (int i = 1; i < 3; ++i) + if ((out.vertices[t[i]].x() < 5.f) != (out.vertices[t[0]].x() < 5.f)) + straddles = true; + if (straddles) + CHECK(longest_edge(out, t) <= 0.5f + 1e-4f); + } + } + + SECTION("a uniform colour adds nothing") + { + const indexed_triangle_set out = + subdivide_mesh_adaptive(cube, region, /*max edge*/ 0.f, 200000, nullptr, nullptr, 0.f, + /*min_edge*/ 0.05f, /*border*/ 0.f, nullptr, all_one, + /*colour edge*/ 0.5f); + + CHECK(out.indices.size() == cube.indices.size()); + } + + SECTION("no colour sampler leaves the mesh alone") + { + // The regression this guards: the colour criterion must be inert when nothing is colouring, + // or every bake would start refining geometry for no reason. + const indexed_triangle_set out = + subdivide_mesh_adaptive(cube, region, /*max edge*/ 0.f, 200000, nullptr, nullptr, 0.f, + /*min_edge*/ 0.05f, /*border*/ 0.f, nullptr, nullptr, + /*colour edge*/ 0.5f); + + CHECK(out.indices.size() == cube.indices.size()); + } +}