mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-12 18:31:18 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdcfa1e15 | ||
|
|
de07a84bfb | ||
|
|
752b356ff0 | ||
|
|
8f1c8f0580 | ||
|
|
c07165888b |
@@ -124,9 +124,7 @@ Control on the Global page, and from the `?` key on the page of the view that re
|
||||
the dialog should list it; the row's section heading is the `section_table` entry
|
||||
above it, so a new section needs an entry there too. Pick a default that does not
|
||||
collide inside its contexts; the `[Shortcuts]` tests check every default against the
|
||||
others. A default `KeyChord{}` leaves the action unbound: it is listed in the dialog
|
||||
for the user to bind and matches nothing until they do, which is the honest answer
|
||||
when every key its contexts can reach is already spoken for.
|
||||
others.
|
||||
2. Handle it in the dispatcher of its context: `MainFrame::handle_global_shortcut`,
|
||||
`GLCanvas3D::handle_shortcut`, `ObjectList::dispatch_shortcut`, or a gizmo's
|
||||
`on_tool_shortcut`. A gizmo that opens on a key sets `m_shortcut` in its constructor.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g transform="rotate(-45 8 8)"><path d="M8 14.4 6.5 11.2 9.5 11.2 Z" style="fill:#009688"/><path d="M6.5 7.3 6.5 11.2 8 14.4 9.5 11.2 9.5 7.3" style="fill:none;stroke:#949494;stroke-width:0.4;stroke-linejoin:round;stroke-linecap:round"/><rect x="5.5" y="3.8" width="5" height="3.5" rx="0.9" style="fill:none;stroke:#949494;stroke-width:0.4;stroke-linejoin:round"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 458 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="5.9" cy="6.3" r="1.25" style="fill:#009688"/><path d="M3.4 11.9 6.4 7.7 8.8 10.7 10.4 8.6 12.6 11.9 Z" style="fill:#009688"/><rect x="2.375" y="2.375" width="11.25" height="11.25" rx="1.25" style="fill:none;stroke:#949494;stroke-width:0.4;stroke-linecap:round;stroke-linejoin:round"/></svg>
|
||||
|
Before Width: | Height: | Size: 386 B |
@@ -51,11 +51,6 @@ uniform float tex_aspect;
|
||||
uniform float rotation_rad;
|
||||
uniform vec2 uv_offset;
|
||||
uniform bool invert;
|
||||
// False means the layer places a single copy rather than repeating: outside that copy there is
|
||||
// simply no texture. DecodedHeightTexture::sample() has always returned 0 there, so the bake got
|
||||
// this right while the preview wrapped the image and showed a tiled pattern that was never going
|
||||
// to be printed.
|
||||
uniform bool tile_enabled;
|
||||
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
|
||||
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
|
||||
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
|
||||
@@ -223,14 +218,6 @@ int nearest_palette_entry(vec3 rgb)
|
||||
return best;
|
||||
}
|
||||
|
||||
// True where this uv has texture on it: everywhere when the layer repeats, and only inside the
|
||||
// single placed copy when it does not. The bake's own sampler draws the same line (see
|
||||
// DecodedHeightTexture::sample), so the preview now stops at the same edge it does.
|
||||
bool in_tile(vec2 uv)
|
||||
{
|
||||
return tile_enabled || (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0);
|
||||
}
|
||||
|
||||
void main()
|
||||
{
|
||||
if (any(lessThan(clipping_planes_dots, ZERO)))
|
||||
@@ -256,9 +243,9 @@ void main()
|
||||
? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr
|
||||
: vertex_uv;
|
||||
color_uv = uv;
|
||||
have_uv = in_tile(uv);
|
||||
have_uv = true;
|
||||
float h = texture2D(height_tex, uv).r;
|
||||
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) * (have_uv ? 1.0 : 0.0);
|
||||
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
|
||||
vec3 sigmaS = dFdx(world_pos.xyz);
|
||||
vec3 sigmaT = dFdy(world_pos.xyz);
|
||||
vec3 R1 = cross(sigmaT, triangle_normal);
|
||||
@@ -275,12 +262,10 @@ void main()
|
||||
|
||||
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
|
||||
// first point where it drops below the displaced surface (see header).
|
||||
// uv first: outside a single placed copy there is no shell to march through.
|
||||
vec2 uv = project_uv(tex_pos, triangle_normal);
|
||||
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) *
|
||||
(in_tile(uv) ? 1.0 : 0.0);
|
||||
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
|
||||
vec3 view_dir = normalize(eye_model_pos - tex_pos);
|
||||
float v_dot_n = dot(view_dir, triangle_normal);
|
||||
vec2 uv = project_uv(tex_pos, triangle_normal);
|
||||
|
||||
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
|
||||
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
|
||||
@@ -317,7 +302,7 @@ void main()
|
||||
}
|
||||
|
||||
color_uv = uv; // after the parallax march, so colour and relief stay registered
|
||||
have_uv = in_tile(uv);
|
||||
have_uv = true;
|
||||
|
||||
float hL = texture2D(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
|
||||
float hR = texture2D(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
|
||||
@@ -326,8 +311,7 @@ void main()
|
||||
|
||||
vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y));
|
||||
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
|
||||
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0) *
|
||||
(have_uv ? 1.0 : 0.0);
|
||||
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
|
||||
|
||||
float cs = cos(rotation_rad);
|
||||
float sn = sin(rotation_rad);
|
||||
@@ -357,12 +341,7 @@ void main()
|
||||
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
|
||||
// the same material under the same light, and the relief this preview exists to show is unaffected.
|
||||
vec3 albedo = uniform_color.rgb;
|
||||
// The fourth channel is coverage: where the picture is not there, the surface keeps its own
|
||||
// colour rather than being painted the black that a transparent texel's rgb reads as.
|
||||
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) {
|
||||
vec4 tex_rgba = texture2D(color_tex, color_uv);
|
||||
if (tex_rgba.a >= 0.5)
|
||||
albedo = palette_rgb[nearest_palette_entry(tex_rgba.rgb)];
|
||||
}
|
||||
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
|
||||
albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)];
|
||||
gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
|
||||
}
|
||||
|
||||
@@ -110,11 +110,6 @@ uniform float tex_aspect;
|
||||
uniform float rotation_rad;
|
||||
uniform vec2 uv_offset;
|
||||
uniform bool invert;
|
||||
// False means the layer places a single copy rather than repeating: outside that copy there is
|
||||
// simply no texture. DecodedHeightTexture::sample() has always returned 0 there, so the bake got
|
||||
// this right while the preview wrapped the image and showed a tiled pattern that was never going
|
||||
// to be printed.
|
||||
uniform bool tile_enabled;
|
||||
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
|
||||
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
|
||||
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
|
||||
@@ -289,14 +284,6 @@ int nearest_palette_entry(vec3 rgb)
|
||||
return best;
|
||||
}
|
||||
|
||||
// True where this uv has texture on it: everywhere when the layer repeats, and only inside the
|
||||
// single placed copy when it does not. The bake's own sampler draws the same line (see
|
||||
// DecodedHeightTexture::sample), so the preview now stops at the same edge it does.
|
||||
bool in_tile(vec2 uv)
|
||||
{
|
||||
return tile_enabled || (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0);
|
||||
}
|
||||
|
||||
void main()
|
||||
{
|
||||
if (any(lessThan(clipping_planes_dots, ZERO)))
|
||||
@@ -334,9 +321,9 @@ void main()
|
||||
? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr
|
||||
: vertex_uv;
|
||||
color_uv = uv;
|
||||
have_uv = in_tile(uv);
|
||||
have_uv = true;
|
||||
float h = texture(height_tex, uv).r;
|
||||
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) * (have_uv ? 1.0 : 0.0);
|
||||
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
|
||||
vec3 sigmaS = dFdx(world_pos.xyz);
|
||||
vec3 sigmaT = dFdy(world_pos.xyz);
|
||||
vec3 R1 = cross(sigmaT, triangle_normal);
|
||||
@@ -356,12 +343,10 @@ void main()
|
||||
|
||||
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
|
||||
// first point where it drops below the displaced surface (see header).
|
||||
// uv first: outside a single placed copy there is no shell to march through.
|
||||
vec2 uv = project_uv(tex_pos, triangle_normal);
|
||||
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) *
|
||||
(in_tile(uv) ? 1.0 : 0.0);
|
||||
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
|
||||
vec3 view_dir = normalize(eye_model_pos - tex_pos);
|
||||
float v_dot_n = dot(view_dir, triangle_normal);
|
||||
vec2 uv = project_uv(tex_pos, triangle_normal);
|
||||
|
||||
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
|
||||
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
|
||||
@@ -398,7 +383,7 @@ void main()
|
||||
}
|
||||
|
||||
color_uv = uv; // after the parallax march, so colour and relief stay registered
|
||||
have_uv = in_tile(uv);
|
||||
have_uv = true;
|
||||
|
||||
float hL = texture(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
|
||||
float hR = texture(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
|
||||
@@ -411,8 +396,7 @@ void main()
|
||||
// uv -> mm is 1/tiling_scale for the triplanar projection, so this turns the uv-space
|
||||
// gradient into a real surface slope.
|
||||
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
|
||||
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0) *
|
||||
(have_uv ? 1.0 : 0.0);
|
||||
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
|
||||
// uv was rotated by project_uv() while t/b are the unrotated model axes, so rotate the
|
||||
// gradient back into the axes' frame.
|
||||
float cs = cos(rotation_rad);
|
||||
@@ -445,12 +429,7 @@ void main()
|
||||
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
|
||||
// the same material under the same light, and the relief this preview exists to show is unaffected.
|
||||
vec3 albedo = uniform_color.rgb;
|
||||
// The fourth channel is coverage: where the picture is not there, the surface keeps its own
|
||||
// colour rather than being painted the black that a transparent texel's rgb reads as.
|
||||
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) {
|
||||
vec4 tex_rgba = texture(color_tex, color_uv);
|
||||
if (tex_rgba.a >= 0.5)
|
||||
albedo = palette_rgb[nearest_palette_entry(tex_rgba.rgb)];
|
||||
}
|
||||
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
|
||||
albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)];
|
||||
out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
|
||||
}
|
||||
|
||||
@@ -115,20 +115,6 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
|
||||
return top * (1.f - ty) + bottom * ty;
|
||||
}
|
||||
|
||||
float DecodedHeightTexture::sample_alpha(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
|
||||
{
|
||||
TexelTap tap;
|
||||
if (!has_alpha())
|
||||
return 1.f; // no coverage plane means the picture is everywhere, which is the usual case
|
||||
if (!texel_tap(uv, tile_enabled, tile_method, tap))
|
||||
return 0.f;
|
||||
|
||||
auto at = [this](int x, int y) { return float(alpha[size_t(y) * size_t(width) + size_t(x)]) / 255.f; };
|
||||
const float top = at(tap.x0, tap.y0) * (1.f - tap.tx) + at(tap.x1, tap.y0) * tap.tx;
|
||||
const float bottom = at(tap.x0, tap.y1) * (1.f - tap.tx) + at(tap.x1, tap.y1) * tap.tx;
|
||||
return top * (1.f - tap.ty) + bottom * tap.ty;
|
||||
}
|
||||
|
||||
Vec3f DecodedHeightTexture::sample_color(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
|
||||
{
|
||||
TexelTap tap;
|
||||
@@ -171,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 {
|
||||
@@ -275,272 +282,6 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer)
|
||||
return decode_height_texture(layer).has_color();
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Unsharp mask over the colour: blur a copy and push each texel away from it. The blur is the same
|
||||
// sliding-window helper the Smoothing slider uses, run per channel, so the cost is the image size
|
||||
// rather than the image size times the radius.
|
||||
//
|
||||
// Worth having here for a reason it would not be in a photo editor: the picture is about to be
|
||||
// reduced to a handful of flat colours, and a soft edge between two of them quantizes into a ragged
|
||||
// band of both. Sharpening first makes that boundary land in one place.
|
||||
void sharpen_rgb(DecodedHeightTexture &tex, float amount)
|
||||
{
|
||||
if (!tex.has_color() || amount <= 0.f)
|
||||
return;
|
||||
const size_t n = size_t(tex.width) * size_t(tex.height);
|
||||
// A small radius, scaled with the image so a 4k picture is not sharpened a tenth as hard as a
|
||||
// 512 one. Deliberately not tied to `amount`: that is how much, this is how wide.
|
||||
const int radius = std::max(1, int(std::lround(double(std::min(tex.width, tex.height)) / 512.0)));
|
||||
const float k = std::clamp(amount, 0.f, 1.f) * 1.5f;
|
||||
|
||||
std::vector<uint8_t> plane(n);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
plane[i] = tex.rgb[i * 3 + size_t(c)];
|
||||
smooth_height_pixels_box(plane, tex.width, tex.height, radius);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const float sharp = float(tex.rgb[i * 3 + size_t(c)]) + k * (float(tex.rgb[i * 3 + size_t(c)]) - float(plane[i]));
|
||||
tex.rgb[i * 3 + size_t(c)] = uint8_t(std::lround(std::clamp(sharp, 0.f, 255.f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reduces the picture to at most `count` colours, by k-means in RGB.
|
||||
//
|
||||
// "At most": two of the chosen colours can still match the same filament, in which case the print
|
||||
// uses fewer. The guarantee runs the right way round, which is the one that matters - asking for
|
||||
// three colours cannot produce four.
|
||||
//
|
||||
// k-means rather than a fixed palette or an even split of the luma range, because the useful
|
||||
// question is which colours this picture is actually made of. A tartan asks for its own handful of
|
||||
// greens and reds; posterizing the range would hand it eight shades of mud instead. The clustering
|
||||
// runs over a coarse histogram, not over the pixels: at 1024 x 1024 that is 4096 weighted points
|
||||
// instead of a million, which is what makes it cheap enough to sit on a slider.
|
||||
void reduce_colors(DecodedHeightTexture &tex, int count)
|
||||
{
|
||||
if (!tex.has_color() || count < 2 || count > 64)
|
||||
return;
|
||||
const size_t n = size_t(tex.width) * size_t(tex.height);
|
||||
|
||||
// 16 levels per channel, each bin keeping the mean of what fell in it, so a colour is not
|
||||
// snapped to a bin corner before the clustering has had a say.
|
||||
struct Bin { double r = 0, g = 0, b = 0; uint32_t n = 0; };
|
||||
std::vector<Bin> bins(size_t(16 * 16 * 16));
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const uint8_t r = tex.rgb[i * 3], g = tex.rgb[i * 3 + 1], b = tex.rgb[i * 3 + 2];
|
||||
Bin &bin = bins[(size_t(r >> 4) << 8) | (size_t(g >> 4) << 4) | size_t(b >> 4)];
|
||||
bin.r += r; bin.g += g; bin.b += b; ++bin.n;
|
||||
}
|
||||
struct Point { float c[3]; double w; };
|
||||
std::vector<Point> pts;
|
||||
pts.reserve(bins.size());
|
||||
for (const Bin &bin : bins)
|
||||
if (bin.n > 0)
|
||||
pts.push_back({ { float(bin.r / bin.n), float(bin.g / bin.n), float(bin.b / bin.n) }, double(bin.n) });
|
||||
if (pts.size() <= size_t(count))
|
||||
return; // already no more colours than asked for
|
||||
|
||||
// Seeded the k-means++ way, deterministically: the heaviest point first, then each next one is
|
||||
// whichever point is furthest from everything chosen so far, weighted by how much of the image
|
||||
// it is. Picking the N fullest bins instead would put every centre inside the one dominant
|
||||
// colour of a picture that has one.
|
||||
std::vector<std::array<float, 3>> centre;
|
||||
{
|
||||
size_t best = 0;
|
||||
for (size_t i = 1; i < pts.size(); ++i)
|
||||
if (pts[i].w > pts[best].w)
|
||||
best = i;
|
||||
centre.push_back({ pts[best].c[0], pts[best].c[1], pts[best].c[2] });
|
||||
std::vector<double> d2(pts.size(), std::numeric_limits<double>::max());
|
||||
while (int(centre.size()) < count) {
|
||||
const auto &last = centre.back();
|
||||
double top = -1.0;
|
||||
size_t pick = 0;
|
||||
for (size_t i = 0; i < pts.size(); ++i) {
|
||||
const double dr = pts[i].c[0] - last[0], dg = pts[i].c[1] - last[1], db = pts[i].c[2] - last[2];
|
||||
d2[i] = std::min(d2[i], dr * dr + dg * dg + db * db);
|
||||
if (const double score = d2[i] * pts[i].w; score > top) {
|
||||
top = score;
|
||||
pick = i;
|
||||
}
|
||||
}
|
||||
if (top <= 0.0)
|
||||
break; // fewer distinct colours than asked for
|
||||
centre.push_back({ pts[pick].c[0], pts[pick].c[1], pts[pick].c[2] });
|
||||
}
|
||||
}
|
||||
|
||||
// Lloyd's algorithm over the weighted bins. Twelve passes: on a histogram this coarse it has
|
||||
// stopped moving well before that, and a fixed count keeps the slider's cost predictable.
|
||||
const int k = int(centre.size());
|
||||
for (int iter = 0; iter < 12; ++iter) {
|
||||
std::vector<std::array<double, 3>> sum(size_t(k), { 0.0, 0.0, 0.0 });
|
||||
std::vector<double> wsum(size_t(k), 0.0);
|
||||
for (const Point &p : pts) {
|
||||
int best = 0;
|
||||
double bd = std::numeric_limits<double>::max();
|
||||
for (int c = 0; c < k; ++c) {
|
||||
const double dr = p.c[0] - centre[size_t(c)][0], dg = p.c[1] - centre[size_t(c)][1],
|
||||
db = p.c[2] - centre[size_t(c)][2];
|
||||
if (const double d = dr * dr + dg * dg + db * db; d < bd) { bd = d; best = c; }
|
||||
}
|
||||
for (int ch = 0; ch < 3; ++ch)
|
||||
sum[size_t(best)][size_t(ch)] += double(p.c[ch]) * p.w;
|
||||
wsum[size_t(best)] += p.w;
|
||||
}
|
||||
for (int c = 0; c < k; ++c)
|
||||
if (wsum[size_t(c)] > 0.0)
|
||||
for (int ch = 0; ch < 3; ++ch)
|
||||
centre[size_t(c)][size_t(ch)] = float(sum[size_t(c)][size_t(ch)] / wsum[size_t(c)]);
|
||||
}
|
||||
|
||||
// Every texel takes its nearest centre. Parallel by rows, as the tonal pass is.
|
||||
const size_t rows = size_t(tex.height), cols = size_t(tex.width);
|
||||
tbb::parallel_for(tbb::blocked_range<size_t>(0, rows), [&](const tbb::blocked_range<size_t> &range) {
|
||||
for (size_t y = range.begin(); y < range.end(); ++y)
|
||||
for (size_t x = 0; x < cols; ++x) {
|
||||
uint8_t *px = tex.rgb.data() + (y * cols + x) * 3;
|
||||
int best = 0;
|
||||
float bd = std::numeric_limits<float>::max();
|
||||
for (int c = 0; c < k; ++c) {
|
||||
const float dr = float(px[0]) - centre[size_t(c)][0], dg = float(px[1]) - centre[size_t(c)][1],
|
||||
db = float(px[2]) - centre[size_t(c)][2];
|
||||
if (const float d = dr * dr + dg * dg + db * db; d < bd) { bd = d; best = c; }
|
||||
}
|
||||
for (int ch = 0; ch < 3; ++ch)
|
||||
px[size_t(ch)] = uint8_t(std::lround(std::clamp(centre[size_t(best)][size_t(ch)], 0.f, 255.f)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keys one colour out, so a logo on a white card prints as the logo and the object's own colour
|
||||
// shows through the rest.
|
||||
//
|
||||
// The corners are the default because a background that does not reach all four of them is not a
|
||||
// background, and that makes the common case one click; the median of the four is taken so a single
|
||||
// stray corner cannot decide it. The panel's dropper and swatch override it when the picture needs
|
||||
// something else keyed out.
|
||||
void remove_background(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer)
|
||||
{
|
||||
if (!tex.has_color() || !layer.bg_remove || tex.width < 2 || tex.height < 2)
|
||||
return;
|
||||
const size_t n = size_t(tex.width) * size_t(tex.height);
|
||||
float key[3];
|
||||
if (layer.bg_from_corners) {
|
||||
const size_t corner[4] = { 0, size_t(tex.width) - 1, (size_t(tex.height) - 1) * size_t(tex.width),
|
||||
n - 1 };
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
std::array<float, 4> v{};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
v[size_t(i)] = float(tex.rgb[corner[i] * 3 + size_t(c)]);
|
||||
std::sort(v.begin(), v.end());
|
||||
key[c] = 0.5f * (v[1] + v[2]);
|
||||
}
|
||||
} else {
|
||||
for (int c = 0; c < 3; ++c)
|
||||
key[c] = std::clamp(layer.bg_color[c], 0.f, 1.f) * 255.f;
|
||||
}
|
||||
// Euclidean in 0..255 RGB, against a tolerance scaled to the diagonal of that cube, so the
|
||||
// slider reads as "how different from the background still counts as background".
|
||||
const float limit = std::clamp(layer.bg_tolerance, 0.f, 1.f) * 441.67f;
|
||||
if (!tex.has_alpha())
|
||||
tex.alpha.assign(n, 255);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const float dr = float(tex.rgb[i * 3]) - key[0];
|
||||
const float dg = float(tex.rgb[i * 3 + 1]) - key[1];
|
||||
const float db = float(tex.rgb[i * 3 + 2]) - key[2];
|
||||
if (std::sqrt(dr * dr + dg * dg + db * db) <= limit)
|
||||
tex.alpha[i] = 0;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool has_color_adjustments(const TextureDisplacementLayer &layer)
|
||||
{
|
||||
return layer.brightness != 0.f || layer.contrast != 0.f || layer.saturation != 1.f ||
|
||||
layer.gamma != 1.f || layer.color_invert || layer.hue != 0.f || layer.sharpness > 0.f ||
|
||||
layer.color_count >= 2 || layer.bg_remove;
|
||||
}
|
||||
|
||||
void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer)
|
||||
{
|
||||
if (!tex.has_color() || !has_color_adjustments(layer))
|
||||
return;
|
||||
|
||||
// A contrast of -1 collapses the image to mid grey and +1 doubles the slope about it, which is
|
||||
// the range a single slider can offer without a scale nobody can read off the handle.
|
||||
const float slope = 1.f + std::clamp(layer.contrast, -1.f, 1.f);
|
||||
const float bright = std::clamp(layer.brightness, -1.f, 1.f);
|
||||
const float sat = std::max(0.f, layer.saturation);
|
||||
const float inv_g = 1.f / std::max(0.01f, layer.gamma);
|
||||
const bool negate = layer.color_invert;
|
||||
// Hue is a rotation of the colour about the grey axis. Rodrigues' formula about (1,1,1)/sqrt(3),
|
||||
// then the luma is put back exactly (the rotation preserves R+G+B, not the Rec. 709 weighting),
|
||||
// so turning the wheel changes which filament an area matches without also changing how light
|
||||
// it is.
|
||||
const float hue_rad = layer.hue * float(M_PI) / 180.f;
|
||||
const float hue_cos = std::cos(hue_rad), hue_sin = std::sin(hue_rad);
|
||||
const bool rotate = layer.hue != 0.f;
|
||||
constexpr float LW[3] = { 0.2126f, 0.7152f, 0.0722f }; // Rec. 709 luma
|
||||
|
||||
// Each output byte depends only on its own pixel, so the rows are independent. At 1024 x 1024
|
||||
// this runs on every preview rebuild and every bake, which is what makes it worth splitting.
|
||||
const size_t rows = size_t(tex.height);
|
||||
const size_t cols = size_t(tex.width);
|
||||
tbb::parallel_for(tbb::blocked_range<size_t>(0, rows), [&](const tbb::blocked_range<size_t> &range) {
|
||||
for (size_t y = range.begin(); y < range.end(); ++y) {
|
||||
uint8_t *row = tex.rgb.data() + y * cols * 3;
|
||||
for (size_t x = 0; x < cols; ++x) {
|
||||
uint8_t *px = row + x * 3;
|
||||
float c[3];
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
float v = float(px[k]) * (1.f / 255.f);
|
||||
v = std::pow(v, inv_g);
|
||||
v = (v - 0.5f) * slope + 0.5f;
|
||||
v += bright;
|
||||
c[k] = v;
|
||||
}
|
||||
if (rotate) {
|
||||
const float luma = LW[0] * c[0] + LW[1] * c[1] + LW[2] * c[2];
|
||||
// The component of the colour across the grey axis, which is what a hue is.
|
||||
const float mean = (c[0] + c[1] + c[2]) / 3.f;
|
||||
const float p[3] = { c[0] - mean, c[1] - mean, c[2] - mean };
|
||||
// k x p for the unit grey axis k = (1,1,1)/sqrt(3).
|
||||
constexpr float INV_SQRT3 = 0.57735027f;
|
||||
const float x[3] = { INV_SQRT3 * (p[1] - p[2]), INV_SQRT3 * (p[2] - p[0]),
|
||||
INV_SQRT3 * (p[0] - p[1]) };
|
||||
for (int k = 0; k < 3; ++k)
|
||||
c[k] = mean + p[k] * hue_cos + x[k] * hue_sin;
|
||||
const float luma_now = LW[0] * c[0] + LW[1] * c[1] + LW[2] * c[2];
|
||||
for (float &v : c)
|
||||
v += luma - luma_now;
|
||||
}
|
||||
if (sat != 1.f) {
|
||||
// Rec. 709 luma, the same weights the rest of the colour path uses to decide
|
||||
// what counts as light and dark.
|
||||
const float luma = LW[0] * c[0] + LW[1] * c[1] + LW[2] * c[2];
|
||||
for (float &v : c)
|
||||
v = luma + (v - luma) * sat;
|
||||
}
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
float v = std::clamp(c[k], 0.f, 1.f);
|
||||
if (negate)
|
||||
v = 1.f - v;
|
||||
px[k] = uint8_t(std::lround(v * 255.f));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sharpen, threshold and the background key are all passes over the whole image rather than
|
||||
// per-texel arithmetic, so they come after the loop above and in this order: sharpening a
|
||||
// thresholded image would only ring along the one edge it has left, and the key has to read the
|
||||
// colours the user is actually looking at.
|
||||
sharpen_rgb(tex, layer.sharpness);
|
||||
reduce_colors(tex, layer.color_count);
|
||||
remove_background(tex, layer);
|
||||
}
|
||||
|
||||
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer)
|
||||
{
|
||||
DecodedHeightTexture result;
|
||||
@@ -593,10 +334,6 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
result.height = h;
|
||||
result.pixels.resize(size_t(w) * size_t(h));
|
||||
result.rgb.resize(size_t(w) * size_t(h) * 3);
|
||||
// A fourth channel is coverage, and it is the only thing that can say "the picture is
|
||||
// not here" as opposed to "the picture is black here". Kept only when the source has it.
|
||||
if (bpp >= 4)
|
||||
result.alpha.resize(size_t(w) * size_t(h));
|
||||
// decode_colored_png() hands its buffer back bottom-up - it is shared with the CLI's
|
||||
// plate-thumbnail loader, which expects that - while this type, and decode_png()'s
|
||||
// grayscale path above, are top-to-bottom. Reverse the rows on the way in so a colour
|
||||
@@ -610,8 +347,6 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
result.rgb[dst * 3 + 1] = g;
|
||||
result.rgb[dst * 3 + 2] = b;
|
||||
result.pixels[dst] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b));
|
||||
if (bpp >= 4)
|
||||
result.alpha[dst] = src[3];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,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
|
||||
@@ -634,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));
|
||||
@@ -658,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;
|
||||
@@ -672,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;
|
||||
@@ -2145,14 +2110,10 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
|
||||
// Outside a non-tiled placement there is no texture at all - the same hard edge sample() gives the
|
||||
// height. Checked explicitly because sample_color() reports it as black, which is a real colour.
|
||||
auto covered = [&](const Vec2f &planar) {
|
||||
if (layer.tile_enabled)
|
||||
return true;
|
||||
const Vec2f uv = xf(planar);
|
||||
if (!layer.tile_enabled && !(uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f))
|
||||
return false;
|
||||
// Where the picture is transparent there is no colour to print, which is the same answer as
|
||||
// being outside the placement - not black, which is a colour. Half coverage counts as
|
||||
// covered: a triangle prints in one filament either way, so the only useful question is
|
||||
// which side of the edge it falls on.
|
||||
return texture.sample_alpha(uv, layer.tile_enabled, layer.tile_method) >= 0.5f;
|
||||
return uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f;
|
||||
};
|
||||
|
||||
if (lscm_uv != nullptr) {
|
||||
@@ -3531,13 +3492,9 @@ std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
|
||||
const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)];
|
||||
if (data.triangles_to_split.empty())
|
||||
continue;
|
||||
DecodedHeightTexture tex = decode_height_texture(*layer);
|
||||
const DecodedHeightTexture tex = decode_height_texture(*layer);
|
||||
if (tex.empty() || (need_color && !tex.has_color()))
|
||||
continue;
|
||||
// The one place the bake and every preview job read a layer's colour from, so correcting it
|
||||
// here is what keeps what the panel shows and what the bake prints the same image.
|
||||
if (need_color)
|
||||
apply_color_adjustments(tex, *layer);
|
||||
|
||||
TriangleSelector selector(selector_mesh);
|
||||
selector.deserialize(data, false);
|
||||
|
||||
@@ -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
|
||||
@@ -277,6 +309,18 @@ struct TextureDisplacementLayer
|
||||
// - zero or positive: a mesh vertex, every unwrapped copy of it (how edits were stored before).
|
||||
std::vector<std::pair<int, Vec2f>> 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).
|
||||
@@ -293,32 +337,6 @@ struct TextureDisplacementLayer
|
||||
// for the boundary to be drawn on.
|
||||
bool color_enabled = false;
|
||||
|
||||
// Colour correction, applied to the image's RGB before it is matched against the filaments and
|
||||
// before the preview quantizes it. Never to the height: the relief is what the grayscale says,
|
||||
// and a brightness slider has no business moving geometry. The defaults below are the identity,
|
||||
// so a layer that never meets the Image tool behaves exactly as it did.
|
||||
float brightness = 0.f; // -1 .. 1, added last, after contrast
|
||||
float contrast = 0.f; // -1 .. 1, pivoted on mid grey; -1 flattens to grey, 1 doubles the slope
|
||||
float hue = 0.f; // -180 .. 180 degrees, rotated about the grey axis at constant luma
|
||||
float sharpness = 0.f; // 0 .. 1 unsharp mask amount, 0 is off
|
||||
// How many colours the picture is allowed to use, 0 for as many as it likes. Reduced by k-means
|
||||
// in RGB before the filaments are matched, so the result uses at most this many of them: 2 for a
|
||||
// logo that should be two flat colours, 4 or 5 for a poster, off for a photograph.
|
||||
int color_count = 0; // 0, or 2 .. 12
|
||||
// Leaves the background of a picture unprinted, so the object's own colour shows through instead
|
||||
// of a slab of white behind a logo. Texels the source image marked transparent always count;
|
||||
// `bg_remove` additionally keys out one colour.
|
||||
bool bg_remove = false;
|
||||
float bg_tolerance = 0.15f; // 0 .. 1, distance from the keyed colour that still counts as background
|
||||
// The colour keyed out. Taken from the median of the picture's four corners while
|
||||
// `bg_from_corners` is set, which is right for nearly every logo on a flat card; the panel's
|
||||
// dropper and swatch clear it and put a colour of the user's own here.
|
||||
bool bg_from_corners = true;
|
||||
float bg_color[3] = { 1.f, 1.f, 1.f };
|
||||
float saturation = 1.f; // 0 .. 2, 1 leaves it alone, 0 is grayscale
|
||||
float gamma = 1.f; // 0.2 .. 5, 1 leaves it alone; above 1 lifts the midtones
|
||||
bool color_invert = false; // negative of the colour, as opposed to `invert` which flips relief
|
||||
|
||||
bool empty() const { return !image_data || image_data->empty(); }
|
||||
|
||||
template<class Archive> void save(Archive &ar) const
|
||||
@@ -328,9 +346,7 @@ struct TextureDisplacementLayer
|
||||
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
|
||||
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
|
||||
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
|
||||
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, brightness,
|
||||
contrast, saturation, gamma, color_invert, hue, sharpness, color_count, bg_remove,
|
||||
bg_tolerance, bg_from_corners, bg_color[0], bg_color[1], bg_color[2]);
|
||||
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled);
|
||||
}
|
||||
template<class Archive> void load(Archive &ar)
|
||||
{
|
||||
@@ -342,8 +358,7 @@ struct TextureDisplacementLayer
|
||||
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
|
||||
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
|
||||
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
|
||||
view_project_matrix, color_enabled, brightness, contrast, saturation, gamma, color_invert, hue, sharpness,
|
||||
color_count, bg_remove, bg_tolerance, bg_from_corners, bg_color[0], bg_color[1], bg_color[2]);
|
||||
view_project_matrix, color_enabled);
|
||||
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
|
||||
tile_method = static_cast<TextureTileMethod>(tile_method_int);
|
||||
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
|
||||
@@ -477,19 +492,11 @@ struct DecodedHeightTexture
|
||||
// has_color() is what the whole colour feature keys off: a layer set to colour a model with a
|
||||
// grayscale texture on it simply colours nothing.
|
||||
std::vector<uint8_t> rgb;
|
||||
// Coverage: 0 where the picture is not there at all, 255 where it is fully there. Empty when the
|
||||
// source image had no alpha and nothing has keyed a background out, which is the common case and
|
||||
// means "opaque everywhere". Distinct from a black texel, which is a colour.
|
||||
std::vector<uint8_t> alpha;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool empty() const { return width <= 0 || height <= 0 || pixels.empty(); }
|
||||
bool has_color() const { return !empty() && rgb.size() == size_t(width) * size_t(height) * 3; }
|
||||
bool has_alpha() const { return !empty() && alpha.size() == size_t(width) * size_t(height); }
|
||||
// Coverage at a uv, bilinear, in [0, 1]. 1 everywhere when there is no alpha plane.
|
||||
float sample_alpha(const Vec2f &uv, bool tile_enabled = true,
|
||||
TextureTileMethod tile_method = TextureTileMethod::Repeat) const;
|
||||
// Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is false,
|
||||
// a uv outside [0, 1) samples as 0 - the texture simply is not there, rather than its border
|
||||
// row/column being smeared outward forever (which is what clamping the coordinate would do, and
|
||||
@@ -514,6 +521,36 @@ struct DecodedHeightTexture
|
||||
bool texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &out) const;
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
@@ -526,19 +563,6 @@ 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);
|
||||
|
||||
// True when `layer`'s colour correction is anything other than the identity, so callers can skip
|
||||
// both the work and the cache invalidation it would otherwise force.
|
||||
bool has_color_adjustments(const TextureDisplacementLayer &layer);
|
||||
|
||||
// Applies that correction to an already decoded image, in place, to `rgb` only. Order is gamma,
|
||||
// contrast, brightness, saturation, invert, each clamped back into range - the order a photo editor
|
||||
// applies them in, and the one that keeps contrast pivoted on mid grey rather than on a brightness
|
||||
// the slider has already moved.
|
||||
//
|
||||
// Deliberately not folded into decode_height_texture(): that result is cached per image, shared by
|
||||
// every layer using the same file, and keyed on the image bytes alone.
|
||||
void apply_color_adjustments(DecodedHeightTexture &tex, 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
|
||||
|
||||
@@ -186,8 +186,6 @@ set(SLIC3R_GUI_SOURCES
|
||||
GUI/Gizmos/GLGizmoFlatten.hpp
|
||||
GUI/Gizmos/GLGizmoFuzzySkin.cpp
|
||||
GUI/Gizmos/GLGizmoFuzzySkin.hpp
|
||||
GUI/Gizmos/GLGizmoImage.cpp
|
||||
GUI/Gizmos/GLGizmoImage.hpp
|
||||
GUI/Gizmos/GLGizmoMeasure.cpp
|
||||
GUI/Gizmos/GLGizmoMeasure.hpp
|
||||
GUI/Gizmos/GLGizmoMeshBoolean.cpp
|
||||
|
||||
@@ -1,891 +0,0 @@
|
||||
#include "GLGizmoImage.hpp"
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/format.hpp"
|
||||
|
||||
#include "slic3r/GUI/Camera.hpp"
|
||||
#include "slic3r/GUI/GLCanvas3D.hpp"
|
||||
#include "slic3r/GUI/GLTexture.hpp"
|
||||
#include "slic3r/GUI/GUI.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/IconManager.hpp"
|
||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||
#include "slic3r/GUI/Plater.hpp"
|
||||
#include "slic3r/GUI/TextureLibrary.hpp"
|
||||
#include "slic3r/GUI/Shortcuts.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoUtils.hpp"
|
||||
#include "slic3r/GUI/Jobs/Worker.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <imgui.h>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <wx/string.h>
|
||||
|
||||
namespace Slic3r::GUI {
|
||||
|
||||
GLGizmoImage::GLGizmoImage(GLCanvas3D &parent, const std::string &icon_filename, unsigned int sprite_id)
|
||||
: GLGizmoTextureDisplacement(parent, icon_filename, sprite_id)
|
||||
{
|
||||
}
|
||||
|
||||
bool GLGizmoImage::on_init()
|
||||
{
|
||||
if (!GLGizmoTextureDisplacement::on_init())
|
||||
return false;
|
||||
// Everything the base set up stays - it is the same painting machinery - except the key that
|
||||
// opens it, which is this tool's own.
|
||||
m_shortcut = Shortcut::GizmoImage;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string GLGizmoImage::on_get_name() const
|
||||
{
|
||||
return _u8L("Image");
|
||||
}
|
||||
|
||||
wxString GLGizmoImage::handle_snapshot_action_name(bool shift_down, GLGizmoPainterBase::Button button_down) const
|
||||
{
|
||||
// Same paint, same masks; only the undo step's wording follows the tool the user is in.
|
||||
const bool erasing = shift_down || (m_erase_mode != (button_down == GLGizmoPainterBase::Button::Right));
|
||||
return erasing ? _L("Remove image from the area") : _L("Place image");
|
||||
}
|
||||
|
||||
bool GLGizmoTextureDisplacement::pick_background_at(const Vec2d &mouse_pos)
|
||||
{
|
||||
ModelVolume *mv = texture_volume();
|
||||
ModelObject *mo = m_c->selection_info()->model_object();
|
||||
TextureDisplacementLayer *layer = mv != nullptr ? active_layer() : nullptr;
|
||||
if (mv == nullptr || mo == nullptr || layer == nullptr || layer->empty())
|
||||
return false;
|
||||
|
||||
const int idx = texture_volume_raycaster_index();
|
||||
if (idx < 0)
|
||||
return false;
|
||||
const Selection &selection = m_parent.get_selection();
|
||||
const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
|
||||
const Camera &camera = wxGetApp().plater()->get_camera();
|
||||
|
||||
Vec3f hit = Vec3f::Zero(), normal = Vec3f::Zero();
|
||||
if (!m_c->raycaster()->raycasters()[size_t(idx)]->unproject_on_mesh(
|
||||
mouse_pos, trafo, camera, hit, normal,
|
||||
m_c->object_clipper()->get_clipping_plane()))
|
||||
return false;
|
||||
|
||||
// The picture's own colour at that point, read through the projection the bake uses, rather
|
||||
// than the pixel on screen: the screen pixel carries the lighting and the filament the colour
|
||||
// was already quantized to, so keying on it would remove the wrong thing.
|
||||
//
|
||||
// Uncorrected, deliberately. The key is applied at the end of the correction chain, against
|
||||
// colours the earlier passes have already moved, so it has to be stated in those terms - the
|
||||
// same reason the panel's dropper reads the corrected preview.
|
||||
const indexed_triangle_set &base = mv->mesh().its;
|
||||
const indexed_triangle_set patch =
|
||||
extract_painted_patch(base, mv->texture_displacement_facet(layer->slot).get_data());
|
||||
Vec3f center = Vec3f::Zero(), axis = Vec3f::UnitZ(), average_normal = Vec3f::UnitZ();
|
||||
if (!patch.indices.empty())
|
||||
texture_displacement_patch_frame(patch, texture_displacement_vertex_normals(base), center, axis,
|
||||
average_normal);
|
||||
|
||||
DecodedHeightTexture tex = decode_height_texture(*layer);
|
||||
apply_color_adjustments(tex, *layer);
|
||||
Vec3f rgb;
|
||||
if (!sample_layer_color(tex, *layer, hit, normal, rgb, center, axis))
|
||||
return false;
|
||||
|
||||
for (int c = 0; c < 3; ++c)
|
||||
layer->bg_color[c] = std::clamp(rgb[c], 0.f, 1.f);
|
||||
layer->bg_from_corners = false;
|
||||
layer->bg_remove = true;
|
||||
m_image_bg_picking = false;
|
||||
m_preview_params_dirty = true;
|
||||
m_parent.set_as_dirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
void GLGizmoTextureDisplacement::set_file_drag_over(bool over)
|
||||
{
|
||||
if (!image_mode() || m_file_drag_over == over)
|
||||
return;
|
||||
m_file_drag_over = over;
|
||||
// A drag is not a mouse move as far as the canvas is concerned, so nothing would repaint and the
|
||||
// zone would appear only once something else happened to trigger a frame.
|
||||
m_parent.set_as_dirty();
|
||||
m_parent.request_extra_frame();
|
||||
}
|
||||
|
||||
// Drawn over the whole canvas on the foreground list, from inside the panel's ImGui frame, which is
|
||||
// what puts it above the 3D view and above the panel itself.
|
||||
void GLGizmoTextureDisplacement::render_image_drop_hint()
|
||||
{
|
||||
if (!m_file_drag_over)
|
||||
return;
|
||||
|
||||
const ImVec2 size = ImGui::GetIO().DisplaySize;
|
||||
if (size.x < 1.f || size.y < 1.f)
|
||||
return;
|
||||
ImDrawList *dl = ImGui::GetForegroundDrawList();
|
||||
const ImU32 teal = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA);
|
||||
|
||||
// Dim the view so the zone reads as a state the canvas is in, not as decoration on top of it.
|
||||
dl->AddRectFilled(ImVec2(0.f, 0.f), size, IM_COL32(0, 0, 0, 70));
|
||||
|
||||
const float inset = std::round(ImGui::GetFontSize() * 1.2f);
|
||||
const ImVec2 a(inset, inset), b(size.x - inset, size.y - inset);
|
||||
if (b.x <= a.x || b.y <= a.y)
|
||||
return;
|
||||
|
||||
// A dashed border, drawn as runs along the four edges: ImGui 1.83 has no dashed stroke.
|
||||
const float dash = std::round(ImGui::GetFontSize() * 0.75f);
|
||||
const float gap = dash;
|
||||
const float th = std::max(2.f, std::round(ImGui::GetFontSize() * 0.12f));
|
||||
for (float x = a.x; x < b.x; x += dash + gap) {
|
||||
const float x1 = std::min(x + dash, b.x);
|
||||
dl->AddLine(ImVec2(x, a.y), ImVec2(x1, a.y), teal, th);
|
||||
dl->AddLine(ImVec2(x, b.y), ImVec2(x1, b.y), teal, th);
|
||||
}
|
||||
for (float y = a.y; y < b.y; y += dash + gap) {
|
||||
const float y1 = std::min(y + dash, b.y);
|
||||
dl->AddLine(ImVec2(a.x, y), ImVec2(a.x, y1), teal, th);
|
||||
dl->AddLine(ImVec2(b.x, y), ImVec2(b.x, y1), teal, th);
|
||||
}
|
||||
|
||||
// What dropping here will do. Worded as an invitation rather than a promise: anything that is
|
||||
// not a picture falls through to the model loader, exactly as it did before this tool existed.
|
||||
const std::string text = _u8L("Drop a picture to put it on the model");
|
||||
const ImVec2 ts = ImGui::CalcTextSize(text.c_str());
|
||||
const float pad = std::round(ImGui::GetFontSize() * 0.8f);
|
||||
const ImVec2 c((a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f);
|
||||
const ImVec2 r0(c.x - ts.x * 0.5f - pad, c.y - ts.y * 0.5f - pad * 0.6f);
|
||||
const ImVec2 r1(c.x + ts.x * 0.5f + pad, c.y + ts.y * 0.5f + pad * 0.6f);
|
||||
dl->AddRectFilled(r0, r1, ImGui::GetColorU32(ImGuiCol_WindowBg, 0.92f), ImGui::GetStyle().FrameRounding);
|
||||
dl->AddRect(r0, r1, teal, ImGui::GetStyle().FrameRounding, 0, th * 0.5f);
|
||||
dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - ts.y * 0.5f), ImGui::GetColorU32(ImGuiCol_Text), text.c_str());
|
||||
}
|
||||
|
||||
bool GLGizmoTextureDisplacement::drop_image_file(const std::string &path)
|
||||
{
|
||||
if (!image_mode() || texture_volume() == nullptr)
|
||||
return false;
|
||||
|
||||
// Imported rather than just read: the same thing the panel's Import tile does, so a dropped
|
||||
// picture lands in "My images" and is there the next time without hunting for the file again.
|
||||
std::string error;
|
||||
const std::optional<TextureLibraryEntry> entry = import_texture_to_library(path, error);
|
||||
if (!entry) {
|
||||
show_error(nullptr, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The tool starts with no layer at all, so a drop before anything is loaded has to make one.
|
||||
if (active_layer() == nullptr)
|
||||
add_texture_layer();
|
||||
TextureDisplacementLayer *layer = active_layer();
|
||||
if (layer == nullptr)
|
||||
return false;
|
||||
set_layer_texture(*layer, *entry);
|
||||
m_parent.set_as_dirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The Image tool's window.
|
||||
//
|
||||
// A member of GLGizmoTextureDisplacement rather than of GLGizmoImage, and defined here rather than
|
||||
// in that class's own file: it needs the same private machinery the displacement panel uses (the
|
||||
// layer, the texture picker, the previews, the bake), and keeping it here leaves that 1700 line
|
||||
// panel untouched instead of threading it with tests for which tool is drawing.
|
||||
//
|
||||
// What it deliberately leaves out, all of which only means something once the surface moves or once
|
||||
// there is more than one picture: brushes and the erase toggle, the layer stack, blend modes, depth,
|
||||
// midlevel, edge fade, tiling mode, the unwrap projection and its UV editor, seams, the wireframe
|
||||
// and the mesh-preparation sections.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
void GLGizmoTextureDisplacement::render_image_input_window(float x, float y, float bottom_limit)
|
||||
{
|
||||
ModelObject *mo = m_c->selection_info()->model_object();
|
||||
if (mo == nullptr)
|
||||
return;
|
||||
ModelVolume *mv = texture_volume();
|
||||
|
||||
// The palette follows project state nobody tells this gizmo about (the filaments in the
|
||||
// sidebar), so it is checked once a frame and a change re-runs the preview.
|
||||
cached_palette();
|
||||
if (m_palette_changed)
|
||||
m_preview_params_dirty = true;
|
||||
|
||||
// Everything this tool fixes, re-pinned each frame so what the preview shows is what Apply will
|
||||
// produce: Standard's recipe, zero depth with colour on, a click that floods the connected
|
||||
// surface rather than a brush, and the fast preview, which with no relief to build is both
|
||||
// instant and exactly the final colour.
|
||||
// Two statements, not one `||`: short-circuiting would skip the image presets on any frame the
|
||||
// standard ones changed something.
|
||||
const bool std_changed = apply_standard_mode_presets(mv);
|
||||
const bool image_changed = apply_image_mode_presets(mv);
|
||||
if (std_changed || image_changed)
|
||||
m_preview_params_dirty = true;
|
||||
m_tool_type = ToolType::SMART_FILL;
|
||||
m_cursor_type = TriangleSelector::CursorType::POINTER;
|
||||
m_erase_mode = false;
|
||||
m_auto_update = true;
|
||||
if (!m_use_shaded_preview)
|
||||
apply_view_mode(1);
|
||||
|
||||
float scale = m_parent.get_scale();
|
||||
#ifdef WIN32
|
||||
const int dpi = get_dpi_for_window(wxGetApp().GetTopWindow());
|
||||
scale *= (float) dpi / (float) DPI_DEFAULT;
|
||||
#endif // WIN32
|
||||
|
||||
const float approx_height = m_imgui->scaled(22.f);
|
||||
y = std::min(y, bottom_limit - approx_height);
|
||||
|
||||
// Docked (the default) it is pinned to the right edge of the 3D canvas and cannot be moved, the
|
||||
// same place and the same rule as the displacement panel, so the two tools do not put their
|
||||
// controls in different corners. Undocked it becomes an ordinary floating window with a title
|
||||
// bar to drag it by; the position is deliberately not seeded on undock, so it stays exactly
|
||||
// where it was and the user simply gains the ability to move it.
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse;
|
||||
if (!m_undocked) {
|
||||
flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar;
|
||||
// Right-aligned (pivot 1), so the width the window auto-resized to last frame does not need
|
||||
// to be known here.
|
||||
float right = float(m_parent.get_canvas_size().get_width()) - m_imgui->scaled(0.5f);
|
||||
GizmoImguiSetNextWIndowPos(right, y, 0.f, 0.f, ImGuiCond_Always, 1.0f, 0.0f);
|
||||
}
|
||||
|
||||
ImGuiWrapper::push_toolbar_style(m_parent.get_scale());
|
||||
GizmoImguiBegin(get_name(), flags);
|
||||
ensure_panel_icons();
|
||||
const float previous_body_h = m_panel_body_h;
|
||||
const float previous_footer_h = m_panel_footer_h;
|
||||
|
||||
const ImGuiStyle &style = ImGui::GetStyle();
|
||||
const bool busy = m_bake_in_progress || m_prepare_in_progress;
|
||||
const float panel_w = m_imgui->scaled(16.f);
|
||||
const float frame_h = ImGui::GetFrameHeight();
|
||||
const float icon_sm = std::round(frame_h * 1.05f);
|
||||
const float gap_s = std::round(m_imgui->scaled(0.4f));
|
||||
const float label_w = m_imgui->scaled(4.6f);
|
||||
const float wrap_w = m_imgui->scaled(15.f);
|
||||
const ImVec4 orca = ImGuiWrapper::COL_ORCA;
|
||||
const ImVec4 col_frame = ImGui::GetStyleColorVec4(ImGuiCol_Separator);
|
||||
|
||||
const auto hover_tip = [&](const auto &text) {
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(text, wrap_w);
|
||||
};
|
||||
// Label column plus a slider filling the rest of the row, the same shape the displacement panel
|
||||
// uses, so the two tools read as one family.
|
||||
const auto float_row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max,
|
||||
const char *format, bool log) -> bool {
|
||||
const float x0 = ImGui::GetCursorPosX();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
m_imgui->text(label);
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
|
||||
ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX());
|
||||
return ImGui::SliderFloat(id, v, v_min, v_max, format,
|
||||
ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0));
|
||||
};
|
||||
// Borderless icon button, for the dock toggle: always the grey monochrome variant.
|
||||
const auto icon_button = [&](int uid, const std::string &iconfile, const wxString &label,
|
||||
const wxString &tip_text) -> bool {
|
||||
const auto it = m_panel_icon_map.find(iconfile);
|
||||
const float pad = std::max(1.f, std::round(icon_sm * 0.14f));
|
||||
bool clicked = false;
|
||||
ImGui::PushID(uid);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.f, 0.f, 0.f, 0.f));
|
||||
if (it != m_panel_icon_map.end() && !it->second.empty() && it->second[0]->is_valid()) {
|
||||
const IconManager::Icon &ic = *it->second[0];
|
||||
clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id,
|
||||
ImVec2(icon_sm - 2.f * pad, icon_sm - 2.f * pad), ic.tl, ic.br, int(pad));
|
||||
} else {
|
||||
clicked = ImGui::Button(label.ToUTF8().data(), ImVec2(0.f, icon_sm));
|
||||
}
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopID();
|
||||
hover_tip(tip_text);
|
||||
return clicked;
|
||||
};
|
||||
// A section header that folds its contents away. ImGui's own CollapsingHeader draws a filled bar
|
||||
// that is louder than anything else in this window, so this is the same triangle-and-text the
|
||||
// displacement panel's "More settings" link uses, at heading weight.
|
||||
const auto section = [&](const char *id, const wxString &label, bool &open) {
|
||||
const float arrow = std::round(ImGui::GetFontSize() * 0.55f);
|
||||
const ImVec2 ts = ImGui::CalcTextSize(label.ToUTF8().data());
|
||||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
ImGui::PushID(id);
|
||||
if (ImGui::InvisibleButton("##sec", ImVec2(panel_w, ts.y + gap_s)))
|
||||
open = !open;
|
||||
const bool hot = ImGui::IsItemHovered();
|
||||
ImDrawList *dl = ImGui::GetWindowDrawList();
|
||||
const ImU32 col = ImGui::GetColorU32(hot ? ImGuiCol_Text : ImGuiCol_TextDisabled);
|
||||
const float cx = p.x + 0.5f * arrow, cy = p.y + 0.5f * ts.y, hs = 0.32f * arrow;
|
||||
if (open)
|
||||
dl->AddTriangleFilled(ImVec2(cx - hs, cy - 0.5f * hs), ImVec2(cx + hs, cy - 0.5f * hs),
|
||||
ImVec2(cx, cy + 0.5f * hs), col);
|
||||
else
|
||||
dl->AddTriangleFilled(ImVec2(cx - 0.5f * hs, cy - hs), ImVec2(cx - 0.5f * hs, cy + hs),
|
||||
ImVec2(cx + 0.5f * hs, cy), col);
|
||||
dl->AddText(ImVec2(p.x + arrow + gap_s, p.y), col, label.ToUTF8().data());
|
||||
ImGui::PopID();
|
||||
return open;
|
||||
};
|
||||
|
||||
// The integer twin of float_row, for the colour count.
|
||||
const auto int_row = [&](const char *id, const wxString &label, int *v, int v_min, int v_max,
|
||||
const char *format) -> bool {
|
||||
const float x0 = ImGui::GetCursorPosX();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
m_imgui->text(label);
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
|
||||
ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX());
|
||||
return ImGui::SliderInt(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp);
|
||||
};
|
||||
// Framed icon toggle, the displacement panel's one cut down to what this window needs.
|
||||
const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, const wxString &label,
|
||||
const wxString &tip_text) -> bool {
|
||||
const auto it = m_panel_icon_map.find(iconfile);
|
||||
const float pad = std::max(1.f, std::round(icon_sm * 0.14f));
|
||||
bool clicked = false;
|
||||
ImGui::PushID(uid);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, active ? ImVec4(orca.x, orca.y, orca.z, 0.22f) : ImVec4(0.f, 0.f, 0.f, 0.f));
|
||||
if (it != m_panel_icon_map.end() && int(it->second.size()) > (active ? 1 : 0) &&
|
||||
it->second[active ? 1 : 0]->is_valid()) {
|
||||
const IconManager::Icon &ic = *it->second[active ? 1 : 0];
|
||||
clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id,
|
||||
ImVec2(icon_sm - 2.f * pad, icon_sm - 2.f * pad), ic.tl, ic.br, int(pad));
|
||||
} else {
|
||||
clicked = ImGui::Button(label.ToUTF8().data(), ImVec2(0.f, icon_sm));
|
||||
}
|
||||
ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(),
|
||||
ImGui::GetColorU32(active ? orca : col_frame), style.FrameRounding);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopID();
|
||||
hover_tip(tip_text);
|
||||
return clicked;
|
||||
};
|
||||
|
||||
TextureDisplacementLayer *layer = mv != nullptr ? active_layer() : nullptr;
|
||||
|
||||
{
|
||||
const float x0 = ImGui::GetCursorPosX();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
m_imgui->push_bold_font();
|
||||
m_imgui->text(wxString::FromUTF8(get_name(false)));
|
||||
m_imgui->pop_bold_font();
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + panel_w - icon_sm));
|
||||
if (icon_button(920, "canvas_drag.svg", m_undocked ? _L("Dock panel") : _L("Undock panel"),
|
||||
_L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back "
|
||||
"against the right edge.")))
|
||||
m_undocked = !m_undocked;
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
// Everything between the title and the footer scrolls as one, so Apply and Close stay on screen
|
||||
// on a short canvas instead of being the first thing pushed off the bottom. Sized from last
|
||||
// frame's content and footer heights, as the displacement panel's body is.
|
||||
const float bottom = m_undocked ? ImGui::GetIO().DisplaySize.y : bottom_limit;
|
||||
const float max_body_h = std::max(m_imgui->scaled(8.f), bottom - ImGui::GetCursorScreenPos().y -
|
||||
m_panel_footer_h - style.WindowPadding.y -
|
||||
style.ItemSpacing.y);
|
||||
const float body_h = m_panel_body_h > 0.f ? std::min(m_panel_body_h + 1.f, max_body_h) : max_body_h;
|
||||
const float scrollbar_w = m_imgui->scaled(0.5f);
|
||||
const ImVec4 grab = wxGetApp().dark_mode() ? ImVec4(1.f, 1.f, 1.f, 0.26f) : ImVec4(0.f, 0.f, 0.f, 0.26f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, scrollbar_w);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0.5f * scrollbar_w);
|
||||
ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab);
|
||||
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(grab.x, grab.y, grab.z, 0.45f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(grab.x, grab.y, grab.z, 0.65f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.f, 0.f, 0.f, 0.f));
|
||||
ImGui::BeginChild("##image_body", ImVec2(panel_w, body_h), false);
|
||||
|
||||
// The picture, shown whole and the full width of the window: it is the thing being placed, so a
|
||||
// row-sized thumbnail beside a file name is the wrong size for it. Clicking it opens the library,
|
||||
// which is also where Import lives, and before anything is loaded the same box is an empty frame
|
||||
// with a plus in it - one target, in the same place, whichever state the tool is in.
|
||||
const auto open_library = [&](int slot) {
|
||||
m_picker_slot = slot;
|
||||
m_picker_open_request = true;
|
||||
};
|
||||
// A layer with no picture on it is the starting state, not a broken one: the tool creates the
|
||||
// layer empty and the panel stays on the frame below until a picture has been chosen.
|
||||
const bool has_picture = layer != nullptr && !layer->empty();
|
||||
const bool placed = mv != nullptr && mv->is_texture_displacement_painted();
|
||||
|
||||
// The handle comes up by itself the first moment there is something to anchor it to, so the
|
||||
// picture can be nudged straight after the click that placed it without hunting for a toggle.
|
||||
// Armed once: turning it back off has to stay off, not be undone on the next frame.
|
||||
if (has_picture && placed && !m_image_handle_armed) {
|
||||
m_image_handle_armed = true;
|
||||
if (update_adjust_anchor())
|
||||
m_adjust_texture_mode = true;
|
||||
}
|
||||
if (!has_picture) {
|
||||
const float box_h = std::round(panel_w * 0.6f);
|
||||
const ImVec2 p0 = ImGui::GetCursorScreenPos();
|
||||
m_imgui->disabled_begin(busy || mv == nullptr);
|
||||
const bool clicked = ImGui::InvisibleButton("##load", ImVec2(panel_w, box_h));
|
||||
m_imgui->disabled_end();
|
||||
const bool hot = ImGui::IsItemHovered();
|
||||
ImDrawList *dl = ImGui::GetWindowDrawList();
|
||||
const ImU32 col = ImGui::GetColorU32(hot ? orca : col_frame);
|
||||
dl->AddRect(p0, ImVec2(p0.x + panel_w, p0.y + box_h), col, style.FrameRounding);
|
||||
const float cx = p0.x + 0.5f * panel_w, cy = p0.y + 0.5f * box_h;
|
||||
const float arm = std::round(box_h * 0.18f), thick = std::max(1.f, std::round(box_h * 0.035f));
|
||||
dl->AddLine(ImVec2(cx - arm, cy), ImVec2(cx + arm, cy), col, thick);
|
||||
dl->AddLine(ImVec2(cx, cy - arm), ImVec2(cx, cy + arm), col, thick);
|
||||
hover_tip(_u8L("Choose a picture to put on the model."));
|
||||
if (clicked) {
|
||||
if (layer == nullptr)
|
||||
add_texture_layer(); // created with no picture on it, on purpose
|
||||
open_library(layer != nullptr ? layer->slot : m_active_layer_slot);
|
||||
}
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
|
||||
ImGui::TextDisabled("%s", _u8L("Choose a picture, then click the model to put it there.").c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
} else {
|
||||
// The colour texture is the corrected image at full resolution, which is exactly what should
|
||||
// be previewed - move a correction slider and the box follows. A grayscale picture has no
|
||||
// colour texture, so it falls back to the height thumbnail.
|
||||
GLTexture *shown = get_layer_color_texture(*layer);
|
||||
if (shown == nullptr)
|
||||
shown = get_layer_thumbnail(*layer);
|
||||
const float aspect = std::max(0.05f, layer_texture_aspect(*layer));
|
||||
const float box_h = std::clamp(std::round(panel_w / aspect), std::round(panel_w * 0.35f),
|
||||
std::round(panel_w * 1.1f));
|
||||
const ImVec2 box_min = ImGui::GetCursorScreenPos();
|
||||
bool box_hit = false;
|
||||
if (shown != nullptr) {
|
||||
box_hit = ImGui::ImageButton((ImTextureID) (intptr_t) shown->get_id(), ImVec2(panel_w, box_h),
|
||||
ImVec2(0.f, 0.f), ImVec2(1.f, 1.f), 0);
|
||||
} else {
|
||||
box_hit = ImGui::Button("?##thumb", ImVec2(panel_w, box_h));
|
||||
}
|
||||
hover_tip(m_image_bg_picking ?
|
||||
_u8L("Click the background, here or on the model.") :
|
||||
_u8L("Choose a different picture. Where you placed it and how it is corrected stay as "
|
||||
"they are."));
|
||||
if (box_hit) {
|
||||
if (!m_image_bg_picking) {
|
||||
open_library(layer->slot);
|
||||
} else {
|
||||
// Read straight out of the decoded image rather than off the screen: the preview is
|
||||
// scaled, and it already has the corrections applied, so a screen read would key the
|
||||
// corrected colour against itself.
|
||||
const ImVec2 rel((ImGui::GetIO().MousePos.x - box_min.x) / panel_w,
|
||||
(ImGui::GetIO().MousePos.y - box_min.y) / box_h);
|
||||
const DecodedHeightTexture &tex = decode_height_texture(*layer);
|
||||
if (tex.has_color()) {
|
||||
const int px = std::clamp(int(rel.x * float(tex.width)), 0, tex.width - 1);
|
||||
const int py = std::clamp(int(rel.y * float(tex.height)), 0, tex.height - 1);
|
||||
const size_t i = (size_t(py) * size_t(tex.width) + size_t(px)) * 3;
|
||||
for (int c = 0; c < 3; ++c)
|
||||
layer->bg_color[c] = float(tex.rgb[i + size_t(c)]) / 255.f;
|
||||
layer->bg_from_corners = false;
|
||||
layer->bg_remove = true;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
m_image_bg_picking = false;
|
||||
}
|
||||
}
|
||||
const std::string name = layer->name.empty() ? _u8L("Image") : layer->name;
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
|
||||
ImGui::TextDisabled("%s", name.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
|
||||
if (!height_texture_has_color(*layer)) {
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
|
||||
m_imgui->warning_text(_L("This image is grayscale, so it has no colors to print. Choose a color picture."));
|
||||
ImGui::PopTextWrapPos();
|
||||
}
|
||||
|
||||
// Placing it: one click floods the surface it lands on, out to the first fold sharper than
|
||||
// the angle below. No brush, no erase toggle - on a flat side or a cube face the click alone
|
||||
// is the whole gesture, and the slider is there for the cases where it is not.
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
|
||||
ImGui::TextDisabled("%s", placed ? _u8L("Click the model to move the picture somewhere else.").c_str() :
|
||||
_u8L("Click the model to put the picture there.").c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
float fill_angle = m_smart_fill_angle;
|
||||
if (float_row("##fill_angle", _L("Spread"), &fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", false))
|
||||
m_smart_fill_angle = fill_angle;
|
||||
hover_tip(_u8L("How far a click spreads from the spot you hit: it keeps going while the surface stays "
|
||||
"flatter than this, and stops at a sharper fold. Small values keep it to one face."));
|
||||
|
||||
ImGui::Separator();
|
||||
if (section("place", _L("Placement"), m_image_sec_place)) {
|
||||
|
||||
// Projection, minus Unwrap: that one is an editor's worth of UI (seams, islands, the UV pane)
|
||||
// for a tool whose whole point is a click and a few sliders.
|
||||
{
|
||||
struct Mapping { const char *file; wxString label; wxString tip; TextureProjectionMethod method; };
|
||||
const Mapping mappings[] = {
|
||||
{ "menu_obj_cube.svg", _L("Flat"),
|
||||
_L("Flat - lays the picture straight onto the surface from whichever axis it faces, and blends "
|
||||
"where it wraps around an edge. The right choice for a face or a flat side."),
|
||||
TextureProjectionMethod::Triplanar },
|
||||
{ "menu_obj_cylinder.svg", _L("Around"),
|
||||
_L("Around - wraps the picture around the area, for a round part like a mug or a tube."),
|
||||
TextureProjectionMethod::Cylindrical },
|
||||
{ "menu_obj_sphere.svg", _L("Ball"),
|
||||
_L("Ball - wraps the picture around the area in both directions, for a dome or a sphere."),
|
||||
TextureProjectionMethod::Spherical },
|
||||
{ "texture_displacement_map_view.svg", _L("From view"),
|
||||
_L("From view - throws the picture onto the area from where you are looking now, like a slide "
|
||||
"projector. Line the view up with the surface first, because faces turned away stretch."),
|
||||
TextureProjectionMethod::ViewProjected },
|
||||
};
|
||||
const float x0 = ImGui::GetCursorPosX();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
// Plain text, like every other label in this window: the displacement panel greys its
|
||||
// headings because it has many, and here there are five rows in total.
|
||||
m_imgui->text(_L_CONTEXT("Mapping", "Texture Displacement"));
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
|
||||
for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) {
|
||||
if (mi > 0)
|
||||
ImGui::SameLine(0.f, gap_s);
|
||||
const bool on = layer->projection_method == mappings[mi].method;
|
||||
if (icon_toggle(900 + mi, mappings[mi].file, on, mappings[mi].label, mappings[mi].tip) && !on) {
|
||||
// Capture the current view the moment "From view" is chosen, so it does something
|
||||
// sensible immediately rather than projecting from a stale direction.
|
||||
if (mappings[mi].method == TextureProjectionMethod::ViewProjected)
|
||||
capture_view_projection(*layer);
|
||||
layer->projection_method = mappings[mi].method;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (layer->projection_method == TextureProjectionMethod::ViewProjected) {
|
||||
if (m_imgui->button(_u8L("Capture current view"))) {
|
||||
capture_view_projection(*layer);
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
hover_tip(_u8L("Throws the picture on again from where you are looking now. Orbit the model, press "
|
||||
"this, and it is re-laid from the new angle."));
|
||||
}
|
||||
|
||||
m_preview_params_dirty |= float_row("##size", _L("Size"), &layer->tiling_scale, 1.f, 300.f, "%.1f mm", true);
|
||||
hover_tip(_u8L("How wide the picture is on the model, in millimetres."));
|
||||
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer->rotation_deg, 0.f, 360.f, "%.0f°", false);
|
||||
hover_tip(_u8L("Turns the picture on the surface, for lining it up with an edge of the model."));
|
||||
|
||||
// Sliding it into place, and whether it repeats. Both belong on one row: the handle is the
|
||||
// coarse control and Tile decides what it is sliding - one copy, or a pattern.
|
||||
{
|
||||
const float x0 = ImGui::GetCursorPosX();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
m_imgui->text(_L("Move"));
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
|
||||
if (icon_toggle(910, "texture_displacement_adjust.svg", m_adjust_texture_mode, _L("Move on the model"),
|
||||
_L("Move on the model - drag the handle that appears on the surface to slide the "
|
||||
"picture around, or one of its two arrows to slide it along just that axis."))) {
|
||||
if (!m_adjust_texture_mode) {
|
||||
update_model_object(); // flush any pending placement before anchoring to it
|
||||
if (update_adjust_anchor())
|
||||
m_adjust_texture_mode = true;
|
||||
else
|
||||
show_error(nullptr, _u8L("Click the model to put the picture somewhere first."));
|
||||
} else {
|
||||
m_adjust_texture_mode = false;
|
||||
m_adjust_drag_handle = AdjustHandle::None;
|
||||
}
|
||||
}
|
||||
ImGui::SameLine(0.f, gap_s * 2.f);
|
||||
if (m_imgui->bbl_checkbox(_L("Tile"), layer->tile_enabled))
|
||||
m_preview_params_dirty = true;
|
||||
hover_tip(_u8L("Repeats the picture over the area instead of placing a single copy. Off by "
|
||||
"default, because a photo or a logo goes on once; Size then sets how big that "
|
||||
"one copy is."));
|
||||
}
|
||||
|
||||
// The one number that decides how sharp the printed picture is. Each triangle prints in a
|
||||
// single filament, so the mesh under the paint has to be finer than the detail wanted out of
|
||||
// the image - and the budget above is what stops that running away.
|
||||
{
|
||||
TextureDisplacementOptions &opts = mv->texture_displacement_options;
|
||||
float edge = opts.v2_refine_mm;
|
||||
m_imgui->disabled_begin(busy);
|
||||
if (float_row("##resolution", _L("Detail"), &edge, 0.02f, 2.f, "%.2f mm", true)) {
|
||||
opts.v2_refine_mm = edge;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
m_imgui->disabled_end();
|
||||
hover_tip(_u8L("How fine the mesh is made under the picture, in millimetres. Each triangle prints "
|
||||
"in one filament, so this is how sharp an edge between two colors can come out. "
|
||||
"Smaller costs triangles fast: halving it needs four times as many."));
|
||||
|
||||
// Refining past the budget is not an error - the bake simplifies back down to it - but the
|
||||
// result then carries less of the picture than this slider asks for, and without saying so
|
||||
// the only sign is a print that came out softer than expected.
|
||||
const size_t budget = size_t(std::max(0, opts.v2_max_triangles_k)) * 1000;
|
||||
const size_t needed = estimated_refined_triangles(*mv, edge);
|
||||
if (budget > 0 && needed > budget * 5 / 4) {
|
||||
const auto count = [](size_t n) {
|
||||
return n >= 1000000 ? Slic3r::format("%1$.1f M", double(n) / 1000000.) :
|
||||
Slic3r::format("%1% k", (n + 500) / 1000);
|
||||
};
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
|
||||
m_imgui->warning_text(Slic3r::format(_u8L("Needs about %1% triangles, budget %2% - it will be "
|
||||
"simplified back down and come out softer."),
|
||||
count(needed), count(budget)));
|
||||
ImGui::PopTextWrapPos();
|
||||
}
|
||||
}
|
||||
|
||||
} // Placement
|
||||
|
||||
ImGui::Separator();
|
||||
if (section("color", _L("Color"), m_image_sec_color)) {
|
||||
|
||||
// Colour correction. Every one of these changes which filament each area lands in, which is
|
||||
// why they are here rather than left to whatever the file happened to contain: a photo that
|
||||
// prints as mud usually only needs contrast.
|
||||
m_preview_params_dirty |= float_row("##brightness", _L("Brightness"), &layer->brightness, -1.f, 1.f, "%.2f", false);
|
||||
hover_tip(_u8L("Lightens or darkens the whole picture before it is matched to your filaments."));
|
||||
m_preview_params_dirty |= float_row("##contrast", _L("Contrast"), &layer->contrast, -1.f, 1.f, "%.2f", false);
|
||||
hover_tip(_u8L("Pushes light and dark apart. Raise it when a photo prints as mud; lower it when "
|
||||
"everything collapses into the lightest and darkest filament."));
|
||||
m_preview_params_dirty |= float_row("##saturation", _L("Saturation"), &layer->saturation, 0.f, 2.f, "%.2f", false);
|
||||
hover_tip(_u8L("How strong the colors are. At 0 the picture is gray, so it prints in your grays and "
|
||||
"blacks; raising it pushes each area towards a purer filament."));
|
||||
m_preview_params_dirty |= float_row("##hue", _L("Hue"), &layer->hue, -180.f, 180.f, "%.0f°", false);
|
||||
hover_tip(_u8L("Turns every color of the picture around the color wheel, without changing how light "
|
||||
"it is. Your filaments are a fixed, arbitrary set, so this is what lets the picture "
|
||||
"meet them: if you have orange loaded but no red, a small turn puts the reds on it."));
|
||||
m_preview_params_dirty |= float_row("##gamma", _L("Gamma"), &layer->gamma, 0.2f, 5.f, "%.2f", true);
|
||||
hover_tip(_u8L("Lifts or drops the midtones without touching the brightest and darkest parts. Use it "
|
||||
"when the middle of the picture is the part coming out wrong."));
|
||||
m_preview_params_dirty |= float_row("##sharpen", _L("Sharpen"), &layer->sharpness, 0.f, 1.f, "%.2f", false);
|
||||
hover_tip(_u8L("Crisps up the edges before the picture is matched to your filaments. Worth more here "
|
||||
"than in a photo: a soft edge between two colors quantizes into a ragged band of "
|
||||
"both, and sharpening first makes it land in one place."));
|
||||
|
||||
// How many colours the picture may use. 2 is a logo in two flat colours, 4 or 5 a poster,
|
||||
// off a photograph. This is not the same as the displacement tool's "Mixed colors", which
|
||||
// caps how many mixed filament slots a bake may create rather than what the picture uses.
|
||||
{
|
||||
int count = layer->color_count;
|
||||
if (int_row("##colors", _L("Colors"), &count, 0, 12, count == 0 ? _u8L("Off").c_str() : "%d")) {
|
||||
// 1 is not a picture, so the slider steps over it on the way out of Off.
|
||||
layer->color_count = count == 1 ? 2 : count;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
hover_tip(_u8L("Reduces the picture to this many colors before your filaments are matched to "
|
||||
"it, so it prints in at most that many. The colors are the ones the picture is "
|
||||
"actually made of, not an even split of the range. Off leaves it alone."));
|
||||
}
|
||||
|
||||
if (m_imgui->bbl_checkbox(_L("Negative"), layer->color_invert))
|
||||
m_preview_params_dirty = true;
|
||||
hover_tip(_u8L("Prints the picture inverted, light for dark and each color for its opposite."));
|
||||
|
||||
} // Color
|
||||
|
||||
ImGui::Separator();
|
||||
if (!section("bg", _L("Background"), m_image_sec_bg)) {
|
||||
// Folded away, but still worth saying whether it is doing anything.
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("%s", layer->bg_remove ? _u8L("removed").c_str() : _u8L("kept").c_str());
|
||||
} else {
|
||||
if (m_imgui->bbl_checkbox(_L("Remove background"), layer->bg_remove))
|
||||
m_preview_params_dirty = true;
|
||||
hover_tip(_u8L("Leaves the background unprinted, so the object's own color shows through instead of "
|
||||
"a slab of white behind a logo. A picture that is already transparent needs none of "
|
||||
"this: those parts are left out either way."));
|
||||
if (layer->bg_remove) {
|
||||
// Which colour is being removed, shown rather than described: the swatch is also a
|
||||
// picker, and the dropper beside it takes the colour from the preview above.
|
||||
const float x0 = ImGui::GetCursorPosX();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
m_imgui->text(_L("Color"));
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
|
||||
float shown_bg[3] = { layer->bg_color[0], layer->bg_color[1], layer->bg_color[2] };
|
||||
if (layer->bg_from_corners) {
|
||||
// Nothing has been picked, so show what the corners actually gave: the same median
|
||||
// the bake will key on, not a placeholder white.
|
||||
const DecodedHeightTexture &tex = decode_height_texture(*layer);
|
||||
if (tex.has_color() && tex.width > 1 && tex.height > 1) {
|
||||
const size_t n = size_t(tex.width) * size_t(tex.height);
|
||||
const size_t corner[4] = { 0, size_t(tex.width) - 1,
|
||||
(size_t(tex.height) - 1) * size_t(tex.width), n - 1 };
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
std::array<float, 4> v{};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
v[size_t(i)] = float(tex.rgb[corner[i] * 3 + size_t(c)]);
|
||||
std::sort(v.begin(), v.end());
|
||||
shown_bg[c] = 0.5f * (v[1] + v[2]) / 255.f;
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX() - icon_sm - gap_s);
|
||||
if (ImGui::ColorEdit3("##bg_color", shown_bg,
|
||||
ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel)) {
|
||||
for (int c = 0; c < 3; ++c)
|
||||
layer->bg_color[c] = shown_bg[c];
|
||||
layer->bg_from_corners = false;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
hover_tip(layer->bg_from_corners ?
|
||||
_u8L("Taken from the four corners of the picture, which is right for a logo on a "
|
||||
"plain card. Click to choose a color yourself instead.") :
|
||||
_u8L("The color being removed. Click to change it."));
|
||||
ImGui::SameLine(0.f, gap_s);
|
||||
if (icon_toggle(930, "texture_displacement_pipette.svg", m_image_bg_picking, _L("Pick the color"),
|
||||
_L("Pick the color - then click the background, either in the preview above or on "
|
||||
"the model itself.")))
|
||||
m_image_bg_picking = !m_image_bg_picking;
|
||||
if (!layer->bg_from_corners) {
|
||||
ImGui::SameLine(0.f, gap_s);
|
||||
if (m_imgui->button(_u8L("Corners"))) {
|
||||
layer->bg_from_corners = true;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
hover_tip(_u8L("Back to taking the color from the picture's four corners."));
|
||||
}
|
||||
|
||||
m_preview_params_dirty |= float_row("##bg_tol", _L("Tolerance"), &layer->bg_tolerance, 0.01f, 0.6f, "%.2f", false);
|
||||
hover_tip(_u8L("How different from that color still counts as background. Raise it when a "
|
||||
"shadow or a gradient is left behind; lower it when it starts eating the picture."));
|
||||
}
|
||||
|
||||
} // Background
|
||||
|
||||
// Outside both sections, because it clears the colour corrections and the background key
|
||||
// alike, and a reset that lives inside one of them would look like it only reached that far.
|
||||
ImGui::Separator();
|
||||
if (m_imgui->button(_u8L("Reset all"))) {
|
||||
layer->brightness = 0.f;
|
||||
layer->contrast = 0.f;
|
||||
layer->saturation = 1.f;
|
||||
layer->hue = 0.f;
|
||||
layer->gamma = 1.f;
|
||||
layer->sharpness = 0.f;
|
||||
layer->color_count = 0;
|
||||
layer->bg_remove = false;
|
||||
layer->bg_tolerance = 0.15f;
|
||||
layer->bg_from_corners = true;
|
||||
layer->bg_color[0] = layer->bg_color[1] = layer->bg_color[2] = 1.f;
|
||||
m_image_bg_picking = false;
|
||||
layer->color_invert = false;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
hover_tip(_u8L("Puts every correction and the background back to where they started, leaving the "
|
||||
"picture as it was loaded. Where you placed it and how big it is are not touched."));
|
||||
}
|
||||
|
||||
// The library popup lives in this window's scope, where its open request and the popup itself
|
||||
// share one ID stack.
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
// ImGui sliders report "changed" on every frame of a drag, and a preview rebuild is real work,
|
||||
// so with the mouse still down it waits for the release.
|
||||
if (m_preview_params_dirty && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
rebuild_preview();
|
||||
m_preview_params_dirty = false;
|
||||
}
|
||||
|
||||
m_panel_body_h = ImGui::GetCursorPosY() - style.ItemSpacing.y;
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleColor(5);
|
||||
ImGui::PopStyleVar(2);
|
||||
|
||||
const float footer_top = ImGui::GetCursorScreenPos().y;
|
||||
ImGui::Separator();
|
||||
{
|
||||
const bool painted = placed;
|
||||
const bool no_colors = mv != nullptr && !any_layer_colors(*mv);
|
||||
// Colour is the only thing this bake produces, so with a grayscale picture there would be
|
||||
// nothing to show for it - and it is not a cheap no-op: the bake still refines the painted
|
||||
// area and replaces the mesh with the result.
|
||||
const bool can_apply = !busy && mv != nullptr && painted && !no_colors;
|
||||
|
||||
const float button_h = std::round(frame_h * 1.25f);
|
||||
const float button_w = std::max({ ImGui::CalcTextSize(_u8L("Apply").c_str()).x,
|
||||
ImGui::CalcTextSize(_u8L("Close").c_str()).x,
|
||||
ImGui::CalcTextSize(_u8L("Stop").c_str()).x,
|
||||
ImGui::CalcTextSize(_u8L("Working...").c_str()).x }) +
|
||||
m_imgui->scaled(0.5f);
|
||||
const float row_y = ImGui::GetCursorPosY();
|
||||
const float icon_h = 21.f * scale;
|
||||
const float row_h = std::max(icon_h, button_h);
|
||||
|
||||
ImGui::SetCursorPosY(row_y + (row_h - icon_h) * .5f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(int(m_imgui->scaled(.5f)), style.ItemSpacing.y));
|
||||
GLGizmoUtils::render_wiki_guide_button(m_parent, scale,
|
||||
"https://www.orcaslicer.com/wiki/print_prepare/prepare_texture_displacement");
|
||||
ImGui::PopStyleVar(1);
|
||||
|
||||
ImGui::SameLine(panel_w - button_w * 2 - style.ItemSpacing.x);
|
||||
GLGizmoUtils::push_orca_button_style();
|
||||
m_imgui->push_bold_font();
|
||||
m_imgui->disabled_begin(!can_apply);
|
||||
ImGui::SetCursorPosY(row_y + (row_h - button_h) * .5f);
|
||||
const std::string apply_label = busy ? _u8L("Working...") : _u8L("Apply");
|
||||
if (ImGui::Button((apply_label + "##apply").c_str(), ImVec2(button_w, button_h)))
|
||||
bake_standard();
|
||||
m_imgui->disabled_end();
|
||||
m_imgui->pop_bold_font();
|
||||
GLGizmoUtils::pop_orca_button_style();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||
m_imgui->tooltip(!has_picture ? _u8L("Choose a picture first.") :
|
||||
!painted ? _u8L("Click the model to place the picture first.") :
|
||||
no_colors ? _u8L("This picture is grayscale, so there is nothing to print. "
|
||||
"Choose a color picture.") :
|
||||
_u8L("Prints the picture where you placed it, in the nearest of your "
|
||||
"loaded filaments. The surface itself is left exactly where it "
|
||||
"is: this adds color, not relief."),
|
||||
wrap_w);
|
||||
|
||||
ImGui::SameLine();
|
||||
if (busy) {
|
||||
if (ImGui::Button((_u8L("Stop") + "##stop").c_str(), ImVec2(button_w, button_h)))
|
||||
wxGetApp().plater()->get_ui_job_worker().cancel_all();
|
||||
hover_tip(_u8L("Stops it. Whatever it had already finished stays on the model, and can be undone."));
|
||||
} else {
|
||||
if (ImGui::Button((_u8L("Close") + "##close").c_str(), ImVec2(button_w, button_h)))
|
||||
m_parent.reset_all_gizmos();
|
||||
hover_tip(_u8L("Closes the tool. Your picture and where you put it stay with the model."));
|
||||
}
|
||||
}
|
||||
|
||||
m_panel_footer_h = ImGui::GetCursorScreenPos().y - footer_top;
|
||||
// Both heights feed back into next frame's layout, so the window needs one more frame whenever
|
||||
// either of them moved.
|
||||
if (std::abs(m_panel_body_h - previous_body_h) > 0.5f || std::abs(m_panel_footer_h - previous_footer_h) > 0.5f)
|
||||
m_parent.request_extra_frame();
|
||||
|
||||
// Last, on the foreground list, so it covers the panel as well as the view.
|
||||
render_image_drop_hint();
|
||||
|
||||
GizmoImguiEnd();
|
||||
ImGuiWrapper::pop_toolbar_style();
|
||||
}
|
||||
|
||||
} // namespace Slic3r::GUI
|
||||
@@ -1,46 +0,0 @@
|
||||
#ifndef slic3r_GLGizmoImage_hpp_
|
||||
#define slic3r_GLGizmoImage_hpp_
|
||||
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <wx/string.h>
|
||||
|
||||
namespace Slic3r::GUI {
|
||||
|
||||
class GLCanvas3D;
|
||||
|
||||
// "Put this picture on the model": the texture-displacement gizmo with the relief taken out of it.
|
||||
//
|
||||
// There is no second engine here and no copy of one. It is GLGizmoTextureDisplacement with
|
||||
// image_mode() on, which pins every layer to zero depth and colour on (see
|
||||
// apply_image_mode_presets()) and leaves out the controls that only mean something once the surface
|
||||
// moves. Painting, the layer stack, the texture library, the previews and the bake are the very same
|
||||
// code, reached the very same way - a zero-depth bake refines the painted area and writes the
|
||||
// colours exactly as a relief bake does, it just displaces by nothing.
|
||||
//
|
||||
// Its own toolbar button because the two are different jobs, not two settings of one: a user who
|
||||
// wants a photo on a part should not have to know what a height map is, or find the one slider that
|
||||
// has to be zero.
|
||||
class GLGizmoImage : public GLGizmoTextureDisplacement
|
||||
{
|
||||
public:
|
||||
GLGizmoImage(GLCanvas3D &parent, const std::string &icon_filename, unsigned int sprite_id);
|
||||
|
||||
protected:
|
||||
bool image_mode() const override { return true; }
|
||||
|
||||
bool on_init() override;
|
||||
std::string on_get_name() const override;
|
||||
|
||||
wxString handle_snapshot_action_name(bool shift_down, Button button_down) const override;
|
||||
|
||||
std::string get_gizmo_entering_text() const override { return _u8L("Entering Image painting"); }
|
||||
std::string get_gizmo_leaving_text() const override { return _u8L("Leaving Image painting"); }
|
||||
std::string get_action_snapshot_name() const override { return _u8L("Image editing"); }
|
||||
};
|
||||
|
||||
} // namespace Slic3r::GUI
|
||||
|
||||
#endif // slic3r_GLGizmoImage_hpp_
|
||||
@@ -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.
|
||||
@@ -409,18 +418,10 @@ std::unique_ptr<GLTexture> upload_color_texture(const DecodedHeightTexture &deco
|
||||
for (int c = 0; c < 3; ++c)
|
||||
sum[c] += decoded.rgb[si + size_t(c)];
|
||||
}
|
||||
// Coverage rides in the fourth channel, box-filtered with the colour, so the shader can
|
||||
// leave the surface alone where the picture is not there (see the colour path in
|
||||
// texture_displacement_shaded.fs).
|
||||
unsigned int sum_a = 0;
|
||||
if (decoded.has_alpha())
|
||||
for (int sy = y0; sy < y1 && sy < decoded.height; ++sy)
|
||||
for (int sx = x0; sx < x1 && sx < decoded.width; ++sx)
|
||||
sum_a += decoded.alpha[size_t(sy) * size_t(decoded.width) + size_t(sx)];
|
||||
const size_t di = (size_t(y) * size_t(w) + size_t(x)) * 4;
|
||||
for (int c = 0; c < 3; ++c)
|
||||
rgba[di + size_t(c)] = (n > 0) ? static_cast<unsigned char>(sum[size_t(c)] / n) : 0;
|
||||
rgba[di + 3] = (decoded.has_alpha() && n > 0) ? static_cast<unsigned char>(sum_a / n) : 255;
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
|
||||
auto texture = std::make_unique<GLTexture>();
|
||||
@@ -533,13 +534,16 @@ 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;
|
||||
m_palette_pure_quantizer = nullptr;
|
||||
m_palette_filaments.clear();
|
||||
m_palette_images.clear();
|
||||
m_palette_adjusts.clear();
|
||||
m_mix_ranking.reset();
|
||||
m_palette_slots.clear();
|
||||
m_palette_changed = false;
|
||||
@@ -565,8 +569,6 @@ void GLGizmoTextureDisplacement::on_shutdown()
|
||||
m_seam_edit_mode = false;
|
||||
m_adjust_drag_handle = AdjustHandle::None;
|
||||
m_adjust_anchor_valid = false;
|
||||
m_image_handle_armed = false;
|
||||
m_file_drag_over = false;
|
||||
// The pane is request-only: closing the gizmo drops the request, so reopening it later doesn't
|
||||
// silently reopen the pane too.
|
||||
m_show_uv_editor = false;
|
||||
@@ -771,21 +773,8 @@ bool GLGizmoTextureDisplacement::on_mouse(const wxMouseEvent &mouse_event)
|
||||
{
|
||||
if (m_seam_edit_mode)
|
||||
return on_mouse_seam(mouse_event);
|
||||
// The background dropper takes the next click wherever it lands, the model included, so it comes
|
||||
// before both the handle and the paint.
|
||||
if (m_image_bg_picking && mouse_event.LeftDown() &&
|
||||
pick_background_at(Vec2d(mouse_event.GetX(), mouse_event.GetY())))
|
||||
return true;
|
||||
if (m_adjust_texture_mode) {
|
||||
if (on_mouse_adjust_texture(mouse_event))
|
||||
return true;
|
||||
// The displacement tool turns this mode on deliberately, so there a click off the handle
|
||||
// doing nothing is the point. The Image tool has it on from the start, so the same click has
|
||||
// to fall through and place the picture, or turning it on would disable the tool's one
|
||||
// gesture.
|
||||
if (!image_mode())
|
||||
return false;
|
||||
}
|
||||
if (m_adjust_texture_mode)
|
||||
return on_mouse_adjust_texture(mouse_event);
|
||||
|
||||
const bool handled = GLGizmoPainterBase::on_mouse(mouse_event);
|
||||
// A consumed drag/click is a paint (or erase) event: the base class has already updated the live
|
||||
@@ -1587,9 +1576,6 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh()
|
||||
shader->set_uniform("height_tex", 0);
|
||||
shader->set_uniform("height_tex_texel", Vec2f(1.f / float(tex->get_width()), 1.f / float(tex->get_height())));
|
||||
shader->set_uniform("depth_mm", layer->depth_mm);
|
||||
// Without this the preview wrapped the image and showed a repeating pattern where the bake
|
||||
// places a single copy.
|
||||
shader->set_uniform("tile_enabled", layer->tile_enabled);
|
||||
shader->set_uniform("tiling_scale", layer->tiling_scale);
|
||||
// Read off the uploaded texture rather than the decoded one: they are the same image, and this is
|
||||
// the aspect the sampler will actually see.
|
||||
@@ -2477,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) {
|
||||
@@ -3613,11 +3602,6 @@ void GLGizmoTextureDisplacement::update_model_object()
|
||||
wxGetApp().obj_list()->update_info_items(std::find(mos.begin(), mos.end(), mo) - mos.begin());
|
||||
m_parent.post_event(SimpleEvent(EVT_GLCANVAS_SCHEDULE_BACKGROUND_PROCESS));
|
||||
rebuild_preview();
|
||||
// The on-canvas handle is anchored to the paint patch, so a stroke that moved the paint has
|
||||
// to move it with them. Matters most in the Image tool, where the handle is on from the
|
||||
// start and a click moves the picture to a different part of the model.
|
||||
if (m_adjust_texture_mode)
|
||||
update_adjust_anchor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3700,11 +3684,11 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
|
||||
// a monochrome ("normal", grey in the current theme) and an original-colour variant.
|
||||
static const std::vector<std::string> names = {
|
||||
"texture_displacement_brush.svg", "texture_displacement_face.svg", "texture_displacement_connected_area.svg",
|
||||
"texture_displacement_pipette.svg", // the Image panel's background dropper
|
||||
"texture_displacement_real_preview.svg", "texture_displacement_fast_preview.svg",
|
||||
"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",
|
||||
@@ -3759,24 +3743,13 @@ void GLGizmoTextureDisplacement::add_texture_layer()
|
||||
// Start the layer off on the first library texture rather than on nothing at all: a textureless
|
||||
// layer looks broken (painting on it appears to do nothing, because there is no height map to
|
||||
// displace by). The user swaps it for another from the layer's own picker.
|
||||
//
|
||||
// Not in the Image tool: there the layer deliberately starts with no picture at all, so the panel
|
||||
// stays on its empty frame until the user has chosen one. Picking a shipped height map for them
|
||||
// would be choosing the wrong thing twice over - it is not their picture, and most of that
|
||||
// library is grayscale, which an image bake cannot print.
|
||||
if (image_mode()) {
|
||||
// One copy, not a repeating pattern: a photo or a logo is placed once. The panel's Tile
|
||||
// checkbox is there for the cases that do want it, so this is a default and not a pin.
|
||||
layer.tile_enabled = false;
|
||||
} else {
|
||||
const std::vector<TextureLibraryEntry> &library = texture_library();
|
||||
if (!library.empty())
|
||||
if (const LibraryTexture *tex = get_library_texture(library.front().path)) {
|
||||
layer.name = library.front().name;
|
||||
layer.path = library.front().path;
|
||||
layer.image_data = tex->image_data;
|
||||
}
|
||||
}
|
||||
const std::vector<TextureLibraryEntry> &library = texture_library();
|
||||
if (!library.empty())
|
||||
if (const LibraryTexture *tex = get_library_texture(library.front().path)) {
|
||||
layer.name = library.front().name;
|
||||
layer.path = library.front().path;
|
||||
layer.image_data = tex->image_data;
|
||||
}
|
||||
mv->texture_displacement_layers.push_back(std::move(layer));
|
||||
// A new layer opens with every setting showing; there is nothing on it yet to hide settings from.
|
||||
m_layer_expanded[size_t(free_slot)] = true;
|
||||
@@ -3971,19 +3944,15 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
return n;
|
||||
};
|
||||
|
||||
// The shipped library is grayscale height maps, which is what the displacement tool wants and the
|
||||
// opposite of what an image bake can use: it prints colour, and a height map has none. So the
|
||||
// Image tool offers only the user's own pictures, and the section is left out entirely rather
|
||||
// than shown full of things that would all warn when picked.
|
||||
if (!image_mode()) {
|
||||
ImGui::TextDisabled("%s", _u8L("Built-in").c_str());
|
||||
ImGui::TextDisabled("%s", _u8L("Built-in").c_str());
|
||||
{
|
||||
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
const int count = grid(origin, false);
|
||||
end_grid(origin, count);
|
||||
if (count == 0)
|
||||
ImGui::TextDisabled("%s", _u8L("No textures found.").c_str());
|
||||
}
|
||||
ImGui::TextDisabled("%s", image_mode() ? _u8L("My images").c_str() : _u8L("My textures").c_str());
|
||||
ImGui::TextDisabled("%s", _u8L("My textures").c_str());
|
||||
{
|
||||
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
const int count = grid(origin, true);
|
||||
@@ -3991,20 +3960,15 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
|
||||
import_clicked = true;
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(image_mode() ?
|
||||
_u8L("Import a picture from your computer. Its colors are kept, and it is saved to "
|
||||
"your personal folder so it is here next time.") :
|
||||
_u8L("Import your own image as a height map. It is converted to the format the "
|
||||
"slicer bakes from and saved to your personal texture folder, kept separate "
|
||||
"from the textures shipped with OrcaSlicer."),
|
||||
m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
|
||||
"bakes from and saved to your personal texture folder, kept separate from the "
|
||||
"textures shipped with OrcaSlicer."),
|
||||
m_imgui->scaled(20.f));
|
||||
end_grid(origin, count + 1);
|
||||
}
|
||||
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w);
|
||||
ImGui::TextDisabled("%s", image_mode() ?
|
||||
_u8L("PNG, JPG or BMP. Hover one to remove it.").c_str() :
|
||||
_u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
|
||||
ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
|
||||
if (chosen || import_clicked)
|
||||
@@ -4035,6 +3999,338 @@ 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::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
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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;
|
||||
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
|
||||
// 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)
|
||||
{
|
||||
@@ -4584,22 +4880,15 @@ const std::vector<GLGizmoTextureDisplacement::PaletteEntry> &GLGizmoTextureDispl
|
||||
// The images themselves rather than their addresses, so a freed and reallocated image can never
|
||||
// pass for the old one. Whether one has colour, and whether its colours are flat, follows from it.
|
||||
std::vector<std::shared_ptr<std::vector<unsigned char>>> images;
|
||||
// ... and what each one is corrected by, which changes the colours the mixes are ranked against
|
||||
// without changing the image it came from.
|
||||
std::vector<ColorAdjustKey> adjusts;
|
||||
if (mv != nullptr)
|
||||
for (const TextureDisplacementLayer &layer : mv->texture_displacement_layers)
|
||||
if (layer.color_enabled && !layer.empty()) {
|
||||
if (layer.color_enabled && !layer.empty())
|
||||
images.push_back(layer.image_data);
|
||||
adjusts.push_back(ColorAdjustKey::of(layer));
|
||||
}
|
||||
|
||||
const bool ranking_stale = filaments != m_palette_filaments || images != m_palette_images ||
|
||||
adjusts != m_palette_adjusts;
|
||||
const bool ranking_stale = filaments != m_palette_filaments || images != m_palette_images;
|
||||
if (ranking_stale) {
|
||||
m_palette_filaments = std::move(filaments);
|
||||
m_palette_images = std::move(images);
|
||||
m_palette_adjusts = std::move(adjusts);
|
||||
m_mix_ranking.reset();
|
||||
}
|
||||
if (mixing && !m_mix_ranking)
|
||||
@@ -4676,12 +4965,9 @@ std::vector<GLGizmoTextureDisplacement::MixTarget> GLGizmoTextureDisplacement::m
|
||||
continue;
|
||||
TextureDisplacementLayer raw = layer;
|
||||
raw.smoothing = 0.f;
|
||||
DecodedHeightTexture tex = decode_height_texture(raw);
|
||||
const DecodedHeightTexture tex = decode_height_texture(raw);
|
||||
if (!tex.has_color())
|
||||
continue;
|
||||
// Corrected first: the mixes are chosen to cover the colours the bake will actually ask for,
|
||||
// and a darkened or desaturated image asks for different ones.
|
||||
apply_color_adjustments(tex, layer);
|
||||
|
||||
// 16 levels per channel, each bin keeping the mean of the colours that fell in it: a coarse grid
|
||||
// to gather on, without snapping every colour to a bin corner.
|
||||
@@ -5387,16 +5673,10 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp
|
||||
{
|
||||
if (layer.empty() || !layer.color_enabled)
|
||||
return nullptr;
|
||||
const ColorAdjustKey adjust = ColorAdjustKey::of(layer);
|
||||
if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing &&
|
||||
m_color_tex_adjust == adjust)
|
||||
if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing)
|
||||
return m_color_tex.get();
|
||||
|
||||
// Corrected here rather than inside the decode, exactly as prepare_sampleable_layers() does for
|
||||
// the bake, so the fast preview shades the image the bake will print.
|
||||
DecodedHeightTexture tex = decode_height_texture(layer);
|
||||
apply_color_adjustments(tex, layer);
|
||||
std::unique_ptr<GLTexture> texture = upload_color_texture(tex, HEIGHT_TEX_MAX_PX);
|
||||
std::unique_ptr<GLTexture> texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
|
||||
if (!texture) {
|
||||
m_color_tex.reset();
|
||||
m_color_tex_source = nullptr;
|
||||
@@ -5405,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 = adjust;
|
||||
return m_color_tex.get();
|
||||
}
|
||||
|
||||
@@ -5482,8 +5761,6 @@ static constexpr float STD_SUBDIV_BORDER_MM = 0.4f;
|
||||
// what the eye actually lands on - a stepped outline around a printed decal reads as a defect in a
|
||||
// way a slightly coarse relief transition does not - and it costs triangles along an outline only.
|
||||
static constexpr float STD_SUBDIV_COLOR_MM = 0.25f;
|
||||
// The Image tool's triangle budget, in thousands. See apply_image_mode_presets().
|
||||
static constexpr int IMAGE_BUDGET_K = 1000;
|
||||
|
||||
bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
|
||||
{
|
||||
@@ -5514,61 +5791,6 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Pinned into the volume rather than applied on the way to the bake, exactly as Standard mode pins
|
||||
// displace_border and smooth_enabled above: the preview, the bake, the triangle estimate and the
|
||||
// palette all read the layers straight off the volume, and a value that only some of them saw would
|
||||
// put the preview and the bake out of step.
|
||||
//
|
||||
// The two tools share one layer stack - that is what "not a fork" means here - so opening the Image
|
||||
// tool on a volume whose layers carry relief flattens them, and they stay flat when the displacement
|
||||
// tool is reopened. Only that one number changes: the paint, the image, the placement and any relief
|
||||
// already baked into the mesh are untouched.
|
||||
bool GLGizmoTextureDisplacement::apply_image_mode_presets(ModelVolume *mv)
|
||||
{
|
||||
if (mv == nullptr)
|
||||
return false;
|
||||
bool changed = false;
|
||||
TextureDisplacementOptions &opts = mv->texture_displacement_options;
|
||||
// A million triangles for the picture. The auto budget is 750 k, chosen for relief, where the
|
||||
// triangles buy shape; here they buy nothing but the sharpness of the edges between colours, and
|
||||
// that is the whole output, so it is worth spending more of them on it.
|
||||
if (opts.v2_max_triangles_k != IMAGE_BUDGET_K) {
|
||||
opts.v2_max_triangles_k = IMAGE_BUDGET_K;
|
||||
changed = true;
|
||||
}
|
||||
// Resolution is a slider in this panel rather than an Auto checkbox, so it needs a real value to
|
||||
// start from: the same recommendation Auto would have produced for this model.
|
||||
if (opts.v2_refine_mm <= 0.f) {
|
||||
const float edge = v2_recommendation(*mv).edge_mm;
|
||||
opts.v2_refine_mm = edge > 0.f ? edge : 0.3f;
|
||||
changed = true;
|
||||
}
|
||||
for (TextureDisplacementLayer &layer : mv->texture_displacement_layers) {
|
||||
// Zero depth is the whole of "no relief": the bake still refines the painted area and still
|
||||
// writes the colours, every layer just contributes a displacement of 0 (see
|
||||
// make_combined_displacement_sampler()). Nothing special-cases it.
|
||||
if (layer.depth_mm != 0.f) {
|
||||
layer.depth_mm = 0.f;
|
||||
changed = true;
|
||||
}
|
||||
// Colour is the only thing an image bake produces, so it is on wherever there is colour to
|
||||
// use rather than being a checkbox the user could leave off and wonder at.
|
||||
const bool wanted = height_texture_has_color(layer);
|
||||
if (layer.color_enabled != wanted) {
|
||||
layer.color_enabled = wanted;
|
||||
changed = true;
|
||||
}
|
||||
// Unwrap is the one projection the Image tool does not offer: it is an editor's worth of UI
|
||||
// (seams, islands, the UV pane) for a case that wants a click and a slider. A layer arriving
|
||||
// from the displacement tool set to it falls back to the triplanar default.
|
||||
if (layer.projection_method == TextureProjectionMethod::LSCM) {
|
||||
layer.projection_method = TextureProjectionMethod::Triplanar;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
void GLGizmoTextureDisplacement::bake_standard()
|
||||
{
|
||||
ModelVolume *mv = texture_volume();
|
||||
@@ -5877,14 +6099,6 @@ void GLGizmoTextureDisplacement::render_paint_cursor_hint()
|
||||
|
||||
void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float bottom_limit)
|
||||
{
|
||||
// The Image tool gets its own, much smaller window (see GLGizmoImage.cpp): everything below this
|
||||
// point is the displacement panel, and it is left exactly as it was rather than being threaded
|
||||
// with tests for which of the two tools is drawing.
|
||||
if (image_mode()) {
|
||||
render_image_input_window(x, y, bottom_limit);
|
||||
return;
|
||||
}
|
||||
|
||||
ModelObject *mo = m_c->selection_info()->model_object();
|
||||
if (!mo)
|
||||
return;
|
||||
@@ -6547,7 +6761,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));
|
||||
@@ -6561,6 +6775,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"))) {
|
||||
@@ -7360,10 +7584,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
|
||||
@@ -7600,6 +7829,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"
|
||||
@@ -154,33 +155,6 @@ public:
|
||||
// What the cache was built from.
|
||||
std::vector<ColorRGBA> m_palette_filaments;
|
||||
std::vector<std::shared_ptr<std::vector<unsigned char>>> m_palette_images; // the colouring layers' images
|
||||
// One layer's colour correction, as a value that can be compared and cached on. It belongs to
|
||||
// every cache keyed on "what the image looks like", because a correction slider changes the
|
||||
// pixels while leaving the image_data pointer alone.
|
||||
struct ColorAdjustKey
|
||||
{
|
||||
float brightness = 0.f, contrast = 0.f, saturation = 1.f, gamma = 1.f, hue = 0.f;
|
||||
float sharpness = 0.f, bg_tolerance = 0.15f;
|
||||
float bg_r = 1.f, bg_g = 1.f, bg_b = 1.f;
|
||||
int color_count = 0;
|
||||
bool invert = false, bg_remove = false, bg_from_corners = true;
|
||||
bool operator==(const ColorAdjustKey &o) const
|
||||
{
|
||||
return brightness == o.brightness && contrast == o.contrast && saturation == o.saturation &&
|
||||
gamma == o.gamma && hue == o.hue && sharpness == o.sharpness &&
|
||||
bg_tolerance == o.bg_tolerance && bg_r == o.bg_r && bg_g == o.bg_g && bg_b == o.bg_b &&
|
||||
color_count == o.color_count && invert == o.invert && bg_remove == o.bg_remove &&
|
||||
bg_from_corners == o.bg_from_corners;
|
||||
}
|
||||
bool operator!=(const ColorAdjustKey &o) const { return !(*this == o); }
|
||||
static ColorAdjustKey of(const TextureDisplacementLayer &l)
|
||||
{
|
||||
return { l.brightness, l.contrast, l.saturation, l.gamma, l.hue,
|
||||
l.sharpness, l.bg_tolerance, l.bg_color[0], l.bg_color[1], l.bg_color[2],
|
||||
l.color_count, l.color_invert, l.bg_remove, l.bg_from_corners };
|
||||
}
|
||||
};
|
||||
std::vector<ColorAdjustKey> m_palette_adjusts; // alongside m_palette_images, one per entry
|
||||
std::optional<std::vector<PaletteEntry>> m_mix_ranking; // rank_mixes(), computed on demand
|
||||
bool m_palette_mixing = false;
|
||||
int m_palette_mix_count = 0;
|
||||
@@ -230,25 +204,7 @@ public:
|
||||
// rotation handles instead of painting); otherwise forwards to the normal painting handling.
|
||||
bool on_mouse(const wxMouseEvent &mouse_event) override;
|
||||
|
||||
// Loads `path` as the Image tool's picture, importing it into the user's library on the way so
|
||||
// it is there next time. For a drop onto the 3D view; see PlaterDropTarget::OnDropFiles(). False
|
||||
// when this is not the Image tool, or the file could not be read.
|
||||
bool drop_image_file(const std::string &path);
|
||||
|
||||
// Called while a file is being dragged over the 3D view, so the Image tool can show that
|
||||
// dropping it there will do something. Without it the drag has no feedback at all and the only
|
||||
// way to find out is to let go.
|
||||
void set_file_drag_over(bool over);
|
||||
|
||||
protected:
|
||||
// True in the Image tool (see GLGizmoImage): this same gizmo restricted to the one case where
|
||||
// the surface does not move at all - a colour picture laid on the model. Everything behind the
|
||||
// panel is shared and unchanged: the same layers, the same paint masks, the same bake. This flag
|
||||
// only tells the panel which of the two tools it is drawing, and makes it pin the values the
|
||||
// restricted case fixes (see apply_image_mode_presets()).
|
||||
virtual bool image_mode() const { return false; }
|
||||
|
||||
bool on_init() override;
|
||||
void on_render_input_window(float x, float y, float bottom_limit) override;
|
||||
std::string on_get_name() const override;
|
||||
// Never in the assemble view: its toolbar does not offer this gizmo, and every preview here is drawn
|
||||
@@ -266,11 +222,8 @@ protected:
|
||||
EnforcerBlockerType get_left_button_state_type() const override { return m_erase_mode ? EnforcerBlockerType::NONE : EnforcerBlockerType::ENFORCER; }
|
||||
EnforcerBlockerType get_right_button_state_type() const override { return m_erase_mode ? EnforcerBlockerType::ENFORCER : EnforcerBlockerType::NONE; }
|
||||
|
||||
// Paint / Erase, from the panel. See get_left_button_state_type(). Protected because a subclass
|
||||
// overriding handle_snapshot_action_name() has to read the same flag to name the undo step.
|
||||
bool m_erase_mode = false;
|
||||
|
||||
private:
|
||||
bool on_init() override;
|
||||
void update_model_object() override;
|
||||
void update_from_model_object(bool first_update) override;
|
||||
void on_opening() override {}
|
||||
@@ -316,36 +269,6 @@ private:
|
||||
// the paint if it were not carried across (see prepare_mesh()).
|
||||
void bake_standard();
|
||||
|
||||
// Image mode's fixed values, on top of Standard's and pinned the same way (every frame, so the
|
||||
// preview cannot disagree with the bake): zero depth on every layer, and colour on wherever the
|
||||
// picture has any. Those two are what turn the displacement bake into "put this picture on the
|
||||
// model" without a second code path. Returns true if it actually changed something.
|
||||
bool apply_image_mode_presets(ModelVolume *mv);
|
||||
// The Image tool's whole window: load a picture, click the model to place it, correct it, Apply.
|
||||
// Defined in GLGizmoImage.cpp, beside the class it exists for, rather than here - it is a member
|
||||
// only so that it can reach the same private machinery the displacement panel uses, and keeping
|
||||
// it out of this file leaves that panel's 1700 lines alone.
|
||||
void render_image_input_window(float x, float y, float bottom_limit);
|
||||
// Set once the Image tool has switched its placement handle on for the current picture, so that
|
||||
// turning the handle back off stays off instead of being re-armed on the next frame.
|
||||
bool m_image_handle_armed = false;
|
||||
// Set between a drag entering the 3D view and leaving or dropping on it; drives the drop zone
|
||||
// render_image_input_window() draws over the canvas.
|
||||
bool m_file_drag_over = false;
|
||||
void render_image_drop_hint();
|
||||
// Set while the panel's background dropper is armed: the next click on the picture preview takes
|
||||
// the colour under it instead of opening the library.
|
||||
bool m_image_bg_picking = false;
|
||||
// Takes the background colour from the point of the model under `mouse_pos`: the picture's own
|
||||
// colour there, read through the same projection the bake uses, not the lit pixel on screen.
|
||||
// False when the ray misses or the layer does not colour that point, leaving the dropper armed.
|
||||
bool pick_background_at(const Vec2d &mouse_pos);
|
||||
// Which of the Image panel's sections are unfolded. Panel state for the session, deliberately
|
||||
// not saved with the project.
|
||||
bool m_image_sec_place = true;
|
||||
bool m_image_sec_color = true;
|
||||
bool m_image_sec_bg = true;
|
||||
|
||||
// Queues one prepare_mesh() run on the job worker and commits its result when it lands. Every
|
||||
// mesh-preparation button goes through here - Pro's Remesh, Pro's adaptive Subdivide and Standard's
|
||||
// Bake differ only in which stages `params` enables and in what happens afterwards:
|
||||
@@ -477,10 +400,77 @@ 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<bool, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_layer_expanded{};
|
||||
|
||||
// ---- 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;
|
||||
// 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
|
||||
// (a "pan" - drag anywhere on it for free 2D movement), plus two arrows along the patch's
|
||||
@@ -954,6 +944,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
|
||||
@@ -1010,7 +1001,6 @@ private:
|
||||
std::unique_ptr<GLTexture> m_color_tex;
|
||||
const void *m_color_tex_source = nullptr;
|
||||
float m_color_tex_smoothing = -1.f;
|
||||
ColorAdjustKey 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;
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
//#include "slic3r/GUI/Gizmos/GLGizmoHollow.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoSeam.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoImage.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoSimplify.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoEmboss.hpp"
|
||||
@@ -202,11 +201,6 @@ void GLGizmosManager::switch_gizmos_icon_filename()
|
||||
// light/dark switch.
|
||||
gizmo->set_icon_filename("toolbar_texture_displacement.svg");
|
||||
break;
|
||||
case(EType::Image):
|
||||
// Drawn flat in Orca's menu-icon palette, which reads on either background, so one file
|
||||
// serves both themes here too.
|
||||
gizmo->set_icon_filename("toolbar_image.svg");
|
||||
break;
|
||||
case(EType::MeshBoolean):
|
||||
gizmo->set_icon_filename(m_is_dark ? "toolbar_meshboolean_dark.svg" : "toolbar_meshboolean.svg");
|
||||
break;
|
||||
@@ -258,8 +252,6 @@ bool GLGizmosManager::init()
|
||||
m_gizmos.emplace_back(new GLGizmoMmuSegmentation(m_parent, m_is_dark ? "mmu_segmentation_dark.svg" : "mmu_segmentation.svg", EType::MmSegmentation));
|
||||
// One shared icon (no dedicated dark variant yet); it recolours acceptably in both themes.
|
||||
m_gizmos.emplace_back(new GLGizmoTextureDisplacement(m_parent, "toolbar_texture_displacement.svg", EType::TextureDisplacement));
|
||||
// The same gizmo restricted to the zero-displacement case; see GLGizmoImage.
|
||||
m_gizmos.emplace_back(new GLGizmoImage(m_parent, "toolbar_image.svg", EType::Image));
|
||||
m_gizmos.emplace_back(new GLGizmoEmboss(m_parent, m_is_dark ? "toolbar_text_dark.svg" : "toolbar_text.svg", EType::Emboss));
|
||||
m_gizmos.emplace_back(new GLGizmoSVG(m_parent));
|
||||
m_gizmos.emplace_back(new GLGizmoMeasure(m_parent, m_is_dark ? "toolbar_measure_dark.svg" : "toolbar_measure.svg", EType::Measure));
|
||||
@@ -593,8 +585,6 @@ bool GLGizmosManager::gizmo_event(SLAGizmoEventType action, const Vec2d& mouse_p
|
||||
return dynamic_cast<GLGizmoFuzzySkin*>(m_gizmos[FuzzySkin].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
|
||||
else if (m_current == TextureDisplacement)
|
||||
return dynamic_cast<GLGizmoTextureDisplacement*>(m_gizmos[TextureDisplacement].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
|
||||
else if (m_current == Image)
|
||||
return dynamic_cast<GLGizmoImage*>(m_gizmos[Image].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
|
||||
else if (m_current == MeshBoolean)
|
||||
return dynamic_cast<GLGizmoMeshBoolean*>(m_gizmos[MeshBoolean].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
|
||||
else if (m_current == BrimEars)
|
||||
@@ -609,7 +599,6 @@ bool GLGizmosManager::is_paint_gizmo()
|
||||
m_current == EType::MmSegmentation ||
|
||||
m_current == EType::FuzzySkin ||
|
||||
m_current == EType::TextureDisplacement ||
|
||||
m_current == EType::Image ||
|
||||
m_current == EType::Seam;
|
||||
}
|
||||
|
||||
@@ -1561,8 +1550,6 @@ std::string get_name_from_gizmo_etype(GLGizmosManager::EType type)
|
||||
return "Fuzzy Skin Painting";
|
||||
case GLGizmosManager::EType::TextureDisplacement:
|
||||
return "Texture Displacement";
|
||||
case GLGizmosManager::EType::Image:
|
||||
return "Image";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ public:
|
||||
FuzzySkin,
|
||||
MmSegmentation,
|
||||
TextureDisplacement,
|
||||
Image,
|
||||
Emboss,
|
||||
Svg,
|
||||
Measure,
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
#include "slic3r/GUI/Field.hpp"
|
||||
#include "slic3r/GUI/Widgets/PopupWindow.hpp"
|
||||
#include <set>
|
||||
#include <wx/filename.h>
|
||||
#include "slic3r/GUI/Widgets/StaticLine.hpp"
|
||||
#include "slic3r/GUI/GUI_ObjectSettings.hpp"
|
||||
#include "slic3r/GUI/ColorDecomposeDialog.hpp"
|
||||
@@ -247,7 +246,6 @@
|
||||
#include "DeviceCore/DevNozzleSystem.h" // DevNozzle, GetExtNozzles / GetRackNozzles
|
||||
#include "ProjectDirtyStateManager.hpp"
|
||||
#include "Gizmos/GLGizmoSimplify.hpp" // create suggestion notification
|
||||
#include "Gizmos/GLGizmoImage.hpp" // Drop an image onto the Image tool
|
||||
#include "Gizmos/GLGizmoSVG.hpp" // Drop SVG file
|
||||
#include "Gizmos/GizmoObjectManipulation.hpp"
|
||||
|
||||
@@ -7112,9 +7110,6 @@ void Sidebar::can_search()
|
||||
p->can_search();
|
||||
}
|
||||
|
||||
// Defined with the other drop helpers below; forward declared because the drop target uses it.
|
||||
void set_image_drag_over(Plater &plater, bool over);
|
||||
|
||||
class PlaterDropTarget : public wxFileDropTarget
|
||||
{
|
||||
public:
|
||||
@@ -7124,53 +7119,12 @@ public:
|
||||
|
||||
virtual bool OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &filenames);
|
||||
|
||||
// A drag has no feedback of its own, so the Image tool is told when one is over the view and
|
||||
// draws a drop zone for as long as it is. wxWidgets cannot hand us the file names before the
|
||||
// drop on GTK, so this says nothing about what is being dragged: the zone invites a picture,
|
||||
// and anything else still falls through to the model loader.
|
||||
virtual wxDragResult OnEnter(wxCoord x, wxCoord y, wxDragResult def)
|
||||
{
|
||||
set_image_drag_over(m_plater, true);
|
||||
return wxFileDropTarget::OnEnter(x, y, def);
|
||||
}
|
||||
virtual void OnLeave()
|
||||
{
|
||||
set_image_drag_over(m_plater, false);
|
||||
wxFileDropTarget::OnLeave();
|
||||
}
|
||||
|
||||
private:
|
||||
MainFrame& m_mainframe;
|
||||
Plater& m_plater;
|
||||
};
|
||||
|
||||
namespace {
|
||||
// The Image gizmo, but only while it is the open one. Null otherwise, which is what keeps a drag or
|
||||
// a drop from meaning anything different with the tool closed.
|
||||
GLGizmoImage *open_image_gizmo(Plater &plater)
|
||||
{
|
||||
GLCanvas3D *canvas = plater.canvas3D();
|
||||
if (canvas == nullptr)
|
||||
return nullptr;
|
||||
GLGizmosManager &gizmos = canvas->get_gizmos_manager();
|
||||
if (gizmos.get_current_type() != GLGizmosManager::Image)
|
||||
return nullptr;
|
||||
return dynamic_cast<GLGizmoImage *>(gizmos.get_gizmo(GLGizmosManager::Image));
|
||||
}
|
||||
|
||||
// True when `file` is a picture and the Image gizmo is the one currently open, in which case it has
|
||||
// taken the file. Mirrors emboss_svg() below, which does the same for an SVG and the text tool.
|
||||
bool drop_image_on_gizmo(Plater &plater, const wxString &file)
|
||||
{
|
||||
static const std::set<wxString> image_ext = { "png", "jpg", "jpeg", "bmp" };
|
||||
const wxString ext = wxFileName(file).GetExt().Lower();
|
||||
if (image_ext.count(ext) == 0)
|
||||
return false;
|
||||
|
||||
GLGizmoImage *image = open_image_gizmo(plater);
|
||||
return image != nullptr && image->drop_image_file(into_u8(file));
|
||||
}
|
||||
|
||||
bool emboss_svg(Plater& plater, const wxString &svg_file, const Vec2d& mouse_drop_position)
|
||||
{
|
||||
std::string svg_file_str = into_u8(svg_file);
|
||||
@@ -7829,12 +7783,6 @@ const std::regex Plater::priv::pattern_zip_amf(".*[.]zip[.]amf", std::regex::ica
|
||||
const std::regex Plater::priv::pattern_any_amf(".*[.](amf|amf[.]xml|zip[.]amf)", std::regex::icase);
|
||||
const std::regex Plater::priv::pattern_prusa(".*bbl", std::regex::icase);
|
||||
|
||||
void set_image_drag_over(Plater &plater, bool over)
|
||||
{
|
||||
if (GLGizmoImage *image = open_image_gizmo(plater))
|
||||
image->set_file_drag_over(over);
|
||||
}
|
||||
|
||||
bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &filenames)
|
||||
{
|
||||
#ifdef WIN32
|
||||
@@ -7842,18 +7790,11 @@ bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &fi
|
||||
this->MSWUpdateDragImageOnLeave();
|
||||
#endif // WIN32
|
||||
|
||||
set_image_drag_over(m_plater, false); // the drag is over, whoever ends up taking the file
|
||||
|
||||
m_mainframe.Raise();
|
||||
m_mainframe.select_tab(TAB_ID_PREPARE);
|
||||
if (wxGetApp().is_editor())
|
||||
m_plater.select_view_3D("3D");
|
||||
|
||||
// A picture dropped while the Image tool is open is that tool's input, not a model to load. Only
|
||||
// then: with the tool closed a dropped PNG goes on doing whatever it did before.
|
||||
if (filenames.size() == 1 && drop_image_on_gizmo(m_plater, filenames.Last()))
|
||||
return true;
|
||||
|
||||
// When only one .svg file is dropped on scene
|
||||
if (filenames.size() == 1) {
|
||||
const wxString &filename = filenames.Last();
|
||||
|
||||
@@ -114,9 +114,6 @@ constexpr std::array<ShortcutInfo, size_t(Shortcut::Count)> shortcut_table = {{
|
||||
SHORTCUT(GizmoAssembly, "gizmo_assembly", L("Gizmo assemble"), PLATER, { 'Y' }),
|
||||
SHORTCUT(GizmoBrimEars, "gizmo_brim_ears", L("Gizmo brim ears"), PLATER, { 'E' }),
|
||||
SHORTCUT(GizmoDisplacement, "gizmo_displacement", L("Gizmo texture displacement painting"), PLATER, { 'D' }),
|
||||
// Unbound by default: every single letter this context can reach is already taken, and a default
|
||||
// that shadows one of them is worse than none. The shortcuts dialog lists it for binding.
|
||||
SHORTCUT(GizmoImage, "gizmo_image", L("Gizmo image painting"), PLATER, KeyChord{}),
|
||||
|
||||
// Sliders
|
||||
SHORTCUT(GoToLayer, "go_to_layer", L("Jump to layer"), PREVIEW, { 'G', SHIFT }),
|
||||
|
||||
@@ -38,7 +38,7 @@ enum class Shortcut : uint8_t {
|
||||
MoveSelectionLeft, MoveSelectionRight, MoveSelectionUp, MoveSelectionDown, RotateSelectionLeft, RotateSelectionRight,
|
||||
// Gizmos
|
||||
GizmoMove, GizmoRotate, GizmoScale, GizmoFlatten, GizmoCut, GizmoMeshBoolean, GizmoFdmSupports, GizmoSeam, GizmoFuzzySkin,
|
||||
GizmoMmuSegmentation, GizmoEmboss, GizmoMeasure, GizmoAssembly, GizmoBrimEars, GizmoDisplacement, GizmoImage,
|
||||
GizmoMmuSegmentation, GizmoEmboss, GizmoMeasure, GizmoAssembly, GizmoBrimEars, GizmoDisplacement,
|
||||
// Sliders
|
||||
GoToLayer, LayerSliderUp, LayerSliderDown, MovesSliderLeft, MovesSliderRight, MovesSliderStart, MovesSliderEnd,
|
||||
// Painting tools
|
||||
|
||||
@@ -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>
|
||||
@@ -1300,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<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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Texture smoothing
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user