diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..ca02f518f1 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -378,6 +378,63 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer } namespace { +// Fraction of the short side that TextureAdjustments::blur == 1 blurs over, and the cost cap on it. +// Deliberately the same pair decode_height_texture() uses for the Smoothing slider - see +// adjustment_blur_radius(). +constexpr float SOURCE_BLUR_SPAN = 0.05f; +constexpr float SOURCE_BLUR_MAX_PX = 48.f; + +// Radius of the unsharp mask's blurred reference, in texels. Small and only mildly resolution-dependent: +// a sharpen is meant to act on the finest detail the image holds, so its radius tracks the texel grid +// rather than the picture's size. 1024x1024 gives ~2 texels, the usual photo-editor default. +float unsharp_radius(int width, int height) +{ + return std::max(1.f, 0.002f * float(std::min(width, height))); +} + +// Brightness as a *lift* toward white (or toward black) rather than an additive offset: positive b maps +// v to v + b*(1 - v), negative to v*(1 + b). Both reach the endpoint exactly at |b| == 1 while staying +// monotonic and non-clipping in between, which an additive shift does not - it drives a whole region to +// a flat 255 and, because this image is also the height map, that region bakes as flat relief rather +// than as bright relief. Keeping the curve injective keeps the detail in it. +float apply_brightness(float v, float b) +{ + return b >= 0.f ? v + b * (1.f - v) : v * (1.f + b); +} + +// Contrast about the 0.5 pivot. Gain is 1/(1-c) going up (c == 1 would be an infinite-slope threshold, so +// the caller's clamp keeps it finite) and 1+c going down, reaching a flat mid-grey at c == -1. +float apply_contrast(float v, float c) +{ + const float gain = c >= 0.f ? 1.f / std::max(1e-3f, 1.f - c) : 1.f + c; + return (v - 0.5f) * gain + 0.5f; +} + +// Blur then sharpen one 8-bit channel in place. Shared by the grayscale path and by each of a colour +// image's three channels, so every channel gets the identical spatial treatment and a colour image's +// hue cannot shift just because its channels were filtered differently. +void adjust_channel_spatial(std::vector &channel, int width, int height, float blur, float sharpness) +{ + if (blur > 0.f) + smooth_height_pixels(channel, width, height, adjustment_blur_radius(blur, width, height)); + + if (sharpness <= 0.f) + return; + + // Unsharp mask: push each texel away from its local average by `amount` times the difference. The + // blurred reference is a copy, so the sharpen reads the un-sharpened neighbourhood everywhere rather + // than its own partly-written output. + std::vector base = channel; + smooth_height_pixels(base, width, height, unsharp_radius(width, height)); + const float amount = 1.5f * std::clamp(sharpness, 0.f, 1.f); + tbb::parallel_for(tbb::blocked_range(0, channel.size()), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) { + const float v = float(channel[i]) + amount * (float(channel[i]) - float(base[i])); + channel[i] = uint8_t(std::lround(std::clamp(v, 0.f, 255.f))); + } + }); +} + struct TextureDetailCache { std::mutex mutex; @@ -386,6 +443,150 @@ struct TextureDetailCache TextureDetailCache g_texture_detail_cache; } // namespace +float adjustment_blur_radius(float blur, int width, int height) +{ + if (blur <= 0.f || width <= 0 || height <= 0) + return 0.f; + const float span = SOURCE_BLUR_SPAN * float(std::min(width, height)); + return std::clamp(blur, 0.f, 1.f) * std::min(span, SOURCE_BLUR_MAX_PX); +} + +DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px) +{ + if (src.empty() || max_px <= 0 || (src.width <= max_px && src.height <= max_px)) + return src; + + const int scale = std::max(1, (std::max(src.width, src.height) + max_px - 1) / max_px); + const int w = std::max(1, src.width / scale); + const int h = std::max(1, src.height / scale); + const bool color = src.has_color(); + + DecodedHeightTexture out; + out.width = w; + out.height = h; + out.pixels.resize(size_t(w) * size_t(h)); + if (color) + out.rgb.resize(size_t(w) * size_t(h) * 3); + + tbb::parallel_for(tbb::blocked_range(0, h), [&](const tbb::blocked_range &r) { + for (int y = r.begin(); y < r.end(); ++y) + for (int x = 0; x < w; ++x) { + // The source block this destination texel covers. The max() keeps the block non-empty + // when rounding would otherwise collapse it. + const int x0 = x * src.width / w, x1 = std::max(x0 + 1, (x + 1) * src.width / w); + const int y0 = y * src.height / h, y1 = std::max(y0 + 1, (y + 1) * src.height / h); + uint32_t sum_h = 0, sum_c[3] = { 0, 0, 0 }, n = 0; + for (int sy = y0; sy < y1 && sy < src.height; ++sy) + for (int sx = x0; sx < x1 && sx < src.width; ++sx, ++n) { + const size_t si = size_t(sy) * size_t(src.width) + size_t(sx); + sum_h += src.pixels[si]; + if (color) + for (int c = 0; c < 3; ++c) + sum_c[c] += src.rgb[si * 3 + size_t(c)]; + } + const size_t di = size_t(y) * size_t(w) + size_t(x); + out.pixels[di] = n > 0 ? uint8_t(sum_h / n) : 0; + if (color) + for (int c = 0; c < 3; ++c) + out.rgb[di * 3 + size_t(c)] = n > 0 ? uint8_t(sum_c[c] / n) : 0; + } + }); + return out; +} + +DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj) +{ + DecodedHeightTexture out = src; + if (src.empty() || adj.identity()) + return out; + + const int w = out.width; + const int h = out.height; + const size_t n = size_t(w) * size_t(h); + + const float brightness = std::clamp(adj.brightness, -1.f, 1.f); + // Short of 1, so apply_contrast()'s gain stays finite; at this value it is already a 1000x slope, + // i.e. a threshold in all but name. + const float contrast = std::clamp(adj.contrast, -1.f, 0.999f); + const float blur = std::clamp(adj.blur, 0.f, 1.f); + const float sharpness = std::clamp(adj.sharpness, 0.f, 1.f); + + // The tonal pass: a 256-entry lookup table, because brightness and contrast are the same scalar + // curve for every texel and every channel. Building it once beats evaluating two curves a few + // million times, and it is what keeps this pass off the panel's critical path. + std::array tone{}; + for (int i = 0; i < 256; ++i) { + float v = float(i) / 255.f; + v = apply_brightness(v, brightness); + v = apply_contrast(v, contrast); + tone[size_t(i)] = uint8_t(std::lround(std::clamp(v, 0.f, 1.f) * 255.f)); + } + + if (!out.has_color()) { + // Grayscale: one channel, and hue has nothing to turn. + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) + out.pixels[i] = tone[out.pixels[i]]; + }); + adjust_channel_spatial(out.pixels, w, h, blur, sharpness); + return out; + } + + // Colour: tone each channel through the same table, then rotate the hue about the luminance axis. + // + // The rotation is feColorMatrix's hueRotate matrix (SVG filters), which holds Rec.709 luma fixed. + // The height below is taken with decode_height_texture()'s Rec.601 coefficients instead, so a hue + // turn is not perfectly height-neutral - a few levels at full saturation. Reconciling the two would + // mean a different rotation than every other tool's, which is the worse trade: the user is watching + // the colour, and the height follows the colour by construction either way. + const float theta = adj.hue_deg * float(M_PI) / 180.f; + const float c = std::cos(theta); + const float s = std::sin(theta); + const float m[9] = { + 0.213f + c * 0.787f - s * 0.213f, 0.715f - c * 0.715f - s * 0.715f, 0.072f - c * 0.072f + s * 0.928f, + 0.213f - c * 0.213f + s * 0.143f, 0.715f + c * 0.285f + s * 0.140f, 0.072f - c * 0.072f - s * 0.283f, + 0.213f - c * 0.213f - s * 0.787f, 0.715f - c * 0.715f + s * 0.715f, 0.072f + c * 0.928f + s * 0.072f, + }; + const bool turn_hue = adj.hue_deg != 0.f; + + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) { + const float rr = float(tone[out.rgb[i * 3]]); + const float gg = float(tone[out.rgb[i * 3 + 1]]); + const float bb = float(tone[out.rgb[i * 3 + 2]]); + const float nr = turn_hue ? m[0] * rr + m[1] * gg + m[2] * bb : rr; + const float ng = turn_hue ? m[3] * rr + m[4] * gg + m[5] * bb : gg; + const float nb = turn_hue ? m[6] * rr + m[7] * gg + m[8] * bb : bb; + out.rgb[i * 3] = uint8_t(std::lround(std::clamp(nr, 0.f, 255.f))); + out.rgb[i * 3 + 1] = uint8_t(std::lround(std::clamp(ng, 0.f, 255.f))); + out.rgb[i * 3 + 2] = uint8_t(std::lround(std::clamp(nb, 0.f, 255.f))); + } + }); + + // The spatial pass wants one channel at a time (smooth_height_pixels() is a single-channel blur, and + // reusing it is what keeps this consistent with Smoothing), so de-interleave, filter, re-interleave. + if (blur > 0.f || sharpness > 0.f) { + std::vector channel(n); + for (int ch = 0; ch < 3; ++ch) { + for (size_t i = 0; i < n; ++i) + channel[i] = out.rgb[i * 3 + size_t(ch)]; + adjust_channel_spatial(channel, w, h, blur, sharpness); + for (size_t i = 0; i < n; ++i) + out.rgb[i * 3 + size_t(ch)] = channel[i]; + } + } + + // Height is the luminance of the colour that came out, not the toned luminance of the colour that + // went in: the two differ once hue rotation or a per-channel clamp has moved the channels around, + // and a height that disagrees with the colour it is stored next to is the bug this avoids. + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) + out.pixels[i] = uint8_t(std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] + + 0.114 * out.rgb[i * 3 + 2])); + }); + return out; +} + TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer) { TextureDetail out; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..86b7b07cf2 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -489,6 +489,68 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer // texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read. bool height_texture_has_color(const TextureDisplacementLayer &layer); +// Photo-style adjustments to a layer's source image, as the gizmo's image-adjustment panel sets them. +// +// These are *not* layer parameters: nothing here is stored on TextureDisplacementLayer and nothing here is +// re-applied at bake time. The panel applies them once, to the image, and the result becomes the layer's +// image - so the adjusted pixels feed the colour and the displacement together, and a brightness change +// really does change the relief. That is the intended behaviour: one image, one source of truth, rather +// than a second set of knobs the bake would have to replay in the right order. +// +// Contrast with TextureDisplacementLayer::smoothing, which *is* a layer parameter applied on every decode: +// `blur` here is a destructive edit of the image, `smoothing` is a live low-pass over whatever the image is. +struct TextureAdjustments +{ + float brightness = 0.f; // [-1, 1]; 0 unchanged, -1 black, +1 white + float contrast = 0.f; // [-1, 1]; 0 unchanged, -1 flat mid-grey, +1 hard threshold + float sharpness = 0.f; // [0, 1]; 0 unchanged. Unsharp-mask amount + float blur = 0.f; // [0, 1]; 0 unchanged. Fraction of SOURCE_BLUR_SPAN, as `smoothing` is + float hue_deg = 0.f; // [-180, 180]; 0 unchanged. No effect on a grayscale image - grey has no hue + + bool identity() const + { + return brightness == 0.f && contrast == 0.f && sharpness == 0.f && blur == 0.f && hue_deg == 0.f; + } + // Exact equality, which is what is wanted: these come straight off sliders, and the question asked of + // them is "is this still the value the preview on screen was computed from", not "are these close". + bool operator==(const TextureAdjustments &o) const + { + return brightness == o.brightness && contrast == o.contrast && sharpness == o.sharpness && + blur == o.blur && hue_deg == o.hue_deg; + } + bool operator!=(const TextureAdjustments &o) const { return !(*this == o); } +}; + +// Applies `adj` to `src` and returns the result; `src` is left alone. An empty `src`, or an identity +// `adj`, gives back a copy. +// +// Order is tonal, then spatial: brightness, contrast and hue per texel first, then blur, then sharpness. +// Sharpness last is what lets the two spatial knobs be used together - blur to kill speckle, then +// sharpness to put the real edges back - instead of the blur simply undoing the sharpen. +// +// Grayscale in, grayscale out (`rgb` stays empty): promoting a grey image to RGB would hand the layer's +// colour feature a row of identical greys to "colour" the model with, and triple the stored PNG for it. +// For a colour image the height is recomputed as the luminance of the *adjusted* colour, with +// decode_height_texture()'s own coefficients, so the relief and the colour can never drift apart. +DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj); + +// Blur radius, in texels, that TextureAdjustments::blur == 1 means on a `min(width, height)`-sized image: +// the same 5%-of-the-short-side span the Smoothing slider uses, under the same 48-texel cost cap, so the +// two controls bite at the same rate and a user moving between them is not surprised. +float adjustment_blur_radius(float blur, int width, int height); + +// Box-filters `src` down so neither side exceeds `max_px`, preserving aspect; never upscales, and +// returns a copy when it already fits. Colour and height are filtered the same way, and the height +// stays the height - it is not recomputed from the filtered colour, because a box filter is linear and +// luminance is linear in the channels, so the two agree already. +// +// This is what lets the adjustment panel's live preview cost depend on the size of the box it is drawn +// in rather than on the size of the image: adjusting a downscaled copy per slider frame, and the +// full-size image only once, on commit. A box filter (rather than a plain decimation) is the point - +// these textures are high-frequency by nature, and dropping texels would alias the preview into +// showing detail the adjustment is not actually producing. +DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px); + // 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 diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..645bb3ccc2 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -120,6 +121,14 @@ constexpr int THUMBNAIL_MAX_PX = 128; // blur of it - which is what made Fast look flatter and softer than the result it was previewing. constexpr int HEIGHT_TEX_MAX_PX = 2048; +// Size the image-adjustment panel's live preview is computed and uploaded at. The panel draws the image +// in a box about 240 px wide, so there is nothing to show above this - and the preview is recomputed on +// every slider move, which is what makes its cost worth bounding. Measured on a 1024x1024 with every +// knob engaged, a full-size pass is 13-19 ms grayscale / 40-60 ms in colour (130-190 ms at 2048 in +// colour); the same pass on a 384 px copy is 3-4 ms / 9-12 ms, and it runs off the UI thread besides. +// The full-size image is only ever adjusted once, on commit. +constexpr int ADJUST_PREVIEW_MAX_PX = 384; + // How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine // band (see collect_paint_region()). Two is enough to grade the size change without the band's own // cost growing to matter: it is a ring around a perimeter, not an area. @@ -525,6 +534,10 @@ void GLGizmoTextureDisplacement::on_shutdown() // progress poll, and its completion handler then finds nothing to do. m_preview_generation->fetch_add(1); m_preview_job_pending = false; + // The adjustment panel holds a full-size decode of its layer's image, which is the largest thing + // here; a closed gizmo should not keep it alive. Discards the edit, as closing the gizmo on an open + // panel implies. + close_image_adjust(); // The palette caches hold the last volume's images; a closed gizmo should not keep them alive. m_palette_cache.clear(); m_palette_quantizer = nullptr; @@ -3982,6 +3995,271 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane m_picker_open_request = true; } } +void GLGizmoTextureDisplacement::open_image_adjust(const TextureDisplacementLayer &layer) +{ + if (layer.empty() || layer.slot < 0) + return; + + close_image_adjust(); // drop any previous panel's source image and in-flight pass + + // The *raw* image, not decode_height_texture(layer): that one applies the layer's Smoothing on top, + // and Smoothing is a live layer parameter rather than part of the image. Baking it into the pixels + // here would apply it twice - once destructively now, and again on every later decode. + TextureDisplacementLayer raw = layer; + raw.smoothing = 0.f; + m_adjust_source = decode_height_texture(raw); + if (m_adjust_source.empty()) + return; + + m_adjust_preview_source = downscale_height_texture(m_adjust_source, ADJUST_PREVIEW_MAX_PX); + m_adjust = TextureAdjustments{}; + m_adjust_preview_shown = TextureAdjustments{}; + m_adjust_slot = layer.slot; + m_adjust_open_request = true; + // The unadjusted image, so the box is filled the moment the panel appears rather than one pass later. + m_adjust_preview_tex = upload_height_thumbnail(m_adjust_preview_source, ADJUST_PREVIEW_MAX_PX); + m_adjust_preview_valid = m_adjust_preview_tex != nullptr; +} + +void GLGizmoTextureDisplacement::close_image_adjust() +{ + // std::async's future blocks here until the pass finishes. That is a few milliseconds on an image + // this small, and the alternative - a detached thread writing into the members of a closed panel - + // is worse. + if (m_adjust_preview_job.valid()) + m_adjust_preview_job.wait(); + m_adjust_preview_job = {}; + + m_adjust_slot = -1; + m_adjust = TextureAdjustments{}; + m_adjust_source = DecodedHeightTexture{}; + m_adjust_preview_source = DecodedHeightTexture{}; + m_adjust_preview_tex.reset(); + m_adjust_preview_valid = false; +} + +void GLGizmoTextureDisplacement::update_image_adjust_preview() +{ + if (m_adjust_preview_source.empty()) + return; + + // Collect a finished pass. wait_for(0) rather than get() outright: this runs inside the frame, and + // blocking on the worker here would hand back to the UI thread the very stall the worker exists to + // spare it. + if (m_adjust_preview_job.valid() && + m_adjust_preview_job.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + const DecodedHeightTexture adjusted = m_adjust_preview_job.get(); + m_adjust_preview_job = {}; + if (std::unique_ptr tex = upload_height_thumbnail(adjusted, ADJUST_PREVIEW_MAX_PX)) { + m_adjust_preview_tex = std::move(tex); + m_adjust_preview_shown = m_adjust_preview_running; + m_adjust_preview_valid = true; + } + } + + // One pass at a time: whatever the sliders did while it ran is picked up by the next launch, so a + // drag collapses to the value the sliders ended on instead of queuing one pass per frame. + if (!m_adjust_preview_job.valid() && m_adjust != m_adjust_preview_shown) { + m_adjust_preview_running = m_adjust; + // Captured by value: the pass must not read members the UI thread goes on editing while it runs. + m_adjust_preview_job = std::async(std::launch::async, + [src = m_adjust_preview_source, adj = m_adjust_preview_running] { + return apply_texture_adjustments(src, adj); + }); + } + + // A pass in flight has no other way to get its result on screen: without a redraw request the canvas + // can sit idle, and the preview would only catch up on the next unrelated frame. + if (m_adjust_preview_job.valid()) + m_parent.set_as_dirty(); +} + +void GLGizmoTextureDisplacement::commit_image_adjust(TextureDisplacementLayer &layer, bool as_copy) +{ + if (m_adjust_source.empty() || m_adjust.identity()) { + // Nothing was changed, so there is nothing to apply. Replacing the image with an identical one + // would still cost an undo step and invalidate every cache keyed by the old pointer. + close_image_adjust(); + return; + } + + // The full-size pass, once: 13-19 ms grayscale / 40-60 ms colour on a 1024x1024 with every knob + // engaged. Run on the UI thread deliberately, unlike the live preview: this is a single click, and the + // rebuild_preview() immediately below it - a CPU mesh displacement of the painted patch - is already + // an order of magnitude more work, synchronously, so moving only this off the thread would buy + // nothing a user could perceive. + const DecodedHeightTexture adjusted = apply_texture_adjustments(m_adjust_source, m_adjust); + + std::string error; + if (as_copy) { + // A new library texture, which set_layer_texture() then points the layer at - so the layer also + // picks up the name, the recently-used ordering and the fresh image_data buffer that path + // already provides, rather than this function repeating any of it. + const std::string stem = layer.name.empty() ? std::string("texture") : layer.name; + const std::optional entry = save_texture_to_library(adjusted, stem, error); + if (!entry) { + show_error(nullptr, error); + // Reopened rather than merely left alone: the popup was closed before this call, and without + // this the next frame would see a closed popup with panel state still set and discard the + // edit. Same reason import_custom_texture() re-requests the picker after its file dialog. + m_adjust_open_request = true; + return; + } + set_layer_texture(layer, *entry); + } else { + // A brand new allocation, which is what invalidates decode_height_texture()'s cache, + // analyze_texture_detail()'s, and the GPU thumbnail and height/colour textures - every one of + // them keyed by this pointer. Writing into the buffer the layer already holds would leave all of + // them serving the pixels from before the edit. + std::shared_ptr> bytes = encode_texture_image_data(adjusted, error); + if (!bytes) { + show_error(nullptr, error); + m_adjust_open_request = true; // as above: keep the edit, bring the panel back + return; + } + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Adjust texture image"), + UndoRedo::SnapshotType::GizmoAction); + layer.image_data = std::move(bytes); + // The image is no longer any file on disk, so no library entry should show as this layer's any + // more (the picker highlights by this path). The bytes themselves are what the project stores. + layer.path.clear(); + layer.path_in_3mf.clear(); + rebuild_preview(); + m_parent.set_as_dirty(); + } + + close_image_adjust(); +} + +void GLGizmoTextureDisplacement::render_image_adjust_popup(const ImVec2 &panel_min, const ImVec2 &panel_max) +{ + ModelVolume *mv = texture_volume(); + TextureDisplacementLayer *layer = nullptr; + if (mv != nullptr) + for (TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.slot == m_adjust_slot) + layer = &l; + + static const char *const popup_id = "##texture_adjust"; + if (m_adjust_open_request) { + m_adjust_open_request = false; + if (layer != nullptr) + ImGui::OpenPopup(popup_id); + } + + const float img_w = std::round(m_imgui->scaled(16.f)); + const float gap = m_imgui->scaled(0.5f); + // Beside the panel, like the texture picker, so the layer being adjusted stays in view. + const float popup_w = img_w + 2.f * ImGui::GetStyle().WindowPadding.x; + const bool left = panel_min.x - gap - popup_w >= 0.f; + ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap : panel_max.x + gap, panel_min.y + m_imgui->scaled(4.f)), + ImGuiCond_Appearing, ImVec2(left ? 1.f : 0.f, 0.f)); + if (!ImGui::BeginPopupModal(popup_id, nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + // The panel thinks it is open but the popup is not: Escape, or the layer went away. Every one of + // those routes discards the edit, which is exactly what Cancel does. + if (m_adjust_slot >= 0) + close_image_adjust(); + return; + } + if (layer == nullptr) { + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + close_image_adjust(); + return; + } + + m_imgui->push_bold_font(); + m_imgui->text(_L("Adjust image")); + m_imgui->pop_bold_font(); + + // Collect a finished pass and start one for the current slider values before the image is drawn, so + // the newest available result is what lands on screen this frame. + update_image_adjust_preview(); + + // The photo itself, at the image's own aspect so a non-square texture is not stretched. + const float aspect = m_adjust_source.height > 0 ? float(m_adjust_source.width) / float(m_adjust_source.height) : 1.f; + const float img_h = std::round(img_w / std::max(0.05f, aspect)); + if (m_adjust_preview_valid && m_adjust_preview_tex) + ImGui::Image((ImTextureID) (intptr_t) m_adjust_preview_tex->get_id(), ImVec2(img_w, img_h)); + else + ImGui::Dummy(ImVec2(img_w, img_h)); + + // The label column is sized off the widest of the five labels, so the tracks line up whatever the + // translation does to their lengths. + float label_w = 0.f; + for (const wxString &l : { _L("Brightness"), _L("Contrast"), _L("Sharpness"), _L("Blur"), _L("Hue") }) + label_w = std::max(label_w, ImGui::CalcTextSize(into_u8(l).c_str()).x); + label_w += m_imgui->scaled(1.2f); + // An explicit track width rather than a fill-to-the-right one: this window is AlwaysAutoResize, so its + // width comes from its content, and an item asking for "whatever is left" would make the width depend + // on itself. The image above sets the content width, and the sliders match it. + const float slider_w = std::max(m_imgui->scaled(4.f), img_w - label_w); + const auto row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, + const char *format, bool enabled, const std::string &tip) { + const float x0 = ImGui::GetCursorPosX(); + ImGui::AlignTextToFramePadding(); + m_imgui->text(label); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + ImGui::SetNextItemWidth(slider_w); + m_imgui->disabled_begin(!enabled); + ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp); + m_imgui->disabled_end(); + // Only on an enabled row: a disabled item does not report hover, so a tooltip on one would never + // be shown. What has to be said about a disabled slider is written out below it instead. + if (enabled && !tip.empty() && ImGui::IsItemHovered()) + m_imgui->tooltip(tip, ImGui::GetFontSize() * 20.f); + }; + + row("##adj_brightness", _L("Brightness"), &m_adjust.brightness, -1.f, 1.f, "%.2f", true, {}); + row("##adj_contrast", _L("Contrast"), &m_adjust.contrast, -1.f, 1.f, "%.2f", true, {}); + row("##adj_sharpness", _L("Sharpness"), &m_adjust.sharpness, 0.f, 1.f, "%.2f", true, + _u8L("Brings out the finest detail in the image. Applied after Blur, so the two can be used " + "together: blur away the speckle, then sharpen the edges that remain.")); + row("##adj_blur", _L("Blur"), &m_adjust.blur, 0.f, 1.f, "%.2f", true, + _u8L("Blurs the image itself, permanently. The layer's own Smoothing slider softens the relief " + "without altering the image, and is the one to reach for first.")); + // Grey has no hue to turn, and the shipped library is grayscale - so on most textures this is a + // slider that would visibly do nothing. Disabled with the reason given, rather than hidden. + const bool has_color = m_adjust_source.has_color(); + row("##adj_hue", _L("Hue"), &m_adjust.hue_deg, -180.f, 180.f, "%.0f°", has_color, {}); + if (!has_color) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + img_w); + m_imgui->text(_L("This texture is grayscale, so it has no hue to shift.")); + ImGui::PopTextWrapPos(); + } + + if (m_imgui->button(_L("Reset"))) + m_adjust = TextureAdjustments{}; + + ImGui::Separator(); + // The part a user would not otherwise expect: this is an edit to the image, and the image is also the + // height map. + m_imgui->text_wrapped(_u8L("The adjusted image becomes the layer's image, so it changes the relief as " + "well as the colour."), img_w); + + const bool cancel = m_imgui->button(_L("Cancel")); + ImGui::SameLine(); + // Nothing to apply until a slider has actually moved. + m_imgui->disabled_begin(m_adjust.identity()); + const bool overwrite = m_imgui->button(_L("Overwrite")); + ImGui::SameLine(); + const bool as_copy = m_imgui->button(_L("Save as copy")); + m_imgui->disabled_end(); + + // Closed before anything commits: commit_image_adjust() can put a modal error dialog up, and an ImGui + // popup left open underneath one is what makes the panel come back in a half-drawn state afterwards. + if (cancel || overwrite || as_copy) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + + if (cancel) + close_image_adjust(); + else if (overwrite) + commit_image_adjust(*layer, false); + else if (as_copy) + commit_image_adjust(*layer, true); +} void GLGizmoTextureDisplacement::remove_texture_layer(int slot) { @@ -6492,6 +6770,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it " "costs fine detail.")); + // Next to Smoothing because both are about the image rather than the projection - but + // note the difference: Smoothing is a live layer parameter, while this edits the image. + m_imgui->disabled_begin(layer.empty()); + if (m_imgui->button(_L("Adjust image") + "##adjust_image")) + open_image_adjust(layer); + m_imgui->disabled_end(); + hover_tip(_u8L("Brightness, contrast, sharpness, blur and hue, shown on the image as you " + "set them. The adjusted image becomes this layer's image, so it changes the " + "relief as well as the colour.")); + // Edge fade: the relief flattens toward the boundary of the painted area. m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing); hover_tip(_u8L("Flattens the relief as it approaches the edge of the painted area, so it " @@ -7229,6 +7517,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const ImVec2 win_min = ImGui::GetWindowPos(); const ImVec2 win_sz = ImGui::GetWindowSize(); render_texture_library_popup(win_min, ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y)); + render_image_adjust_popup(win_min, ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y)); } // ImGui sliders report "changed" continuously on every frame while being dragged, not just once on diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..1edb9f4103 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -23,6 +23,7 @@ #include "libslic3r/Point.hpp" #include #include +#include #include #include #include "slic3r/GUI/Gizmos/GLGizmoBase.hpp" @@ -399,6 +400,53 @@ private: void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry); void import_custom_texture(TextureDisplacementLayer &layer); + // ---- The image-adjustment panel ---- + // + // Brightness/contrast/sharpness/blur/hue over one layer's image, shown on the image itself and + // updating as the sliders move. What it produces is not a set of layer parameters: it replaces the + // layer's *image*, so the adjusted pixels feed the colour and the displacement together and a + // brightness change moves the relief too (see TextureAdjustments). + // + // Modal, unlike the texture picker beside it, because it has three distinct exits - discard, + // overwrite the layer's image, write a new library texture - and a click outside silently + // discarding the edit is not one of them. + void render_image_adjust_popup(const ImVec2 &panel_min, const ImVec2 &panel_max); + // Opens the panel for `layer` and decodes its image once, into m_adjust_source. Sliders never + // re-decode the PNG. + void open_image_adjust(const TextureDisplacementLayer &layer); + // Gives `layer` the full-size adjusted image: as its own image_data (`as_copy` false), or by writing + // a new texture into the user's library and pointing the layer at that (`as_copy` true). Closes the + // panel either way. Takes an undo snapshot. + void commit_image_adjust(TextureDisplacementLayer &layer, bool as_copy); + void close_image_adjust(); + // Collects a finished preview and starts one for the current slider values. One at a time, so a drag + // coalesces into "whatever the sliders last said" instead of a queue of superseded frames. + void update_image_adjust_preview(); + + int m_adjust_slot = -1; // the layer the panel is open for; -1 when closed + bool m_adjust_open_request = false; + TextureAdjustments m_adjust; // what the sliders currently say + + // The layer's image as it was when the panel opened, and a small copy of it. The sliders are applied + // to the small copy for the live preview and to the full-size one exactly once, on commit. Measured on + // a 1024x1024 with every knob engaged, a full-size pass costs 13-19 ms grayscale and 40-60 ms in + // colour (130-190 ms at 2048 in colour) - far too much to repeat per slider frame. The same pass on a + // 384 px copy is 3-4 ms / 9-12 ms, and that is all the preview box can show anyway. + DecodedHeightTexture m_adjust_source; + DecodedHeightTexture m_adjust_preview_source; + // The preview image on screen, re-uploaded whenever a background pass finishes. + std::unique_ptr m_adjust_preview_tex; + + // The live preview runs on its own thread rather than on the UI thread or on the plater's job + // worker. Not the UI thread because even the small pass is milliseconds and this runs while a slider + // is being dragged; not the job worker because that is a single FIFO shared with Bake and the + // displacement preview (see queue_preview_job()), where a per-frame image filter would sit behind - + // and delay - multi-second mesh work. + std::future m_adjust_preview_job; + TextureAdjustments m_adjust_preview_running; // what the in-flight pass is computing + TextureAdjustments m_adjust_preview_shown; // what m_adjust_preview_tex holds + bool m_adjust_preview_valid = false; + // Paint / Erase, from the panel. See get_left_button_state_type(). bool m_erase_mode = false; // Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation. diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp index e3f73258df..3f7fa89f83 100644 --- a/src/slic3r/GUI/TextureLibrary.cpp +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -158,6 +158,28 @@ bool encode_png_bytes(const wxImage &image, std::vector &out, std : encode_gray_png_bytes(image, out, error); } +// A wxImage over decoded pixels, so the encoders above can be reached from an in-memory image as well as +// from a file. Grayscale is expanded to the three equal channels wxImage always stores, which is what +// image_has_color() then reads as "no colour" - so a grey image still encodes as a grayscale PNG. +// +// `scratch` holds the pixel buffer: the wxImage is constructed over it without taking ownership, so it +// has to outlive every use of the returned image. +wxImage image_from_decoded(const DecodedHeightTexture &image, std::vector &scratch) +{ + if (image.empty()) + return wxImage(); + + const size_t n = size_t(image.width) * size_t(image.height); + scratch.resize(n * 3); + if (image.has_color()) + scratch.assign(image.rgb.begin(), image.rgb.end()); + else + for (size_t i = 0; i < n; ++i) + scratch[i * 3] = scratch[i * 3 + 1] = scratch[i * 3 + 2] = image.pixels[i]; + + return wxImage(image.width, image.height, scratch.data(), /* static_data */ true); +} + std::vector read_file_bytes(const std::string &path) { std::ifstream ifs(path, std::ios::binary); @@ -186,6 +208,42 @@ bool is_supported_height_map(const std::vector &bytes) std::vector g_library; bool g_library_scanned = false; +// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture - +// shipped-name collision, or a second edit of the same source - is never overwritten. Returns the new +// file's library entry. Shared by import_texture_to_library() and save_texture_to_library(), which +// differ only in where their bytes came from. +std::optional write_user_texture(const std::vector &bytes, const std::string &stem, + std::string &error) +{ + const std::string user_dir = user_texture_dir(); + if (user_dir.empty()) { + error = _u8L("Could not create the folder for imported textures."); + return std::nullopt; + } + + boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); + for (int i = 2; boost::filesystem::exists(dest); ++i) + dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); + + { + boost::nowide::ofstream ofs(dest.string(), std::ios::binary); + ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); + if (!ofs.good()) { + error = _u8L("Failed to save the imported texture."); + return std::nullopt; + } + } + + texture_library(true); // pick the new file up + const std::string dest_str = dest.string(); + for (const TextureLibraryEntry &e : g_library) + if (e.path == dest_str) + return e; + + error = _u8L("Failed to save the imported texture."); + return std::nullopt; +} + // The user's own textures, most recently used first, kept in the app config. File names are stored rather // than paths so the order survives a moved data directory; '/' separates them because no platform allows // it inside a file name. Bounded so a long-lived config does not grow a line without end. @@ -317,12 +375,6 @@ bool remove_user_texture(const std::string &path, std::string &error) std::optional import_texture_to_library(const std::string &source_path, std::string &error) { - const std::string user_dir = user_texture_dir(); - if (user_dir.empty()) { - error = _u8L("Could not create the folder for imported textures."); - return std::nullopt; - } - wxImage image; if (!image.LoadFile(from_u8(source_path)) || !image.IsOk()) { error = _u8L("Could not load the selected image."); @@ -333,30 +385,7 @@ std::optional import_texture_to_library(const std::string & 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 - // own earlier import) - uniquify instead. - const std::string stem = boost::filesystem::path(source_path).stem().string(); - boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); - for (int i = 2; boost::filesystem::exists(dest); ++i) - dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); - - { - boost::nowide::ofstream ofs(dest.string(), std::ios::binary); - ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); - if (!ofs.good()) { - error = _u8L("Failed to save the imported texture."); - return std::nullopt; - } - } - - texture_library(true); // pick the new file up - const std::string dest_str = dest.string(); - for (const TextureLibraryEntry &e : g_library) - if (e.path == dest_str) - return e; - - error = _u8L("Failed to save the imported texture."); - return std::nullopt; + return write_user_texture(bytes, boost::filesystem::path(source_path).stem().string(), error); } std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error) @@ -382,4 +411,36 @@ std::shared_ptr> load_texture_image_data(const std::s return std::make_shared>(std::move(converted)); } +std::shared_ptr> encode_texture_image_data(const DecodedHeightTexture &image, std::string &error) +{ + std::vector scratch; + const wxImage wx = image_from_decoded(image, scratch); + if (!wx.IsOk()) { + error = _u8L("The selected image is empty."); + return nullptr; + } + + std::vector bytes; + if (!encode_png_bytes(wx, bytes, error)) + return nullptr; + return std::make_shared>(std::move(bytes)); +} + +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error) +{ + std::vector scratch; + const wxImage wx = image_from_decoded(image, scratch); + if (!wx.IsOk()) { + error = _u8L("The selected image is empty."); + return std::nullopt; + } + + std::vector bytes; + if (!encode_png_bytes(wx, bytes, error)) + return std::nullopt; + + return write_user_texture(bytes, stem.empty() ? std::string("texture") : stem, error); +} + } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureLibrary.hpp b/src/slic3r/GUI/TextureLibrary.hpp index 65b35bcea8..faf2f521af 100644 --- a/src/slic3r/GUI/TextureLibrary.hpp +++ b/src/slic3r/GUI/TextureLibrary.hpp @@ -6,6 +6,8 @@ #include #include +#include "libslic3r/TextureDisplacement.hpp" + namespace Slic3r::GUI { // One selectable height-map texture in the texture-displacement gizmo's texture picker. @@ -57,6 +59,27 @@ std::optional import_texture_to_library(const std::string & // Returns nullptr with `error` set if the file cannot be read or decoded at all. std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error); +// Encodes already-decoded pixels back into the PNG form a layer's image_data holds - grayscale in, +// grayscale out, as on import. This is how an edited image (see apply_texture_adjustments()) becomes a +// layer's image. +// +// The returned buffer is always a *fresh allocation*, which is load-bearing rather than incidental: +// decode_height_texture() and analyze_texture_detail() both cache by the image_data pointer, so writing +// new bytes into the buffer a layer already holds would leave every one of those caches - and the GPU +// thumbnail and height/colour textures, keyed the same way - serving the old pixels. Assigning a new +// allocation is what invalidates them. +// +// Returns nullptr with `error` set if the pixels are empty or the encode fails. +std::shared_ptr> encode_texture_image_data(const DecodedHeightTexture &image, std::string &error); + +// Writes `image` into user_texture_dir() as a new PNG named after `stem` (uniquified if taken, so an +// existing texture is never overwritten) and returns its library entry. The in-memory counterpart of +// import_texture_to_library(), for an image that was edited rather than read off disk. +// +// Returns nullopt with `error` set on failure. +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error); + } // namespace Slic3r::GUI #endif // slic3r_TextureLibrary_hpp_ diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 485f4f2a0f..b088d3b86d 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2174,3 +2174,240 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } + +// ---- Image adjustments (the gizmo's image-adjustment panel) ---------------------------------------- + +// A grayscale ramp 0..255 across the width, so every input level is present exactly once per row and a +// tonal curve can be read straight off the output. +static DecodedHeightTexture make_gray_ramp(int w = 256, int h = 4) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = uint8_t(x * 255 / std::max(1, w - 1)); + return t; +} + +// A one-texel checkerboard of 0 and 255 - the highest spatial frequency the grid can hold, which is what +// a blur has to remove and a downscale has to average rather than drop. +static DecodedHeightTexture make_gray_checkerboard(int w = 16, int h = 16) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = ((x + y) % 2 == 0) ? 0 : 255; + return t; +} + +static DecodedHeightTexture make_rgb_flat(uint8_t r, uint8_t g, uint8_t b, int w = 8, int h = 8) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + t.rgb.resize(size_t(w) * size_t(h) * 3); + for (size_t i = 0; i < size_t(w) * size_t(h); ++i) { + t.rgb[i * 3] = r; + t.rgb[i * 3 + 1] = g; + t.rgb[i * 3 + 2] = b; + t.pixels[i] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b)); + } + return t; +} + +TEST_CASE("an adjustment that moves no slider returns the image unchanged", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + const DecodedHeightTexture out = apply_texture_adjustments(src, TextureAdjustments{}); + CHECK(out.width == src.width); + CHECK(out.height == src.height); + CHECK(out.pixels == src.pixels); + CHECK(TextureAdjustments{}.identity()); +} + +TEST_CASE("brightness reaches pure white and pure black only at the ends of its range", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments up; + up.brightness = 1.f; + const DecodedHeightTexture white = apply_texture_adjustments(src, up); + CHECK(std::all_of(white.pixels.begin(), white.pixels.end(), [](uint8_t v) { return v == 255; })); + + TextureAdjustments down; + down.brightness = -1.f; + const DecodedHeightTexture black = apply_texture_adjustments(src, down); + CHECK(std::all_of(black.pixels.begin(), black.pixels.end(), [](uint8_t v) { return v == 0; })); +} + +TEST_CASE("brightness lifts the image without flattening it into a clipped plateau", "[TextureDisplacement]") +{ + // The reason brightness is a lift toward white rather than an additive offset: an offset would drive + // everything above mid-grey to a flat 255, and since this image is also the height map that region + // would bake as flat relief. Half a stop up must leave almost nothing at pure white. + const DecodedHeightTexture src = make_gray_ramp(); + TextureAdjustments adj; + adj.brightness = 0.5f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + + REQUIRE(out.pixels.size() == src.pixels.size()); + const size_t clipped = size_t(std::count(out.pixels.begin(), out.pixels.end(), uint8_t(255))); + CHECK(clipped * 100 < out.pixels.size()); // under 1%; an additive shift would clip half the ramp + + // Every texel got brighter, and the curve stays ordered: brighter in means brighter out. + for (size_t i = 0; i < out.pixels.size(); ++i) + REQUIRE(out.pixels[i] >= src.pixels[i]); + for (int x = 1; x < out.width; ++x) + REQUIRE(out.pixels[size_t(x)] >= out.pixels[size_t(x - 1)]); +} + +TEST_CASE("contrast at its lowest flattens the image to mid-grey and at its highest spreads it apart", + "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments flat; + flat.contrast = -1.f; + const DecodedHeightTexture grey = apply_texture_adjustments(src, flat); + // One value everywhere - the pivot. Which exact level that rounds to is not the point. + CHECK(std::adjacent_find(grey.pixels.begin(), grey.pixels.end(), std::not_equal_to()) == + grey.pixels.end()); + + TextureAdjustments hard; + hard.contrast = 1.f; + const DecodedHeightTexture split = apply_texture_adjustments(src, hard); + // A threshold about the pivot: the dark end of the ramp goes to black, the light end to white. + CHECK(split.pixels[0] == 0); + CHECK(split.pixels[size_t(split.width - 1)] == 255); +} + +TEST_CASE("blur removes the finest detail and sharpness exaggerates an edge", "[TextureDisplacement]") +{ + // Blur: a one-texel checkerboard is the highest frequency the grid holds, so a blur has to collapse it + // toward its own mean. Measured as mean absolute deviation, which needs no assumption about the mean. + const DecodedHeightTexture checker = make_gray_checkerboard(32, 32); + const auto deviation = [](const DecodedHeightTexture &t) { + double mean = 0.; + for (uint8_t v : t.pixels) + mean += double(v); + mean /= double(t.pixels.size()); + double dev = 0.; + for (uint8_t v : t.pixels) + dev += std::abs(double(v) - mean); + return dev / double(t.pixels.size()); + }; + TextureAdjustments blur; + blur.blur = 1.f; + CHECK(deviation(apply_texture_adjustments(checker, blur)) < 0.25 * deviation(checker)); + + // Sharpness: an unsharp mask pushes each side of a step further from the other, so the step + // overshoots - darker than the dark side just inside it, brighter than the bright side just inside. + DecodedHeightTexture step; + step.width = 64; + step.height = 8; + step.pixels.assign(size_t(step.width) * size_t(step.height), 100); + for (int y = 0; y < step.height; ++y) + for (int x = step.width / 2; x < step.width; ++x) + step.pixels[size_t(y) * size_t(step.width) + size_t(x)] = 150; + + TextureAdjustments sharp; + sharp.sharpness = 1.f; + const DecodedHeightTexture sharpened = apply_texture_adjustments(step, sharp); + const size_t row = size_t(step.height / 2) * size_t(step.width); + const size_t mid = size_t(step.width / 2); + CHECK(sharpened.pixels[row + mid - 1] < 100); // undershoot on the dark side of the step + CHECK(sharpened.pixels[row + mid] > 150); // overshoot on the light side +} + +TEST_CASE("a grayscale image stays grayscale and its hue slider does nothing", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + REQUIRE_FALSE(src.has_color()); + + TextureAdjustments hue; + hue.hue_deg = 120.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, hue); + // Not promoted to RGB: a grey image has no colour to offer the layer's colour feature, and storing it + // as RGB would triple the PNG for a row of identical greys. + CHECK_FALSE(out.has_color()); + CHECK(out.rgb.empty()); + CHECK(out.pixels == src.pixels); +} + +TEST_CASE("a colour image's height stays the luminance of its adjusted colour", "[TextureDisplacement]") +{ + // The invariant that keeps the relief and the colour from drifting apart: whatever the adjustment did + // to the channels, the height is recomputed from the channels that came out. + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + REQUIRE(src.has_color()); + + TextureAdjustments adj; + adj.brightness = 0.2f; + adj.contrast = 0.3f; + adj.hue_deg = 75.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + + for (size_t i = 0; i < out.pixels.size(); ++i) { + const long expect = std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] + + 0.114 * out.rgb[i * 3 + 2]); + REQUIRE(long(out.pixels[i]) == expect); + } + // The hue turn actually moved the colour, so the check above is not passing on an unchanged image. + CHECK(out.rgb != src.rgb); +} + +TEST_CASE("a full turn of the hue leaves a colour where it started", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + TextureAdjustments adj; + adj.hue_deg = 360.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + // Rounding through 8-bit channels, so a level or two of slack rather than exact equality. + for (size_t i = 0; i < out.rgb.size(); ++i) + REQUIRE(std::abs(int(out.rgb[i]) - int(src.rgb[i])) <= 2); +} + +TEST_CASE("the adjustment blur radius ramps from zero and stays under its cost cap", "[TextureDisplacement]") +{ + CHECK_THAT(adjustment_blur_radius(0.f, 512, 512), Catch::Matchers::WithinAbs(0.f, 1e-6f)); + // 5% of the short side, as the Smoothing slider uses. + CHECK_THAT(adjustment_blur_radius(1.f, 512, 512), Catch::Matchers::WithinAbs(25.6f, 1e-3f)); + CHECK_THAT(adjustment_blur_radius(0.5f, 512, 512), Catch::Matchers::WithinAbs(12.8f, 1e-3f)); + // Capped, so a large image at the top of the slider cannot cost without bound. + CHECK_THAT(adjustment_blur_radius(1.f, 8192, 8192), Catch::Matchers::WithinAbs(48.f, 1e-3f)); + // Sized off the short side, not the long one. + CHECK_THAT(adjustment_blur_radius(1.f, 4096, 200), Catch::Matchers::WithinAbs(10.f, 1e-3f)); +} + +TEST_CASE("downscaling a height texture averages the texels it drops and never upscales", + "[TextureDisplacement]") +{ + // Averaging, not decimation: a one-texel checkerboard halved must come out uniformly mid-grey. Picking + // one texel per block would instead come out all black or all white - detail the adjustment never + // produced, shown in the preview. + const DecodedHeightTexture small = downscale_height_texture(make_gray_checkerboard(16, 16), 8); + CHECK(small.width == 8); + CHECK(small.height == 8); + CHECK(std::all_of(small.pixels.begin(), small.pixels.end(), [](uint8_t v) { return v == 127; })); + + // Already within the limit: handed back as it is. + const DecodedHeightTexture ramp = make_gray_ramp(64, 4); + const DecodedHeightTexture same = downscale_height_texture(ramp, 256); + CHECK(same.width == ramp.width); + CHECK(same.height == ramp.height); + CHECK(same.pixels == ramp.pixels); + + // Aspect preserved, and a colour source keeps its colour. + const DecodedHeightTexture wide = downscale_height_texture(make_rgb_flat(200, 60, 30, 400, 100), 100); + CHECK(wide.width == 100); + CHECK(wide.height == 25); + CHECK(wide.has_color()); +}