diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index f88aa7665b..fe50798704 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -309,6 +309,18 @@ struct TextureDisplacementLayer // - zero or positive: a mesh vertex, every unwrapped copy of it (how edits were stored before). std::vector> lscm_uv_overrides; + // Live, unsaved image adjustments from the gizmo's image-adjustment panel, applied by + // decode_height_texture() on top of the decoded image - so while the panel is open the relief, the + // colour, the thumbnails and the bake all see the adjusted image, and moving a slider really does + // change the displacement. See TextureAdjustments. + // + // Deliberately *not* serialized, by either save()/load() here or the .3mf: it is the state of an open + // editor, not a property of the layer. The panel resets it when it closes, and "Save as copy" resolves + // it by writing a new image and pointing the layer at that - which is the only way an adjustment + // becomes permanent. A project saved mid-adjustment therefore stores the unadjusted image, which is + // the honest answer: nothing was committed. + TextureAdjustments preview_adjust; + // How this layer folds into the displacement accumulated by the layers below it. Ignored for // the lowest-slot painted layer, which has nothing beneath it to combine with (the GUI shows // it as the "Base" layer and hides the control). @@ -325,18 +337,6 @@ struct TextureDisplacementLayer // for the boundary to be drawn on. bool color_enabled = false; - // Live, unsaved image adjustments from the gizmo's image-adjustment panel, applied by - // decode_height_texture() on top of the decoded image - so while the panel is open the relief, the - // colour, the thumbnails and the bake all see the adjusted image, and moving a slider really does - // change the displacement. See TextureAdjustments. - // - // Deliberately *not* serialized, by either save()/load() here or the .3mf: it is the state of an open - // editor, not a property of the layer. The panel resets it when it closes, and "Save as copy" resolves - // it by writing a new image and pointing the layer at that - which is the only way an adjustment - // becomes permanent. A project saved mid-adjustment therefore stores the unadjusted image, which is - // the honest answer: nothing was committed. - TextureAdjustments preview_adjust; - bool empty() const { return !image_data || image_data->empty(); } template void save(Archive &ar) const @@ -521,18 +521,6 @@ struct DecodedHeightTexture bool texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &out) const; }; -// 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); - -// decode_height_texture(layer).has_color(), answered from the decode cache rather than from a copy of the -// 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); - // Applies `adj` to `src` and returns the result; `src` is left alone. An empty `src`, or an identity // `adj`, gives back a copy. // @@ -563,6 +551,18 @@ float adjustment_blur_radius(float blur, int width, int height); // showing detail the adjustment is not actually producing. DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px); +// 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); + +// decode_height_texture(layer).has_color(), answered from the decode cache rather than from a copy of the +// 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); + // 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 938c17b0eb..87b3d78637 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -3688,13 +3688,14 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() "texture_displacement_checker.svg", "texture_displacement_distortion.svg", "texture_displacement_wireframe.svg", "texture_displacement_cross.svg", "texture_displacement_uv_select_island.svg", "texture_displacement_uv_select_edge.svg", + "cog.svg", // the layer card's "adjust image" button "texture_displacement_uv_select_vertex.svg", // Paint / Erase, brush cursor shape, mapping, tiling, placement and the dock toggle. "texture_displacement_add.svg", "texture_displacement_add_negative.svg", "circle_paint.svg", "menu_obj_sphere.svg", "menu_obj_cube.svg", "menu_obj_cylinder.svg", "texture_displacement_map_unwrap.svg", "texture_displacement_map_view.svg", "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg", "texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg", - "texture_displacement_move_down.svg", "texture_displacement_drag.svg", "cog.svg", + "texture_displacement_move_down.svg", "texture_displacement_drag.svg", "texture_displacement_select_all.svg", "texture_displacement_erase_all.svg", }; std::vector paths; @@ -4043,6 +4044,19 @@ void GLGizmoTextureDisplacement::open_image_adjust(const TextureDisplacementLaye m_adjust_volume_id = mv != nullptr ? mv->id().id : 0; } +void GLGizmoTextureDisplacement::invalidate_adjust_textures() +{ + // The GPU copies of a layer's image - the list thumbnail, the preview shader's height and colour + // textures - are keyed on the image_data pointer and the layer's Smoothing, and a live adjustment + // changes neither: the pointer still names the same PNG. Rather than widen three cache keys for a + // panel that is open a few seconds at a time, the panel drops the copies itself whenever it moves + // the adjustment. Nulling the source is enough; the next get_layer_*() rebuilds from the decode, + // which does see the adjustment. + m_thumbnail_source.fill(nullptr); + m_color_tex_source = nullptr; + m_height_tex_source = nullptr; +} + void GLGizmoTextureDisplacement::close_image_adjust() { // Revert the layer's live adjustment. Every exit that reaches here is a discard: a commit resets @@ -4067,6 +4081,9 @@ void GLGizmoTextureDisplacement::close_image_adjust() } } + if (reverted) + invalidate_adjust_textures(); + // 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. @@ -4283,6 +4300,7 @@ void GLGizmoTextureDisplacement::render_image_adjust_window(const ImVec2 &panel_ layer->preview_adjust = m_adjust; if (m_adjust != m_adjust_applied) { m_adjust_applied = m_adjust; + invalidate_adjust_textures(); // Marked dirty rather than rebuilt here: the panel's own flag is consumed by the main panel's // "rebuild once the mouse is released" rule, so dragging a slider recomputes the displacement // once, on release, instead of on every frame of the drag. That is what keeps the UI responsive @@ -4371,7 +4389,6 @@ void GLGizmoTextureDisplacement::swap_layer_slots(int slot_a, int slot_b) std::swap(m_thumbnails[a], m_thumbnails[b]); std::swap(m_thumbnail_source[a], m_thumbnail_source[b]); std::swap(m_thumbnail_smoothing[a], m_thumbnail_smoothing[b]); - std::swap(m_thumbnail_adjust[a], m_thumbnail_adjust[b]); std::swap(m_layer_expanded[a], m_layer_expanded[b]); if (m_active_layer_slot == slot_a) m_active_layer_slot = slot_b; @@ -5635,7 +5652,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace const size_t slot = size_t(layer.slot); if (m_thumbnails[slot] && m_thumbnail_source[slot] == layer.image_data.get() && - m_thumbnail_smoothing[slot] == layer.smoothing && m_thumbnail_adjust[slot] == layer.preview_adjust) + m_thumbnail_smoothing[slot] == layer.smoothing) return m_thumbnails[slot].get(); // Reuses the already-decoded, already-cached grayscale pixels (see decode_height_texture()'s @@ -5649,7 +5666,6 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace m_thumbnails[slot] = std::move(texture); m_thumbnail_source[slot] = layer.image_data.get(); m_thumbnail_smoothing[slot] = layer.smoothing; - m_thumbnail_adjust[slot] = layer.preview_adjust; return m_thumbnails[slot].get(); } @@ -5657,8 +5673,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp { 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 && - m_color_tex_adjust == layer.preview_adjust) + 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); @@ -5670,7 +5685,6 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp m_color_tex = std::move(texture); m_color_tex_source = layer.image_data.get(); m_color_tex_smoothing = layer.smoothing; - m_color_tex_adjust = layer.preview_adjust; return m_color_tex.get(); } @@ -5681,8 +5695,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDis // One slot, not one per layer: the preview shader only ever shades the *active* layer, so a single // full-resolution upload is enough and the VRAM cost stays at one texture rather than eight. - if (m_height_tex && m_height_tex_source == layer.image_data.get() && m_height_tex_smoothing == layer.smoothing && - m_height_tex_adjust == layer.preview_adjust) + if (m_height_tex && m_height_tex_source == layer.image_data.get() && m_height_tex_smoothing == layer.smoothing) return m_height_tex.get(); std::unique_ptr texture = upload_height_thumbnail(decode_height_texture(layer), HEIGHT_TEX_MAX_PX); @@ -5692,7 +5705,6 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDis m_height_tex = std::move(texture); m_height_tex_source = layer.image_data.get(); m_height_tex_smoothing = layer.smoothing; - m_height_tex_adjust = layer.preview_adjust; return m_height_tex.get(); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 7f8171d161..0b539de4fd 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -400,6 +400,12 @@ private: void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry); void import_custom_texture(TextureDisplacementLayer &layer); + // 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. + // Panel state only, not saved with the project. + std::array m_layer_expanded{}; + // ---- The image-adjustment panel ---- // // Brightness/contrast/sharpness/blur/hue over one layer's image, shown on the image itself and @@ -461,12 +467,9 @@ private: 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. - // Panel state only, not saved with the project. - std::array m_layer_expanded{}; + // Drops the GPU copies of the layer's image so the next frame rebuilds them from the decode, which + // is where the live adjustment is applied. Called whenever the panel moves or reverts it. + void invalidate_adjust_textures(); // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane @@ -985,10 +988,6 @@ private: std::array m_thumbnail_source{}; // The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it. std::array m_thumbnail_smoothing{}; - // And the live image adjustment each was built at, so moving a slider in the image-adjustment panel - // re-uploads it. Part of the key for the same reason smoothing is: preview_adjust changes the pixels - // decode_height_texture() hands back, and every one of these textures is a copy of those pixels. - std::array m_thumbnail_adjust{}; // Full-resolution height texture for the preview shader, keyed the same way (see // get_layer_height_texture()). A smoothing change re-uploads it, so the fast preview shows the @@ -996,14 +995,12 @@ private: std::unique_ptr m_height_tex; const void *m_height_tex_source = nullptr; float m_height_tex_smoothing = -1.f; - TextureAdjustments m_height_tex_adjust; // 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; - TextureAdjustments m_color_tex_adjust; // Library textures the picker has shown at least once, keyed by file path (see LibraryTexture). std::map m_library_textures; diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 3d60192cf4..2fdc54e3b6 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -1301,6 +1301,294 @@ TEST_CASE("TextureDisplacement: the step cutter only cuts inside the region", "[ } } +// ---- 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()); +} + +TEST_CASE("a layer's live image adjustment reaches its decode", "[TextureDisplacement]") +{ + // This is what makes the image-adjustment panel's sliders move the relief: decode_height_texture() + // applies the layer's preview_adjust, so everything downstream of the decode - the displacement, the + // colour, the thumbnails, the bake - sees the adjusted image without being told about the panel. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + // An untouched panel, and a closed one, cost nothing and change nothing. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); + + layer.preview_adjust.brightness = 0.5f; + const DecodedHeightTexture bright = decode_height_texture(layer); + CHECK(bright.pixels != raw.pixels); + CHECK(bright.pixels == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // Back to identity gives the stored image back, so closing the panel really does revert the layer. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); +} + +TEST_CASE("changing a live adjustment is not served the previous one from cache", "[TextureDisplacement]") +{ + // The decode caches by the image_data pointer, which does not change while a slider moves - so the + // adjustment has to be part of the key or every slider move past the first would redraw the same + // pixels. Same for the smoothed copy, which is computed on top of the adjusted one. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + layer.preview_adjust.brightness = 0.5f; + const std::vector bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector dark = decode_height_texture(layer).pixels; + CHECK(bright != dark); + CHECK(dark == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // The same question again with smoothing on, which is cached separately and keyed by both. + layer.smoothing = 0.5f; + layer.preview_adjust.brightness = 0.5f; + const std::vector smooth_bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector smooth_dark = decode_height_texture(layer).pixels; + CHECK(smooth_bright != smooth_dark); +} + // --------------------------------------------------------------------------------------------- // Texture smoothing // --------------------------------------------------------------------------------------------- @@ -2175,291 +2463,3 @@ 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()); -} - -TEST_CASE("a layer's live image adjustment reaches its decode", "[TextureDisplacement]") -{ - // This is what makes the image-adjustment panel's sliders move the relief: decode_height_texture() - // applies the layer's preview_adjust, so everything downstream of the decode - the displacement, the - // colour, the thumbnails, the bake - sees the adjusted image without being told about the panel. - TextureDisplacementLayer layer; - layer.image_data = make_checkerboard_png(32, 32); - - const DecodedHeightTexture raw = decode_height_texture(layer); - REQUIRE_FALSE(raw.empty()); - - // An untouched panel, and a closed one, cost nothing and change nothing. - layer.preview_adjust = TextureAdjustments{}; - CHECK(decode_height_texture(layer).pixels == raw.pixels); - - layer.preview_adjust.brightness = 0.5f; - const DecodedHeightTexture bright = decode_height_texture(layer); - CHECK(bright.pixels != raw.pixels); - CHECK(bright.pixels == apply_texture_adjustments(raw, layer.preview_adjust).pixels); - - // Back to identity gives the stored image back, so closing the panel really does revert the layer. - layer.preview_adjust = TextureAdjustments{}; - CHECK(decode_height_texture(layer).pixels == raw.pixels); -} - -TEST_CASE("changing a live adjustment is not served the previous one from cache", "[TextureDisplacement]") -{ - // The decode caches by the image_data pointer, which does not change while a slider moves - so the - // adjustment has to be part of the key or every slider move past the first would redraw the same - // pixels. Same for the smoothed copy, which is computed on top of the adjusted one. - TextureDisplacementLayer layer; - layer.image_data = make_checkerboard_png(32, 32); - const DecodedHeightTexture raw = decode_height_texture(layer); - REQUIRE_FALSE(raw.empty()); - - layer.preview_adjust.brightness = 0.5f; - const std::vector bright = decode_height_texture(layer).pixels; - layer.preview_adjust.brightness = -0.5f; - const std::vector dark = decode_height_texture(layer).pixels; - CHECK(bright != dark); - CHECK(dark == apply_texture_adjustments(raw, layer.preview_adjust).pixels); - - // The same question again with smoothing on, which is cached separately and keyed by both. - layer.smoothing = 0.5f; - layer.preview_adjust.brightness = 0.5f; - const std::vector smooth_bright = decode_height_texture(layer).pixels; - layer.preview_adjust.brightness = -0.5f; - const std::vector smooth_dark = decode_height_texture(layer).pixels; - CHECK(smooth_bright != smooth_dark); -}