mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-12 10:21:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de07a84bfb | ||
|
|
752b356ff0 | ||
|
|
8f1c8f0580 | ||
|
|
c07165888b |
@@ -157,12 +157,33 @@ struct SmoothedTextureCache
|
||||
{
|
||||
std::weak_ptr<const std::vector<unsigned char>> source;
|
||||
float smoothing = 0.f;
|
||||
// Part of the key, not of the result: smoothing is applied *after* the panel's adjustments, so a
|
||||
// blur computed over one set of adjustments is not the blur another set would give.
|
||||
TextureAdjustments adjust;
|
||||
DecodedHeightTexture texture;
|
||||
};
|
||||
std::mutex mutex;
|
||||
std::unordered_map<const void *, Entry> entries;
|
||||
};
|
||||
SmoothedTextureCache g_smoothed_texture_cache;
|
||||
|
||||
// The adjusted copy, cached between the raw decode and the smoothed one. Needed for the same reason the
|
||||
// smoothed cache is: while a slider in the image-adjustment panel moves, the thumbnail, the shaded
|
||||
// preview's height and colour textures, the UV editor backdrop and the displacement preview job all ask
|
||||
// for the same (image, adjustments) pair within one frame, and each of them running a full-size resample
|
||||
// of its own is what would make the panel unusable on a large texture.
|
||||
struct AdjustedTextureCache
|
||||
{
|
||||
struct Entry
|
||||
{
|
||||
std::weak_ptr<const std::vector<unsigned char>> source;
|
||||
TextureAdjustments adjust;
|
||||
DecodedHeightTexture texture;
|
||||
};
|
||||
std::mutex mutex;
|
||||
std::unordered_map<const void *, Entry> entries;
|
||||
};
|
||||
AdjustedTextureCache g_adjusted_texture_cache;
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
@@ -338,6 +359,34 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), result };
|
||||
}
|
||||
|
||||
// The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before,
|
||||
// because `preview_adjust` is an edit to the image while `smoothing` is a live low-pass over whatever
|
||||
// the image is - the same order the panel presents them in, and the same order a commit bakes in.
|
||||
//
|
||||
// Nothing applies here once the panel has closed: it resets preview_adjust to identity, and identity
|
||||
// is a no-op that costs one comparison.
|
||||
if (!layer.preview_adjust.identity()) {
|
||||
bool have_adjusted = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_adjusted_texture_cache.mutex);
|
||||
auto it = g_adjusted_texture_cache.entries.find(key);
|
||||
if (it != g_adjusted_texture_cache.entries.end() && it->second.adjust == layer.preview_adjust &&
|
||||
it->second.source.lock() == layer.image_data) {
|
||||
result = it->second.texture;
|
||||
have_adjusted = true;
|
||||
}
|
||||
}
|
||||
if (!have_adjusted) {
|
||||
result = apply_texture_adjustments(result, layer.preview_adjust);
|
||||
std::lock_guard<std::mutex> lock(g_adjusted_texture_cache.mutex);
|
||||
auto &entries = g_adjusted_texture_cache.entries;
|
||||
for (auto it = entries.begin(); it != entries.end();)
|
||||
it = it->second.source.expired() ? entries.erase(it) : std::next(it);
|
||||
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data),
|
||||
layer.preview_adjust, result };
|
||||
}
|
||||
}
|
||||
|
||||
// Smoothing radius scales with the texture so the same slider value blurs the same *fraction* of
|
||||
// the image whatever resolution it came in at, and stays continuous in the slider - see
|
||||
// smooth_height_pixels(). The cap is a cost limit, not part of the mapping: the blur is
|
||||
@@ -348,7 +397,7 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
std::lock_guard<std::mutex> lock(g_smoothed_texture_cache.mutex);
|
||||
auto it = g_smoothed_texture_cache.entries.find(key);
|
||||
if (it != g_smoothed_texture_cache.entries.end() && it->second.smoothing == layer.smoothing &&
|
||||
it->second.source.lock() == layer.image_data)
|
||||
it->second.adjust == layer.preview_adjust && it->second.source.lock() == layer.image_data)
|
||||
return it->second.texture;
|
||||
}
|
||||
const float span = 0.05f * float(std::min(result.width, result.height));
|
||||
@@ -372,12 +421,70 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
auto &entries = g_smoothed_texture_cache.entries;
|
||||
for (auto it = entries.begin(); it != entries.end();)
|
||||
it = it->second.source.expired() ? entries.erase(it) : std::next(it);
|
||||
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), layer.smoothing, result };
|
||||
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), layer.smoothing,
|
||||
layer.preview_adjust, result };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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<uint8_t> &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<uint8_t> 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<size_t>(0, channel.size()), [&](const tbb::blocked_range<size_t> &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 +493,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<int>(0, h), [&](const tbb::blocked_range<int> &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<uint8_t, 256> 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<size_t>(0, n), [&](const tbb::blocked_range<size_t> &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<size_t>(0, n), [&](const tbb::blocked_range<size_t> &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<uint8_t> 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<size_t>(0, n), [&](const tbb::blocked_range<size_t> &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;
|
||||
|
||||
@@ -154,6 +154,38 @@ struct TextureIsland
|
||||
// chart to its true mm area, so a freshly unwrapped patch has uniform texel density to begin with.
|
||||
void average_island_scales(std::vector<TextureIsland> &islands);
|
||||
|
||||
// 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); }
|
||||
};
|
||||
|
||||
// One texture asset plus its projection/displacement parameters. Several layers may be painted
|
||||
// onto overlapping areas of the same volume: their displacements are combined per vertex, in slot
|
||||
// order, each layer folding into the total via its own TextureBlendMode (see
|
||||
@@ -293,6 +325,18 @@ 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<class Archive> void save(Archive &ar) const
|
||||
@@ -489,6 +533,36 @@ 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);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
#include <cstdio>
|
||||
#include <glad/gl.h>
|
||||
#include <imgui.h>
|
||||
@@ -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;
|
||||
@@ -2450,9 +2463,12 @@ void GLGizmoTextureDisplacement::update_uv_editor()
|
||||
// before, and independently of, the unwrap - the texture belongs on screen before anything is unwrapped - and
|
||||
// only when the choice, the layer's image or its smoothing changed, because the height image is large.
|
||||
const UVBackground desired_bg = (m_uv_check_mode == UVCheckMode::Checker) ? UVBackground::Checker : UVBackground::Height;
|
||||
const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && (m_uv_editor_bg_smoothing != layer->smoothing);
|
||||
const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) &&
|
||||
(m_uv_editor_bg_smoothing != layer->smoothing ||
|
||||
!(m_uv_editor_bg_adjust == layer->preview_adjust));
|
||||
if (desired_bg != m_uv_editor_bg || layer->image_data.get() != m_uv_editor_bg_image || bg_smoothing_changed) {
|
||||
m_uv_editor_bg_smoothing = layer->smoothing;
|
||||
m_uv_editor_bg_adjust = layer->preview_adjust;
|
||||
m_uv_editor_bg_image = layer->image_data.get();
|
||||
m_uv_editor_bg = desired_bg;
|
||||
if (desired_bg == UVBackground::Checker) {
|
||||
@@ -3678,7 +3694,7 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
|
||||
"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",
|
||||
"texture_displacement_move_down.svg", "texture_displacement_drag.svg", "cog.svg",
|
||||
"texture_displacement_select_all.svg", "texture_displacement_erase_all.svg",
|
||||
};
|
||||
std::vector<std::string> paths;
|
||||
@@ -3982,6 +3998,321 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
m_picker_open_request = true;
|
||||
}
|
||||
}
|
||||
bool GLGizmoTextureDisplacement::load_image_adjust_source(const TextureDisplacementLayer &layer)
|
||||
{
|
||||
if (layer.empty())
|
||||
return false;
|
||||
|
||||
// The *raw* image: neither the layer's Smoothing nor the panel's own preview_adjust, so what the
|
||||
// sliders act on is always the image as it is actually stored rather than the last preview of it.
|
||||
// Without clearing preview_adjust here the adjustment would compound on itself every reload.
|
||||
TextureDisplacementLayer raw = layer;
|
||||
raw.smoothing = 0.f;
|
||||
raw.preview_adjust = TextureAdjustments{};
|
||||
|
||||
DecodedHeightTexture decoded = decode_height_texture(raw);
|
||||
if (decoded.empty())
|
||||
return false;
|
||||
|
||||
m_adjust_source = std::move(decoded);
|
||||
m_adjust_preview_source = downscale_height_texture(m_adjust_source, ADJUST_PREVIEW_MAX_PX);
|
||||
m_adjust_source_image = layer.image_data.get();
|
||||
// The unadjusted image, so the box is filled now rather than one background pass later. Pairing it
|
||||
// with an identity `shown` is what makes update_image_adjust_preview() relaunch for the sliders'
|
||||
// current values against the new source instead of deciding it is already showing them.
|
||||
m_adjust_preview_shown = TextureAdjustments{};
|
||||
m_adjust_preview_tex = upload_height_thumbnail(m_adjust_preview_source, ADJUST_PREVIEW_MAX_PX);
|
||||
m_adjust_preview_valid = m_adjust_preview_tex != nullptr;
|
||||
return true;
|
||||
}
|
||||
|
||||
void GLGizmoTextureDisplacement::open_image_adjust(const TextureDisplacementLayer &layer)
|
||||
{
|
||||
if (layer.empty() || layer.slot < 0)
|
||||
return;
|
||||
|
||||
close_image_adjust(); // reverts and clears any previous panel
|
||||
|
||||
if (!load_image_adjust_source(layer))
|
||||
return;
|
||||
|
||||
m_adjust = TextureAdjustments{};
|
||||
m_adjust_applied = TextureAdjustments{};
|
||||
m_adjust_slot = layer.slot;
|
||||
const ModelVolume *mv = texture_volume();
|
||||
m_adjust_volume_id = mv != nullptr ? mv->id().id : 0;
|
||||
}
|
||||
|
||||
void GLGizmoTextureDisplacement::close_image_adjust()
|
||||
{
|
||||
// Revert the layer's live adjustment. Every exit that reaches here is a discard: a commit resets
|
||||
// preview_adjust itself, before pointing the layer at the file that already carries the edit, so this
|
||||
// cannot undo a committed adjustment.
|
||||
//
|
||||
// Found by the id recorded when the panel opened rather than through texture_volume(), which answers
|
||||
// for the *current* selection. One of the ways to get here is the selection having moved on, and
|
||||
// reverting the newly selected object's layer instead would both miss the adjustment that is still
|
||||
// live on the original volume and clear one the user never touched.
|
||||
bool reverted = false;
|
||||
if (m_adjust_slot >= 0 && m_adjust_volume_id != 0)
|
||||
for (ModelObject *mo : wxGetApp().model().objects) {
|
||||
for (ModelVolume *mv : mo->volumes) {
|
||||
if (mv->id().id != m_adjust_volume_id)
|
||||
continue;
|
||||
for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
|
||||
if (l.slot == m_adjust_slot && !l.preview_adjust.identity()) {
|
||||
l.preview_adjust = TextureAdjustments{};
|
||||
reverted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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_applied = TextureAdjustments{};
|
||||
m_adjust_source = DecodedHeightTexture{};
|
||||
m_adjust_preview_source = DecodedHeightTexture{};
|
||||
m_adjust_source_image = nullptr;
|
||||
m_adjust_volume_id = 0;
|
||||
m_adjust_preview_tex.reset();
|
||||
m_adjust_preview_valid = false;
|
||||
|
||||
// Only while the gizmo is up: on_shutdown() also comes through here, and rebuilding a preview for a
|
||||
// gizmo that is closing would be work for a model nobody is looking at. The reverted layer data is
|
||||
// what matters there, and that is already done above.
|
||||
if (reverted && m_state == On) {
|
||||
rebuild_preview();
|
||||
m_parent.set_as_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
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<GLTexture> 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)
|
||||
{
|
||||
if (m_adjust_source.empty() || m_adjust.identity()) {
|
||||
// Nothing was changed, so there is nothing to save.
|
||||
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 that follows 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;
|
||||
const std::string stem = layer.name.empty() ? std::string("texture") : layer.name;
|
||||
const std::optional<TextureLibraryEntry> entry = save_texture_to_library(adjusted, stem, error);
|
||||
if (!entry) {
|
||||
show_error(nullptr, error);
|
||||
return; // the panel stays open, and the layer keeps the live adjustment: the edit is not lost
|
||||
}
|
||||
|
||||
// Cleared *before* the layer is pointed at the new file, and this ordering is the whole correctness of
|
||||
// the commit: the saved image already has the adjustment baked into its pixels, so leaving
|
||||
// preview_adjust set would apply brightness, contrast and the rest a second time on top of itself.
|
||||
layer.preview_adjust = TextureAdjustments{};
|
||||
// set_layer_texture() does the rest - the name, the recently-used ordering, a fresh image_data buffer
|
||||
// (which is what invalidates every cache keyed by the old pointer), the undo snapshot and the rebuild.
|
||||
set_layer_texture(layer, *entry);
|
||||
|
||||
close_image_adjust();
|
||||
}
|
||||
|
||||
void GLGizmoTextureDisplacement::render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max)
|
||||
{
|
||||
if (m_adjust_slot < 0)
|
||||
return;
|
||||
|
||||
ModelVolume *mv = texture_volume();
|
||||
// A different volume is selected than the one this panel was opened on. Slots are numbered per
|
||||
// volume, so looking m_adjust_slot up here would find an unrelated layer rather than nothing.
|
||||
if (mv == nullptr || mv->id().id != m_adjust_volume_id) {
|
||||
close_image_adjust();
|
||||
return;
|
||||
}
|
||||
|
||||
TextureDisplacementLayer *layer = nullptr;
|
||||
for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
|
||||
if (l.slot == m_adjust_slot)
|
||||
layer = &l;
|
||||
if (layer == nullptr) {
|
||||
// The layer went away under the panel (removed, or merged away by an undo).
|
||||
close_image_adjust();
|
||||
return;
|
||||
}
|
||||
|
||||
// The layer's image can be swapped from the texture picker while this panel is open - the panel is a
|
||||
// window now, so the rest of the gizmo stays usable behind it. Re-read the source when that happens,
|
||||
// keeping the slider positions: the user is adjusting this layer, and the 3D view has already moved to
|
||||
// the new image (decode_height_texture() reads layer.image_data, which changed), so re-reading here is
|
||||
// also what keeps the panel's photo and the model showing the same thing.
|
||||
if (layer->image_data.get() != m_adjust_source_image && !load_image_adjust_source(*layer)) {
|
||||
close_image_adjust(); // the new image cannot be decoded; nothing left to adjust
|
||||
return;
|
||||
}
|
||||
|
||||
const float img_w = std::round(m_imgui->scaled(16.f));
|
||||
const float gap = m_imgui->scaled(0.5f);
|
||||
// Placed beside the panel on first appearance only, and left wherever the user drags it after that -
|
||||
// it is a window, not a popup, so its position is theirs to keep.
|
||||
const float win_w = img_w + 2.f * ImGui::GetStyle().WindowPadding.x;
|
||||
const bool left = panel_min.x - gap - win_w >= 0.f;
|
||||
ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap - win_w : panel_max.x + gap,
|
||||
panel_min.y + m_imgui->scaled(4.f)), ImGuiCond_Appearing);
|
||||
|
||||
bool keep_open = true;
|
||||
if (!ImGui::Begin((_u8L("Adjust image") + "##texture_adjust").c_str(), &keep_open,
|
||||
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) {
|
||||
ImGui::End();
|
||||
if (!keep_open)
|
||||
close_image_adjust();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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. 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);
|
||||
ImGui::TextDisabled("%s", _u8L("This texture is grayscale, so it has no hue to shift.").c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
}
|
||||
|
||||
if (m_imgui->button(_L("Reset")))
|
||||
m_adjust = TextureAdjustments{};
|
||||
|
||||
// Hand the sliders to the layer, which is what puts the adjusted image in front of the displacement:
|
||||
// decode_height_texture() applies preview_adjust, so the relief, the colour and the thumbnails all
|
||||
// follow from here. Written every frame rather than only on a change, so an undo snapshot or a
|
||||
// reselection that resets the layer cannot leave the model showing an adjustment the panel has moved
|
||||
// on from.
|
||||
layer->preview_adjust = m_adjust;
|
||||
if (m_adjust != m_adjust_applied) {
|
||||
m_adjust_applied = m_adjust;
|
||||
// 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
|
||||
// while a full-size resample and a mesh displacement happen behind it.
|
||||
m_preview_params_dirty = true;
|
||||
// This window is drawn after the block that consumes that flag, so the rebuild happens on a later
|
||||
// frame - which means there has to *be* one. Cheap insurance: a slider release that generated no
|
||||
// further canvas event would otherwise leave the flag set and the model unchanged until something
|
||||
// unrelated redrew.
|
||||
m_parent.set_as_dirty();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
m_imgui->text_wrapped(_u8L("The model updates when you release a slider. Save as copy writes the "
|
||||
"adjusted image to your texture library and points this layer at it."), img_w);
|
||||
|
||||
const bool cancel = m_imgui->button(_L("Cancel"));
|
||||
ImGui::SameLine();
|
||||
// Nothing to save until a slider has actually moved.
|
||||
m_imgui->disabled_begin(m_adjust.identity());
|
||||
const bool as_copy = m_imgui->button(_L("Save as copy"));
|
||||
m_imgui->disabled_end();
|
||||
|
||||
ImGui::End();
|
||||
|
||||
if (cancel || !keep_open)
|
||||
close_image_adjust();
|
||||
else if (as_copy)
|
||||
commit_image_adjust(*layer);
|
||||
}
|
||||
|
||||
void GLGizmoTextureDisplacement::remove_texture_layer(int slot)
|
||||
{
|
||||
@@ -4040,6 +4371,7 @@ 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;
|
||||
@@ -5303,7 +5635,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_smoothing[slot] == layer.smoothing && m_thumbnail_adjust[slot] == layer.preview_adjust)
|
||||
return m_thumbnails[slot].get();
|
||||
|
||||
// Reuses the already-decoded, already-cached grayscale pixels (see decode_height_texture()'s
|
||||
@@ -5317,6 +5649,7 @@ 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();
|
||||
}
|
||||
|
||||
@@ -5324,7 +5657,8 @@ 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)
|
||||
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)
|
||||
return m_color_tex.get();
|
||||
|
||||
std::unique_ptr<GLTexture> texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
|
||||
@@ -5336,6 +5670,7 @@ 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();
|
||||
}
|
||||
|
||||
@@ -5346,7 +5681,8 @@ 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)
|
||||
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)
|
||||
return m_height_tex.get();
|
||||
|
||||
std::unique_ptr<GLTexture> texture = upload_height_thumbnail(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
|
||||
@@ -5356,6 +5692,7 @@ 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();
|
||||
}
|
||||
|
||||
@@ -6412,7 +6749,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
ImGui::SameLine();
|
||||
|
||||
const std::string not_painted = _u8L("not painted");
|
||||
const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 3.f * frame_h + 2.f * gap_s;
|
||||
const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 4.f * frame_h + 3.f * gap_s;
|
||||
const float name_w = std::max(frame_h, ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::GetCursorPosX() - gap_s - right_w);
|
||||
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(orca.x, orca.y, orca.z, 0.2f));
|
||||
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(orca.x, orca.y, orca.z, 0.35f));
|
||||
@@ -6426,6 +6763,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("%s", not_painted.c_str());
|
||||
}
|
||||
// Between the name and the order arrows: it acts on the image the name refers to, so it
|
||||
// belongs with the name rather than with the controls that reorder the stack.
|
||||
ImGui::SameLine();
|
||||
m_imgui->disabled_begin(busy || layer.empty());
|
||||
if (icon_button(613 + layer.slot, "cog.svg", frame_h, _L("Adjust image"),
|
||||
_u8L("Brightness, contrast, sharpness, blur and hue, shown on the image and on the "
|
||||
"model as you set them. Save as copy writes the result to your texture library "
|
||||
"and points this layer at it.")))
|
||||
open_image_adjust(layer);
|
||||
m_imgui->disabled_end();
|
||||
ImGui::SameLine();
|
||||
m_imgui->disabled_begin(busy || li == 0);
|
||||
if (icon_button(611, "texture_displacement_move_up.svg", frame_h, _L("Move up"), _L("Move up - applied earlier"))) {
|
||||
@@ -7225,10 +7572,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
set_active_layer(m_picker_slot);
|
||||
|
||||
// In this window's scope, where the popup's open request and the popup itself share one ID stack.
|
||||
ImVec2 panel_rect_min, panel_rect_max;
|
||||
{
|
||||
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));
|
||||
// Only recorded here. The image-adjustment panel is a top-level window rather than a popup, so it
|
||||
// cannot be begun inside this one; it is drawn after GizmoImguiEnd() below.
|
||||
panel_rect_min = win_min;
|
||||
panel_rect_max = 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
|
||||
@@ -7465,6 +7817,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
m_parent.request_extra_frame();
|
||||
}
|
||||
GizmoImguiEnd();
|
||||
|
||||
// Outside the gizmo's window, because it is a window of its own: a floating, non-modal panel that
|
||||
// leaves the 3D view live so the relief can be watched while the sliders move. No-op when closed.
|
||||
render_image_adjust_window(panel_rect_min, panel_rect_max);
|
||||
|
||||
ImGuiWrapper::pop_toolbar_style();
|
||||
|
||||
// Drawn last, over everything, via the foreground draw list: the +/- add-remove sign next to the 3D
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include <imgui.h>
|
||||
#include <map>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoBase.hpp"
|
||||
@@ -399,6 +400,68 @@ 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).
|
||||
//
|
||||
// An ordinary floating window rather than a popup, and deliberately so. A popup dims the canvas
|
||||
// behind it and closes on any click outside itself, which between them make it impossible to watch the
|
||||
// relief change on the model - which is the whole point of adjusting the image here rather than in an
|
||||
// image editor. A window leaves the 3D view live, clickable and orbitable while the panel is open.
|
||||
void render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max);
|
||||
// Opens the panel for `layer`, decoding its image into m_adjust_source. Sliders never re-decode
|
||||
// the PNG.
|
||||
void open_image_adjust(const TextureDisplacementLayer &layer);
|
||||
// Reads `layer`'s stored image into the panel's source buffers and records which image it came from.
|
||||
// Called when the panel opens and again whenever the layer's image is swapped under it, which is why
|
||||
// it leaves the slider values alone. False when the image cannot be decoded.
|
||||
bool load_image_adjust_source(const TextureDisplacementLayer &layer);
|
||||
// Resolves the panel: writes the adjusted image into the user's texture library as a new file and
|
||||
// points `layer` at it, which is the only way an adjustment becomes permanent. Closes the panel.
|
||||
void commit_image_adjust(TextureDisplacementLayer &layer);
|
||||
// Closes the panel and reverts the layer's live preview_adjust to identity, so every exit that is not
|
||||
// a commit - Cancel, the window's close button, the gizmo closing - leaves the layer as it was found.
|
||||
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
|
||||
TextureAdjustments m_adjust; // what the sliders currently say
|
||||
// What the layer's live preview_adjust was last set from, so a slider move can be told from a frame in
|
||||
// which nothing happened and only the former asks the displacement to be recomputed.
|
||||
TextureAdjustments m_adjust_applied;
|
||||
|
||||
// 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;
|
||||
// Which image_data the two buffers above were decoded from, so the panel can notice the layer's image
|
||||
// being swapped from the texture picker while it is open and re-read it. Compared, never dereferenced.
|
||||
const void *m_adjust_source_image = nullptr;
|
||||
// And which volume the panel was opened on. Slot numbers are per volume, so without this a change of
|
||||
// selection would silently rebind the open panel to whichever layer happens to hold the same slot on
|
||||
// the newly selected object - a different image, under a panel the user opened for this one.
|
||||
size_t m_adjust_volume_id = 0;
|
||||
// The preview image on screen, re-uploaded whenever a background pass finishes.
|
||||
std::unique_ptr<GLTexture> 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<DecodedHeightTexture> 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.
|
||||
@@ -878,6 +941,7 @@ private:
|
||||
enum class UVBackground { None, Height, Checker };
|
||||
UVBackground m_uv_editor_bg = UVBackground::None;
|
||||
float m_uv_editor_bg_smoothing = -1.f; // smoothing the height backdrop was uploaded at
|
||||
TextureAdjustments m_uv_editor_bg_adjust; // and the live image adjustment it was uploaded at
|
||||
const void *m_uv_editor_bg_image = nullptr; // the layer image it was uploaded from
|
||||
// Per-chart distortion heatmap colour for the UV pane (#7/#14), computed once when the unwrap is
|
||||
// re-solved (relative stretch doesn't change when islands are merely moved), fed to the canvas only
|
||||
@@ -921,6 +985,10 @@ private:
|
||||
std::array<const void *, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnail_source{};
|
||||
// The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it.
|
||||
std::array<float, TEXTURE_DISPLACEMENT_MAX_LAYERS> 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<TextureAdjustments, TEXTURE_DISPLACEMENT_MAX_LAYERS> 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
|
||||
@@ -928,12 +996,14 @@ private:
|
||||
std::unique_ptr<GLTexture> 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<GLTexture> 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<std::string, LibraryTexture> m_library_textures;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <wx/mstream.h>
|
||||
|
||||
#include "libslic3r/PNGReadWrite.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
|
||||
#include "libslic3r/AppConfig.hpp"
|
||||
@@ -158,6 +159,28 @@ bool encode_png_bytes(const wxImage &image, std::vector<unsigned char> &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<unsigned char> &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<unsigned char> read_file_bytes(const std::string &path)
|
||||
{
|
||||
std::ifstream ifs(path, std::ios::binary);
|
||||
@@ -186,6 +209,42 @@ bool is_supported_height_map(const std::vector<unsigned char> &bytes)
|
||||
std::vector<TextureLibraryEntry> 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<TextureLibraryEntry> write_user_texture(const std::vector<unsigned char> &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<const char *>(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 +376,6 @@ bool remove_user_texture(const std::string &path, std::string &error)
|
||||
|
||||
std::optional<TextureLibraryEntry> 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 +386,7 @@ std::optional<TextureLibraryEntry> 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<const char *>(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<std::vector<unsigned char>> load_texture_image_data(const std::string &path, std::string &error)
|
||||
@@ -382,4 +412,21 @@ std::shared_ptr<std::vector<unsigned char>> load_texture_image_data(const std::s
|
||||
return std::make_shared<std::vector<unsigned char>>(std::move(converted));
|
||||
}
|
||||
|
||||
std::optional<TextureLibraryEntry> save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem,
|
||||
std::string &error)
|
||||
{
|
||||
std::vector<unsigned char> scratch;
|
||||
const wxImage wx = image_from_decoded(image, scratch);
|
||||
if (!wx.IsOk()) {
|
||||
error = _u8L("The selected image is empty.");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> 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
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
|
||||
namespace Slic3r::GUI {
|
||||
|
||||
// One selectable height-map texture in the texture-displacement gizmo's texture picker.
|
||||
@@ -57,6 +59,14 @@ std::optional<TextureLibraryEntry> 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<std::vector<unsigned char>> load_texture_image_data(const std::string &path, 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<TextureLibraryEntry> save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem,
|
||||
std::string &error);
|
||||
|
||||
} // namespace Slic3r::GUI
|
||||
|
||||
#endif // slic3r_TextureLibrary_hpp_
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <functional>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
@@ -2174,3 +2175,291 @@ 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<uint8_t>()) ==
|
||||
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<uint8_t> bright = decode_height_texture(layer).pixels;
|
||||
layer.preview_adjust.brightness = -0.5f;
|
||||
const std::vector<uint8_t> 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<uint8_t> smooth_bright = decode_height_texture(layer).pixels;
|
||||
layer.preview_adjust.brightness = -0.5f;
|
||||
const std::vector<uint8_t> smooth_dark = decode_height_texture(layer).pixels;
|
||||
CHECK(smooth_bright != smooth_dark);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user