diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 6737b6e560..a1e1ac8069 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -357,7 +357,12 @@ void main() // 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 = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)]; + // The fourth channel is coverage: where the picture is not there, the surface keeps its own + // colour rather than being painted the black that a transparent texel's rgb reads as. + if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) { + vec4 tex_rgba = texture2D(color_tex, color_uv); + if (tex_rgba.a >= 0.5) + albedo = palette_rgb[nearest_palette_entry(tex_rgba.rgb)]; + } gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index b5c68c0069..e90de36ad1 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -445,7 +445,12 @@ void main() // 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 = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)]; + // The fourth channel is coverage: where the picture is not there, the surface keeps its own + // colour rather than being painted the black that a transparent texel's rgb reads as. + if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) { + vec4 tex_rgba = texture(color_tex, color_uv); + if (tex_rgba.a >= 0.5) + albedo = palette_rgb[nearest_palette_entry(tex_rgba.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 ff6b95456c..334a5650d1 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -115,6 +115,20 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi return top * (1.f - ty) + bottom * ty; } +float DecodedHeightTexture::sample_alpha(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const +{ + TexelTap tap; + if (!has_alpha()) + return 1.f; // no coverage plane means the picture is everywhere, which is the usual case + if (!texel_tap(uv, tile_enabled, tile_method, tap)) + return 0.f; + + auto at = [this](int x, int y) { return float(alpha[size_t(y) * size_t(width) + size_t(x)]) / 255.f; }; + const float top = at(tap.x0, tap.y0) * (1.f - tap.tx) + at(tap.x1, tap.y0) * tap.tx; + const float 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; +} + Vec3f DecodedHeightTexture::sample_color(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const { TexelTap tap; @@ -261,10 +275,93 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer) return decode_height_texture(layer).has_color(); } +namespace { +// Unsharp mask over the colour: blur a copy and push each texel away from it. The blur is the same +// sliding-window helper the Smoothing slider uses, run per channel, so the cost is the image size +// rather than the image size times the radius. +// +// Worth having here for a reason it would not be in a photo editor: the picture is about to be +// reduced to a handful of flat colours, and a soft edge between two of them quantizes into a ragged +// band of both. Sharpening first makes that boundary land in one place. +void sharpen_rgb(DecodedHeightTexture &tex, float amount) +{ + if (!tex.has_color() || amount <= 0.f) + return; + const size_t n = size_t(tex.width) * size_t(tex.height); + // A small radius, scaled with the image so a 4k picture is not sharpened a tenth as hard as a + // 512 one. Deliberately not tied to `amount`: that is how much, this is how wide. + const int radius = std::max(1, int(std::lround(double(std::min(tex.width, tex.height)) / 512.0))); + const float k = std::clamp(amount, 0.f, 1.f) * 1.5f; + + std::vector plane(n); + for (int c = 0; c < 3; ++c) { + for (size_t i = 0; i < n; ++i) + plane[i] = tex.rgb[i * 3 + size_t(c)]; + smooth_height_pixels_box(plane, tex.width, tex.height, radius); + for (size_t i = 0; i < n; ++i) { + const float sharp = float(tex.rgb[i * 3 + size_t(c)]) + k * (float(tex.rgb[i * 3 + size_t(c)]) - float(plane[i])); + tex.rgb[i * 3 + size_t(c)] = uint8_t(std::lround(std::clamp(sharp, 0.f, 255.f))); + } + } +} + +// Two levels, on luma. For line art and logos, where the useful result is exactly two filaments and +// anything in between is speckle. +void apply_threshold(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer) +{ + if (!tex.has_color() || !layer.threshold_enabled) + return; + const float cut = std::clamp(layer.threshold, 0.f, 1.f) * 255.f; + const size_t n = size_t(tex.width) * size_t(tex.height); + for (size_t i = 0; i < n; ++i) { + const float luma = 0.2126f * float(tex.rgb[i * 3]) + 0.7152f * float(tex.rgb[i * 3 + 1]) + + 0.0722f * float(tex.rgb[i * 3 + 2]); + const uint8_t v = luma >= cut ? 255 : 0; + tex.rgb[i * 3] = tex.rgb[i * 3 + 1] = tex.rgb[i * 3 + 2] = v; + } +} + +// Keys out the colour the picture's corners share, so a logo on a white card prints as the logo and +// the object's own colour shows through the rest. +// +// The corners rather than a colour the user picks: a background that does not reach all four of them +// is not a background, and asking for an eyedropper would be a second interaction for the case this +// exists to make one click. The median of the four is used so a single stray corner cannot decide it. +void remove_background(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer) +{ + if (!tex.has_color() || !layer.bg_remove || tex.width < 2 || tex.height < 2) + return; + const size_t n = size_t(tex.width) * size_t(tex.height); + const size_t corner[4] = { 0, size_t(tex.width) - 1, (size_t(tex.height) - 1) * size_t(tex.width), + n - 1 }; + float key[3]; + for (int c = 0; c < 3; ++c) { + std::array v{}; + for (int i = 0; i < 4; ++i) + v[size_t(i)] = float(tex.rgb[corner[i] * 3 + size_t(c)]); + std::sort(v.begin(), v.end()); + key[c] = 0.5f * (v[1] + v[2]); + } + // Euclidean in 0..255 RGB, against a tolerance scaled to the diagonal of that cube, so the + // slider reads as "how different from the background still counts as background". + const float limit = std::clamp(layer.bg_tolerance, 0.f, 1.f) * 441.67f; + if (!tex.has_alpha()) + tex.alpha.assign(n, 255); + for (size_t i = 0; i < n; ++i) { + const float dr = float(tex.rgb[i * 3]) - key[0]; + const float dg = float(tex.rgb[i * 3 + 1]) - key[1]; + const float db = float(tex.rgb[i * 3 + 2]) - key[2]; + if (std::sqrt(dr * dr + dg * dg + db * db) <= limit) + tex.alpha[i] = 0; + } +} +} // namespace + bool has_color_adjustments(const TextureDisplacementLayer &layer) { return layer.brightness != 0.f || layer.contrast != 0.f || layer.saturation != 1.f || - layer.gamma != 1.f || layer.color_invert || layer.hue != 0.f; + layer.gamma != 1.f || layer.color_invert || layer.hue != 0.f || layer.sharpness > 0.f || + layer.threshold_enabled || layer.bg_remove; } void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer) @@ -336,6 +433,14 @@ void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacemen } } }); + + // Sharpen, threshold and the background key are all passes over the whole image rather than + // per-texel arithmetic, so they come after the loop above and in this order: sharpening a + // thresholded image would only ring along the one edge it has left, and the key has to read the + // colours the user is actually looking at. + sharpen_rgb(tex, layer.sharpness); + apply_threshold(tex, layer); + remove_background(tex, layer); } DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer) @@ -390,6 +495,10 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer result.height = h; result.pixels.resize(size_t(w) * size_t(h)); result.rgb.resize(size_t(w) * size_t(h) * 3); + // A fourth channel is coverage, and it is the only thing that can say "the picture is + // not here" as opposed to "the picture is black here". Kept only when the source has it. + if (bpp >= 4) + result.alpha.resize(size_t(w) * size_t(h)); // decode_colored_png() hands its buffer back bottom-up - it is shared with the CLI's // plate-thumbnail loader, which expects that - while this type, and decode_png()'s // grayscale path above, are top-to-bottom. Reverse the rows on the way in so a colour @@ -403,6 +512,8 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer result.rgb[dst * 3 + 1] = g; result.rgb[dst * 3 + 2] = b; result.pixels[dst] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b)); + if (bpp >= 4) + result.alpha[dst] = src[3]; } } @@ -1936,10 +2047,14 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla // 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 = xf(planar); - return uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f; + if (!layer.tile_enabled && !(uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f)) + return false; + // Where the picture is transparent there is no colour to print, which is the same answer as + // being outside the placement - not black, which is a colour. Half coverage counts as + // covered: a triangle prints in one filament either way, so the only useful question is + // which side of the edge it falls on. + return texture.sample_alpha(uv, layer.tile_enabled, layer.tile_method) >= 0.5f; }; if (lscm_uv != nullptr) { diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 7dea145d9a..c22a1fae97 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -300,6 +300,16 @@ struct TextureDisplacementLayer float brightness = 0.f; // -1 .. 1, added last, after contrast float contrast = 0.f; // -1 .. 1, pivoted on mid grey; -1 flattens to grey, 1 doubles the slope float hue = 0.f; // -180 .. 180 degrees, rotated about the grey axis at constant luma + float sharpness = 0.f; // 0 .. 1 unsharp mask amount, 0 is off + // Line art and logos: everything lighter than `threshold` becomes white and the rest black, so the + // picture matches exactly two filaments instead of speckling across several. + bool threshold_enabled = false; + float threshold = 0.5f; // 0 .. 1, on Rec. 709 luma + // Leaves the background of a picture unprinted, so the object's own colour shows through instead + // of a slab of white behind a logo. Texels the source image marked transparent always count; + // `bg_remove` additionally keys out whatever colour the picture's corners share. + bool bg_remove = false; + float bg_tolerance = 0.15f; // 0 .. 1, distance from the keyed colour that still counts as background float saturation = 1.f; // 0 .. 2, 1 leaves it alone, 0 is grayscale float gamma = 1.f; // 0.2 .. 5, 1 leaves it alone; above 1 lifts the midtones bool color_invert = false; // negative of the colour, as opposed to `invert` which flips relief @@ -314,7 +324,8 @@ struct TextureDisplacementLayer 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, color_enabled, brightness, - contrast, saturation, gamma, color_invert, hue); + contrast, saturation, gamma, color_invert, hue, sharpness, threshold_enabled, threshold, + bg_remove, bg_tolerance); } template void load(Archive &ar) { @@ -326,7 +337,8 @@ 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, color_enabled, brightness, contrast, saturation, gamma, color_invert, hue); + view_project_matrix, color_enabled, brightness, contrast, saturation, gamma, color_invert, hue, sharpness, + threshold_enabled, threshold, bg_remove, bg_tolerance); 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); @@ -460,11 +472,19 @@ struct DecodedHeightTexture // 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; + // Coverage: 0 where the picture is not there at all, 255 where it is fully there. Empty when the + // source image had no alpha and nothing has keyed a background out, which is the common case and + // means "opaque everywhere". Distinct from a black texel, which is a colour. + std::vector alpha; 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; } + bool has_alpha() const { return !empty() && alpha.size() == size_t(width) * size_t(height); } + // Coverage at a uv, bilinear, in [0, 1]. 1 everywhere when there is no alpha plane. + float sample_alpha(const Vec2f &uv, bool tile_enabled = true, + TextureTileMethod tile_method = TextureTileMethod::Repeat) const; // 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 diff --git a/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp b/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp index 30dbd07df9..10a57b6a5f 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp @@ -529,6 +529,35 @@ void GLGizmoTextureDisplacement::render_image_input_window(float x, float y, flo m_preview_params_dirty |= float_row("##gamma", _L("Gamma"), &layer->gamma, 0.2f, 5.f, "%.2f", true); hover_tip(_u8L("Lifts or drops the midtones without touching the brightest and darkest parts. Use it " "when the middle of the picture is the part coming out wrong.")); + m_preview_params_dirty |= float_row("##sharpen", _L("Sharpen"), &layer->sharpness, 0.f, 1.f, "%.2f", false); + hover_tip(_u8L("Crisps up the edges before the picture is matched to your filaments. Worth more here " + "than in a photo: a soft edge between two colors quantizes into a ragged band of " + "both, and sharpening first makes it land in one place.")); + + // Two filaments, on purpose. The case this exists for is a logo or line art, where anything + // in between the two reads as speckle rather than as shading. + if (m_imgui->bbl_checkbox(_L("Two colors"), layer->threshold_enabled)) + m_preview_params_dirty = true; + hover_tip(_u8L("Forces the picture to light and dark only, so it prints in exactly two filaments. " + "For a logo or line art, where the in-between grays come out as speckle.")); + if (layer->threshold_enabled) { + m_preview_params_dirty |= float_row("##threshold", _L("Split at"), &layer->threshold, 0.05f, 0.95f, "%.2f", false); + hover_tip(_u8L("Where the split falls. Lower keeps more of the picture on the light side, higher " + "on the dark one.")); + } + + if (m_imgui->bbl_checkbox(_L("Remove background"), layer->bg_remove)) + m_preview_params_dirty = true; + hover_tip(_u8L("Leaves the background unprinted, so the object's own color shows through instead of " + "a slab of white behind a logo. The color is taken from the picture's four corners. " + "A picture that is already transparent needs none of this: those parts are left out " + "either way.")); + if (layer->bg_remove) { + m_preview_params_dirty |= float_row("##bg_tol", _L("Tolerance"), &layer->bg_tolerance, 0.01f, 0.6f, "%.2f", false); + hover_tip(_u8L("How different from the corners still counts as background. Raise it when a " + "shadow or a gradient is left behind; lower it when it starts eating the picture.")); + } + if (m_imgui->bbl_checkbox(_L("Negative"), layer->color_invert)) m_preview_params_dirty = true; hover_tip(_u8L("Prints the picture inverted, light for dark and each color for its opposite.")); @@ -539,6 +568,11 @@ void GLGizmoTextureDisplacement::render_image_input_window(float x, float y, flo layer->saturation = 1.f; layer->hue = 0.f; layer->gamma = 1.f; + layer->sharpness = 0.f; + layer->threshold_enabled = false; + layer->threshold = 0.5f; + layer->bg_remove = false; + layer->bg_tolerance = 0.15f; layer->color_invert = false; m_preview_params_dirty = true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 41d1b3f600..eb744b3e5d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -409,10 +409,18 @@ std::unique_ptr upload_color_texture(const DecodedHeightTexture &deco for (int c = 0; c < 3; ++c) sum[c] += decoded.rgb[si + size_t(c)]; } + // Coverage rides in the fourth channel, box-filtered with the colour, so the shader can + // leave the surface alone where the picture is not there (see the colour path in + // texture_displacement_shaded.fs). + unsigned int sum_a = 0; + if (decoded.has_alpha()) + for (int sy = y0; sy < y1 && sy < decoded.height; ++sy) + for (int sx = x0; sx < x1 && sx < decoded.width; ++sx) + sum_a += decoded.alpha[size_t(sy) * size_t(decoded.width) + size_t(sx)]; 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; + rgba[di + 3] = (decoded.has_alpha() && n > 0) ? static_cast(sum_a / n) : 255; } auto texture = std::make_unique(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 2ef072ecdb..241976f50d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -160,16 +160,21 @@ public: struct ColorAdjustKey { float brightness = 0.f, contrast = 0.f, saturation = 1.f, gamma = 1.f, hue = 0.f; - bool invert = false; + float sharpness = 0.f, threshold = 0.5f, bg_tolerance = 0.15f; + bool invert = false, threshold_on = false, bg_remove = false; bool operator==(const ColorAdjustKey &o) const { return brightness == o.brightness && contrast == o.contrast && saturation == o.saturation && - gamma == o.gamma && hue == o.hue && invert == o.invert; + gamma == o.gamma && hue == o.hue && sharpness == o.sharpness && + threshold == o.threshold && bg_tolerance == o.bg_tolerance && invert == o.invert && + threshold_on == o.threshold_on && bg_remove == o.bg_remove; } bool operator!=(const ColorAdjustKey &o) const { return !(*this == o); } static ColorAdjustKey of(const TextureDisplacementLayer &l) { - return { l.brightness, l.contrast, l.saturation, l.gamma, l.hue, l.color_invert }; + return { l.brightness, l.contrast, l.saturation, l.gamma, l.hue, + l.sharpness, l.threshold, l.bg_tolerance, + l.color_invert, l.threshold_enabled, l.bg_remove }; } }; std::vector m_palette_adjusts; // alongside m_palette_images, one per entry