Compare commits

..
Author SHA1 Message Date
ExPikaPaka de07a84bfb fix: add the two includes clang-tidy reported as missing
misc-include-cleaner on the changed lines:
  TextureLibrary.cpp uses DecodedHeightTexture, which only reached it through
  TextureLibrary.hpp; it now includes libslic3r/TextureDisplacement.hpp itself.
  The contrast test uses std::not_equal_to, so <functional>.
2026-10-12 09:54:43 +02:00
ExPikaPaka 752b356ff0 fix: refresh the image-adjustment panel when the layer's image changes under it
The panel decoded the layer's image once, when it opened, so switching the
layer's texture from the picker left it showing the old photo. The 3D view had
already moved on, since decode_height_texture() reads layer.image_data and
that had changed, so the panel and the model disagreed about what was being
adjusted. Now the panel notices the image_data it decoded from is no longer
the layer's and re-reads it, keeping the slider positions: the user is
adjusting this layer, and the sliders are what they have just set.

Possible because the panel is a window rather than a popup - the picker is
reachable while it is open, which is what makes this case exist at all.

Two more ways the open panel could end up pointing at the wrong thing, found
while fixing that one:

Slots are numbered per volume, so selecting a different object rebound the
panel to whatever layer held the same slot on the newly selected one - a
different image under a panel opened for another object. The panel now
records the volume it was opened on and closes when the selection leaves it.

And closing it reverted preview_adjust through texture_volume(), which
answers for the current selection. Since a selection change is one of the
ways to close it, that reverted the wrong volume's layer: it cleared an
adjustment the user never made and left the real one live on the original
object, which would have gone on displacing from the adjusted image with no
panel open. The revert now finds the volume by the id recorded at open time.
2026-10-12 08:28:08 +02:00
ExPikaPaka 8f1c8f0580 feat: live displacement in the image-adjustment panel, reached from a per-layer icon
Four changes to the image-adjustment panel, all from using it:

Entry point. The panel is now opened by an icon in the layer's header row,
between the image name and the order arrows, rather than by a button buried
under "More settings". It acts on the image the name refers to, so it belongs
beside the name. Disabled for a layer with no image.

Only "Save as copy". Overwrite is gone, and with it the one path that edited
a layer's stored image in place. Writing the result to the texture library and
pointing the layer at it is the single way an adjustment becomes permanent,
which also means an adjusted texture is always a file the user can find and
reuse. encode_texture_image_data() had no other caller and is removed.

A window, not a popup. A popup dimmed the canvas and closed on any click
outside itself, which between them made it impossible to watch the model
while adjusting it. An ordinary floating window leaves the 3D view live,
clickable and orbitable, and stays put until Cancel, Save as copy or its
close button.

The displacement follows the sliders. TextureDisplacementLayer gains a
transient preview_adjust that decode_height_texture() applies on top of the
decoded image, before smoothing. Everything downstream of the decode then
sees the adjusted image without being told the panel exists: the relief, the
colour, the thumbnails, the UV backdrop and the bake. It is not serialized by
cereal or the .3mf, because it is the state of an open editor rather than a
property of the layer; the panel reverts it on every exit that is not a
commit, and a commit clears it before pointing the layer at the file that
already carries the edit, so the adjustment is never applied twice.

Two caches had to learn about it or they would have gone on serving the
pixels from before the slider moved, since neither the image_data pointer nor
the smoothing value changes while a slider is dragged: the new adjusted-copy
cache keys on (image, adjustments), the smoothed cache now keys on both, and
the GPU thumbnail, height and colour textures and the UV editor backdrop
carry the adjustment in their keys alongside smoothing. Two tests cover
exactly that: the same image_data with two different adjustments must decode
to two different images, with and without smoothing on.

The recompute is lazy. A slider move marks the preview dirty and the existing
"rebuild once the mouse is released" rule in the panel consumes it, so a drag
recomputes the displacement once, on release, instead of once per frame. The
panel's own image still updates continuously, from a background pass over a
384 px copy, so the photo tracks the slider while the model catches up after.
2026-10-12 08:18:58 +02:00
ExPikaPaka c07165888b feat: add an image-adjustment panel to texture displacement layers
Adds brightness, contrast, sharpness, blur and hue over one layer's image,
shown on the image itself and updating as the sliders move. Reached from an
"Adjust image" button in the layer card, beside Smoothing.

What the panel 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. That is deliberate,
and the panel says so. It is also why brightness is a lift toward white
rather than an additive offset: an offset drives everything above mid-grey
to a flat 255, and since the image is the height map, that region would bake
as flat relief instead of bright relief.

Three exits: Cancel discards, Overwrite replaces the layer's image_data, and
Save as copy writes a new PNG into the user's texture library and points the
layer at it. Overwrite and Save as copy both hand the layer a brand new
allocation, which is what invalidates decode_height_texture()'s cache,
analyze_texture_detail()'s, and the GPU thumbnail and height/colour
textures, all of them keyed by that pointer.

Grayscale in, grayscale out: a grey image is not promoted to RGB, since that
would offer the colour feature a row of identical greys and triple the
stored PNG for it. Hue is disabled for a grayscale source, with the reason
shown, because grey has no hue to turn.

The live preview runs on its own thread over a 384 px downscale, one pass in
flight at a time so a drag coalesces to wherever the sliders stop. 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), which is
far too much to repeat per slider frame; the same pass on the downscale is
3-4 ms and 9-12 ms. Not the plater's job worker either: that is a single
FIFO shared with Bake, where a per-frame image filter would queue behind
multi-second mesh work. The full-size pass runs once, on commit, on the UI
thread, because the rebuild_preview() immediately after it is an order of
magnitude more work synchronously already.

The image maths lands in libslic3r as apply_texture_adjustments() and
downscale_height_texture(), so it is testable without a GUI, and the blur
reuses the same smooth_height_pixels() the Smoothing slider drives.
2026-10-12 07:56:54 +02:00
19 changed files with 1200 additions and 1045 deletions
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2.5,2.5h11v11h-11z" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><path d="M8,2.5v11M2.5,8h11" style="fill:none;stroke:#949494;stroke-dasharray:1.5,1.5"/><path d="M4.5,11.5l3.5-6l3.5,6z" style="fill:#009688"/></svg>

Before

Width:  |  Height:  |  Size: 340 B

-22
View File
@@ -353,24 +353,6 @@ Model Model::read_from_file(const std::string&
ObjInfo obj_info;
ObjParser::MtlData mtl_data;
result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data);
// Keep the model's own UV channel on the volume, whatever the colour import then does with
// the mesh: it is what TextureProjectionMethod::ModelUV maps a texture-displacement layer
// through, so a model that arrives with a UV layout is textured on that layout instead of on
// a fresh unwrap. load_obj() fills obj_info.uvs per face of the mesh it built, in the same
// order (and only when every face named a known material, hence the size check). V is flipped
// on the way in, to the convention DecodedHeightTexture samples in - the same flip
// obj_to_textured_mesh() does for the colour importer.
if (result && !obj_info.uvs.empty() && !model.objects.empty() && !model.objects.back()->volumes.empty()) {
ModelVolume &volume = *model.objects.back()->volumes[0];
const indexed_triangle_set &its = volume.mesh().its;
if (obj_info.uvs.size() == its.indices.size()) {
volume.texture_uv.resize(its.indices.size() * 3);
for (size_t fi = 0; fi < obj_info.uvs.size(); ++fi)
for (int k = 0; k < 3; ++k)
volume.texture_uv[fi * 3 + size_t(k)] =
Vec2f(obj_info.uvs[fi][size_t(k)].x(), 1.f - obj_info.uvs[fi][size_t(k)].y());
}
}
if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) {
// Textured OBJ: hand the mesh + materials to the texture-to-color importer
// instead of the flat per-face colour dialog.
@@ -2151,10 +2133,6 @@ void ModelVolume::reset_extra_facets()
// than left referring to a mesh that no longer matches it.
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
this->texture_displacement_facet(i).reset();
// Same reasoning for the model's own UV channel: it is indexed by triangle, so against a mesh
// this no longer matches it would map the texture by another mesh's triangle numbering. A caller
// that can carry it across - see transfer_uv_channel() - puts it back after this.
this->reset_texture_uv();
}
std::optional<TriangleSelector::SavedPainting> ModelVolume::save_painting() const
+2 -23
View File
@@ -974,26 +974,6 @@ public:
// they are not a property of any single layer.
TextureDisplacementOptions texture_displacement_options;
// The model's own texture coordinates, as the file it was imported from carried them: three per
// triangle of mesh().its, in its triangle order (corner k of triangle i at 3i+k), V already
// flipped to the sampling convention (V = 0 is the image's top row). Empty when the source had
// none, which is every STL and every 3mf - only a textured OBJ brings one in today.
//
// Per corner rather than per vertex because that is what a UV layout is: the two sides of a seam
// are the same vertex with two different coordinates, and collapsing them would smear the texture
// across every island boundary. TextureProjectionMethod::ModelUV maps through it, which is what
// lets a model that already carries a UV channel be textured on the artist's own layout instead
// of on a fresh unwrap.
//
// Indexed by triangle, so anything that *replaces* the mesh has to bring it across (or drop it):
// see transfer_uv_channel(), and reset_texture_uv() for the cases that cannot.
std::vector<Vec2f> texture_uv;
bool has_texture_uv() const { return !texture_uv.empty() && texture_uv.size() == mesh().its.indices.size() * 3; }
// Forgets the channel. For a mesh change that nothing can carry it across - a split, a boolean -
// where leaving it would map the texture by another mesh's triangle numbering.
void reset_texture_uv() { texture_uv.clear(); }
// Save painting data before reset_extra_facets() discards it.
// Used for replacing mesh without losing painting data.
// Only for model parts (not modifiers/connectors).
@@ -1256,7 +1236,6 @@ private:
texture_displacement_facets_6(other.texture_displacement_facets_6), texture_displacement_facets_7(other.texture_displacement_facets_7),
texture_displacement_layers(other.texture_displacement_layers),
texture_displacement_options(other.texture_displacement_options),
texture_uv(other.texture_uv),
cut_info(other.cut_info), text_configuration(other.text_configuration), emboss_shape(other.emboss_shape)
{
assert(this->id().valid());
@@ -1355,7 +1334,7 @@ private:
cereal::load_by_value(ar, f);
mesh_changed |= tf != f.timestamp();
}
ar(texture_displacement_layers, texture_displacement_options, texture_uv);
ar(texture_displacement_layers, texture_displacement_options);
cereal::load_by_value(ar, config);
cereal::load(ar, text_configuration);
cereal::load(ar, emboss_shape);
@@ -1379,7 +1358,7 @@ private:
cereal::save_by_value(ar, fuzzy_skin_facets);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
cereal::save_by_value(ar, texture_displacement_facet(i));
ar(texture_displacement_layers, texture_displacement_options, texture_uv);
ar(texture_displacement_layers, texture_displacement_options);
cereal::save_by_value(ar, config);
cereal::save(ar, text_configuration);
cereal::save(ar, emboss_shape);
+314 -272
View File
@@ -157,12 +157,33 @@ struct SmoothedTextureCache
{
std::weak_ptr<const std::vector<unsigned char>> source;
float smoothing = 0.f;
// Part of the key, not of the result: smoothing is applied *after* the panel's adjustments, so a
// blur computed over one set of adjustments is not the blur another set would give.
TextureAdjustments adjust;
DecodedHeightTexture texture;
};
std::mutex mutex;
std::unordered_map<const void *, Entry> entries;
};
SmoothedTextureCache g_smoothed_texture_cache;
// The adjusted copy, cached between the raw decode and the smoothed one. Needed for the same reason the
// smoothed cache is: while a slider in the image-adjustment panel moves, the thumbnail, the shaded
// preview's height and colour textures, the UV editor backdrop and the displacement preview job all ask
// for the same (image, adjustments) pair within one frame, and each of them running a full-size resample
// of its own is what would make the panel unusable on a large texture.
struct AdjustedTextureCache
{
struct Entry
{
std::weak_ptr<const std::vector<unsigned char>> source;
TextureAdjustments adjust;
DecodedHeightTexture texture;
};
std::mutex mutex;
std::unordered_map<const void *, Entry> entries;
};
AdjustedTextureCache g_adjusted_texture_cache;
} // namespace
namespace {
@@ -338,6 +359,34 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), result };
}
// The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before,
// because `preview_adjust` is an edit to the image while `smoothing` is a live low-pass over whatever
// the image is - the same order the panel presents them in, and the same order a commit bakes in.
//
// Nothing applies here once the panel has closed: it resets preview_adjust to identity, and identity
// is a no-op that costs one comparison.
if (!layer.preview_adjust.identity()) {
bool have_adjusted = false;
{
std::lock_guard<std::mutex> lock(g_adjusted_texture_cache.mutex);
auto it = g_adjusted_texture_cache.entries.find(key);
if (it != g_adjusted_texture_cache.entries.end() && it->second.adjust == layer.preview_adjust &&
it->second.source.lock() == layer.image_data) {
result = it->second.texture;
have_adjusted = true;
}
}
if (!have_adjusted) {
result = apply_texture_adjustments(result, layer.preview_adjust);
std::lock_guard<std::mutex> lock(g_adjusted_texture_cache.mutex);
auto &entries = g_adjusted_texture_cache.entries;
for (auto it = entries.begin(); it != entries.end();)
it = it->second.source.expired() ? entries.erase(it) : std::next(it);
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data),
layer.preview_adjust, result };
}
}
// Smoothing radius scales with the texture so the same slider value blurs the same *fraction* of
// the image whatever resolution it came in at, and stays continuous in the slider - see
// smooth_height_pixels(). The cap is a cost limit, not part of the mapping: the blur is
@@ -348,7 +397,7 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
std::lock_guard<std::mutex> lock(g_smoothed_texture_cache.mutex);
auto it = g_smoothed_texture_cache.entries.find(key);
if (it != g_smoothed_texture_cache.entries.end() && it->second.smoothing == layer.smoothing &&
it->second.source.lock() == layer.image_data)
it->second.adjust == layer.preview_adjust && it->second.source.lock() == layer.image_data)
return it->second.texture;
}
const float span = 0.05f * float(std::min(result.width, result.height));
@@ -372,12 +421,70 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
auto &entries = g_smoothed_texture_cache.entries;
for (auto it = entries.begin(); it != entries.end();)
it = it->second.source.expired() ? entries.erase(it) : std::next(it);
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), layer.smoothing, result };
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), layer.smoothing,
layer.preview_adjust, result };
}
return result;
}
namespace {
// Fraction of the short side that TextureAdjustments::blur == 1 blurs over, and the cost cap on it.
// Deliberately the same pair decode_height_texture() uses for the Smoothing slider - see
// adjustment_blur_radius().
constexpr float SOURCE_BLUR_SPAN = 0.05f;
constexpr float SOURCE_BLUR_MAX_PX = 48.f;
// Radius of the unsharp mask's blurred reference, in texels. Small and only mildly resolution-dependent:
// a sharpen is meant to act on the finest detail the image holds, so its radius tracks the texel grid
// rather than the picture's size. 1024x1024 gives ~2 texels, the usual photo-editor default.
float unsharp_radius(int width, int height)
{
return std::max(1.f, 0.002f * float(std::min(width, height)));
}
// Brightness as a *lift* toward white (or toward black) rather than an additive offset: positive b maps
// v to v + b*(1 - v), negative to v*(1 + b). Both reach the endpoint exactly at |b| == 1 while staying
// monotonic and non-clipping in between, which an additive shift does not - it drives a whole region to
// a flat 255 and, because this image is also the height map, that region bakes as flat relief rather
// than as bright relief. Keeping the curve injective keeps the detail in it.
float apply_brightness(float v, float b)
{
return b >= 0.f ? v + b * (1.f - v) : v * (1.f + b);
}
// Contrast about the 0.5 pivot. Gain is 1/(1-c) going up (c == 1 would be an infinite-slope threshold, so
// the caller's clamp keeps it finite) and 1+c going down, reaching a flat mid-grey at c == -1.
float apply_contrast(float v, float c)
{
const float gain = c >= 0.f ? 1.f / std::max(1e-3f, 1.f - c) : 1.f + c;
return (v - 0.5f) * gain + 0.5f;
}
// Blur then sharpen one 8-bit channel in place. Shared by the grayscale path and by each of a colour
// image's three channels, so every channel gets the identical spatial treatment and a colour image's
// hue cannot shift just because its channels were filtered differently.
void adjust_channel_spatial(std::vector<uint8_t> &channel, int width, int height, float blur, float sharpness)
{
if (blur > 0.f)
smooth_height_pixels(channel, width, height, adjustment_blur_radius(blur, width, height));
if (sharpness <= 0.f)
return;
// Unsharp mask: push each texel away from its local average by `amount` times the difference. The
// blurred reference is a copy, so the sharpen reads the un-sharpened neighbourhood everywhere rather
// than its own partly-written output.
std::vector<uint8_t> base = channel;
smooth_height_pixels(base, width, height, unsharp_radius(width, height));
const float amount = 1.5f * std::clamp(sharpness, 0.f, 1.f);
tbb::parallel_for(tbb::blocked_range<size_t>(0, channel.size()), [&](const tbb::blocked_range<size_t> &r) {
for (size_t i = r.begin(); i < r.end(); ++i) {
const float v = float(channel[i]) + amount * (float(channel[i]) - float(base[i]));
channel[i] = uint8_t(std::lround(std::clamp(v, 0.f, 255.f)));
}
});
}
struct TextureDetailCache
{
std::mutex mutex;
@@ -386,6 +493,150 @@ struct TextureDetailCache
TextureDetailCache g_texture_detail_cache;
} // namespace
float adjustment_blur_radius(float blur, int width, int height)
{
if (blur <= 0.f || width <= 0 || height <= 0)
return 0.f;
const float span = SOURCE_BLUR_SPAN * float(std::min(width, height));
return std::clamp(blur, 0.f, 1.f) * std::min(span, SOURCE_BLUR_MAX_PX);
}
DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px)
{
if (src.empty() || max_px <= 0 || (src.width <= max_px && src.height <= max_px))
return src;
const int scale = std::max(1, (std::max(src.width, src.height) + max_px - 1) / max_px);
const int w = std::max(1, src.width / scale);
const int h = std::max(1, src.height / scale);
const bool color = src.has_color();
DecodedHeightTexture out;
out.width = w;
out.height = h;
out.pixels.resize(size_t(w) * size_t(h));
if (color)
out.rgb.resize(size_t(w) * size_t(h) * 3);
tbb::parallel_for(tbb::blocked_range<int>(0, h), [&](const tbb::blocked_range<int> &r) {
for (int y = r.begin(); y < r.end(); ++y)
for (int x = 0; x < w; ++x) {
// The source block this destination texel covers. The max() keeps the block non-empty
// when rounding would otherwise collapse it.
const int x0 = x * src.width / w, x1 = std::max(x0 + 1, (x + 1) * src.width / w);
const int y0 = y * src.height / h, y1 = std::max(y0 + 1, (y + 1) * src.height / h);
uint32_t sum_h = 0, sum_c[3] = { 0, 0, 0 }, n = 0;
for (int sy = y0; sy < y1 && sy < src.height; ++sy)
for (int sx = x0; sx < x1 && sx < src.width; ++sx, ++n) {
const size_t si = size_t(sy) * size_t(src.width) + size_t(sx);
sum_h += src.pixels[si];
if (color)
for (int c = 0; c < 3; ++c)
sum_c[c] += src.rgb[si * 3 + size_t(c)];
}
const size_t di = size_t(y) * size_t(w) + size_t(x);
out.pixels[di] = n > 0 ? uint8_t(sum_h / n) : 0;
if (color)
for (int c = 0; c < 3; ++c)
out.rgb[di * 3 + size_t(c)] = n > 0 ? uint8_t(sum_c[c] / n) : 0;
}
});
return out;
}
DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj)
{
DecodedHeightTexture out = src;
if (src.empty() || adj.identity())
return out;
const int w = out.width;
const int h = out.height;
const size_t n = size_t(w) * size_t(h);
const float brightness = std::clamp(adj.brightness, -1.f, 1.f);
// Short of 1, so apply_contrast()'s gain stays finite; at this value it is already a 1000x slope,
// i.e. a threshold in all but name.
const float contrast = std::clamp(adj.contrast, -1.f, 0.999f);
const float blur = std::clamp(adj.blur, 0.f, 1.f);
const float sharpness = std::clamp(adj.sharpness, 0.f, 1.f);
// The tonal pass: a 256-entry lookup table, because brightness and contrast are the same scalar
// curve for every texel and every channel. Building it once beats evaluating two curves a few
// million times, and it is what keeps this pass off the panel's critical path.
std::array<uint8_t, 256> tone{};
for (int i = 0; i < 256; ++i) {
float v = float(i) / 255.f;
v = apply_brightness(v, brightness);
v = apply_contrast(v, contrast);
tone[size_t(i)] = uint8_t(std::lround(std::clamp(v, 0.f, 1.f) * 255.f));
}
if (!out.has_color()) {
// Grayscale: one channel, and hue has nothing to turn.
tbb::parallel_for(tbb::blocked_range<size_t>(0, n), [&](const tbb::blocked_range<size_t> &r) {
for (size_t i = r.begin(); i < r.end(); ++i)
out.pixels[i] = tone[out.pixels[i]];
});
adjust_channel_spatial(out.pixels, w, h, blur, sharpness);
return out;
}
// Colour: tone each channel through the same table, then rotate the hue about the luminance axis.
//
// The rotation is feColorMatrix's hueRotate matrix (SVG filters), which holds Rec.709 luma fixed.
// The height below is taken with decode_height_texture()'s Rec.601 coefficients instead, so a hue
// turn is not perfectly height-neutral - a few levels at full saturation. Reconciling the two would
// mean a different rotation than every other tool's, which is the worse trade: the user is watching
// the colour, and the height follows the colour by construction either way.
const float theta = adj.hue_deg * float(M_PI) / 180.f;
const float c = std::cos(theta);
const float s = std::sin(theta);
const float m[9] = {
0.213f + c * 0.787f - s * 0.213f, 0.715f - c * 0.715f - s * 0.715f, 0.072f - c * 0.072f + s * 0.928f,
0.213f - c * 0.213f + s * 0.143f, 0.715f + c * 0.285f + s * 0.140f, 0.072f - c * 0.072f - s * 0.283f,
0.213f - c * 0.213f - s * 0.787f, 0.715f - c * 0.715f + s * 0.715f, 0.072f + c * 0.928f + s * 0.072f,
};
const bool turn_hue = adj.hue_deg != 0.f;
tbb::parallel_for(tbb::blocked_range<size_t>(0, n), [&](const tbb::blocked_range<size_t> &r) {
for (size_t i = r.begin(); i < r.end(); ++i) {
const float rr = float(tone[out.rgb[i * 3]]);
const float gg = float(tone[out.rgb[i * 3 + 1]]);
const float bb = float(tone[out.rgb[i * 3 + 2]]);
const float nr = turn_hue ? m[0] * rr + m[1] * gg + m[2] * bb : rr;
const float ng = turn_hue ? m[3] * rr + m[4] * gg + m[5] * bb : gg;
const float nb = turn_hue ? m[6] * rr + m[7] * gg + m[8] * bb : bb;
out.rgb[i * 3] = uint8_t(std::lround(std::clamp(nr, 0.f, 255.f)));
out.rgb[i * 3 + 1] = uint8_t(std::lround(std::clamp(ng, 0.f, 255.f)));
out.rgb[i * 3 + 2] = uint8_t(std::lround(std::clamp(nb, 0.f, 255.f)));
}
});
// The spatial pass wants one channel at a time (smooth_height_pixels() is a single-channel blur, and
// reusing it is what keeps this consistent with Smoothing), so de-interleave, filter, re-interleave.
if (blur > 0.f || sharpness > 0.f) {
std::vector<uint8_t> channel(n);
for (int ch = 0; ch < 3; ++ch) {
for (size_t i = 0; i < n; ++i)
channel[i] = out.rgb[i * 3 + size_t(ch)];
adjust_channel_spatial(channel, w, h, blur, sharpness);
for (size_t i = 0; i < n; ++i)
out.rgb[i * 3 + size_t(ch)] = channel[i];
}
}
// Height is the luminance of the colour that came out, not the toned luminance of the colour that
// went in: the two differ once hue rotation or a per-channel clamp has moved the channels around,
// and a height that disagrees with the colour it is stored next to is the bug this avoids.
tbb::parallel_for(tbb::blocked_range<size_t>(0, n), [&](const tbb::blocked_range<size_t> &r) {
for (size_t i = r.begin(); i < r.end(); ++i)
out.pixels[i] = uint8_t(std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] +
0.114 * out.rgb[i * 3 + 2]));
});
return out;
}
TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer)
{
TextureDetail out;
@@ -1672,165 +1923,6 @@ std::vector<Vec2f> compute_lscm_corner_uvs(const indexed_triangle_set &patch, co
return corner;
}
// Barycentric weights of `p` inside triangle (a, b, c), as the pair that belongs with the edges
// (b - a) and (c - a): p ~= (1 - w1 - w2) * a + w1 * b + w2 * c. `p` is projected onto the triangle's
// plane, so a point a hair off the surface still reports the weights of its foot point. A degenerate
// triangle reports its centroid, the only coordinate that means anything there.
static void barycentric_weights(const Vec3f &a, const Vec3f &b, const Vec3f &c, const Vec3f &p, float &w1, float &w2)
{
const Vec3f e0 = b - a, e1 = c - a, ep = p - a;
const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1);
const float den = d00 * d11 - d01 * d01;
if (std::abs(den) <= 1e-20f) {
w1 = w2 = 1.f / 3.f;
return;
}
w1 = (d11 * dp0 - d01 * dp1) / den;
w2 = (d00 * dp1 - d01 * dp0) / den;
}
std::vector<Vec2f> compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
const std::vector<Vec2f> &model_uv,
const indexed_triangle_set &patch,
const std::vector<int> &patch_source)
{
if (patch.indices.empty() || model_uv.size() != base_mesh.indices.size() * 3 ||
patch_source.size() != patch.indices.size())
return {};
std::vector<Vec2f> corner(patch.indices.size() * 3, Vec2f::Zero());
for (size_t j = 0; j < patch.indices.size(); ++j) {
const int S = patch_source[j];
if (S < 0 || size_t(S) >= base_mesh.indices.size())
continue; // no source triangle: leave (0, 0), which the callers read as "no placement"
const stl_triangle_vertex_indices &src = base_mesh.indices[size_t(S)];
const Vec3f &a = base_mesh.vertices[size_t(src[0])];
const Vec3f &b = base_mesh.vertices[size_t(src[1])];
const Vec3f &c = base_mesh.vertices[size_t(src[2])];
const Vec2f *uv = &model_uv[size_t(S) * 3];
const stl_triangle_vertex_indices &tri = patch.indices[j];
for (int k = 0; k < 3; ++k) {
// An unsplit patch triangle is its source triangle, so its corners land exactly on a, b
// and c and this reproduces their uvs unchanged; a sub-triangle the brush cut gets the
// interpolated coordinate, which is the whole point of going through the barycentric.
float w1 = 0.f, w2 = 0.f;
barycentric_weights(a, b, c, patch.vertices[size_t(tri[k])], w1, w2);
corner[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2];
}
}
return corner;
}
std::vector<Vec2f> collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector<Vec2f> &corner)
{
if (patch.indices.empty() || corner.size() != patch.indices.size() * 3)
return {};
std::vector<Vec2f> per_vertex(patch.vertices.size(), Vec2f::Zero());
std::vector<char> seen(patch.vertices.size(), 0);
for (size_t j = 0; j < patch.indices.size(); ++j)
for (int k = 0; k < 3; ++k) {
const int vi = patch.indices[j][k];
if (vi < 0 || size_t(vi) >= per_vertex.size() || seen[size_t(vi)])
continue;
seen[size_t(vi)] = 1;
per_vertex[size_t(vi)] = corner[j * 3 + size_t(k)];
}
return per_vertex;
}
std::vector<Vec2f> transfer_uv_channel(const indexed_triangle_set &src_mesh,
const std::vector<Vec2f> &src_uv,
const indexed_triangle_set &dst_mesh,
float max_distance)
{
if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_uv.size() != src_mesh.indices.size() * 3)
return {};
const AABBTreeIndirect::Tree3f tree =
AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(src_mesh.vertices, src_mesh.indices);
const float limit2 = max_distance >= 0.f ? max_distance * max_distance : std::numeric_limits<float>::max();
std::vector<Vec2f> out(dst_mesh.indices.size() * 3, Vec2f::Zero());
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst_mesh.indices.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t j = range.begin(); j < range.end(); ++j) {
const stl_triangle_vertex_indices &tri = dst_mesh.indices[j];
for (int k = 0; k < 3; ++k) {
const Vec3f &p = dst_mesh.vertices[size_t(tri[k])];
size_t hit = 0;
Vec3f foot;
const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(
src_mesh.vertices, src_mesh.indices, tree, p, hit, foot);
if (d2 < 0.f || d2 > limit2 || hit >= src_mesh.indices.size())
continue; // nowhere near the source surface: (0, 0), as documented
const stl_triangle_vertex_indices &st = src_mesh.indices[hit];
float w1 = 0.f, w2 = 0.f;
barycentric_weights(src_mesh.vertices[size_t(st[0])], src_mesh.vertices[size_t(st[1])],
src_mesh.vertices[size_t(st[2])], foot, w1, w2);
const Vec2f *uv = &src_uv[hit * 3];
out[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2];
}
}
});
return out;
}
std::vector<uint8_t> transfer_facet_paint(const indexed_triangle_set &src_mesh,
const TriangleSelector::TriangleSplittingData &src_paint,
const indexed_triangle_set &dst_mesh)
{
if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_paint.bitstream.empty())
return {};
const TriangleMesh src(src_mesh);
TriangleSelector selector(src);
selector.deserialize(src_paint, false);
// Every sub-triangle of the source, each carrying its own state - NONE included, so the soup
// covers the whole surface and a foot point always lands on something. One soup rather than one
// tree per state: a foot point has one nearest triangle, and that triangle's state is the answer.
std::vector<indexed_triangle_set> per_state;
selector.get_facets(per_state);
indexed_triangle_set soup;
std::vector<uint8_t> label;
for (size_t st = 0; st < per_state.size(); ++st) {
const indexed_triangle_set &part = per_state[st];
if (part.indices.empty())
continue;
const int base = int(soup.vertices.size());
soup.vertices.insert(soup.vertices.end(), part.vertices.begin(), part.vertices.end());
for (const stl_triangle_vertex_indices &t : part.indices) {
soup.indices.emplace_back(t[0] + base, t[1] + base, t[2] + base);
label.push_back(uint8_t(st));
}
}
if (soup.indices.empty())
return {};
const AABBTreeIndirect::Tree3f tree =
AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(soup.vertices, soup.indices);
std::vector<uint8_t> out(dst_mesh.indices.size(), 0);
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst_mesh.indices.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t j = range.begin(); j < range.end(); ++j) {
const stl_triangle_vertex_indices &t = dst_mesh.indices[j];
// The centroid, not a corner: a facet takes one state, and its middle is the point that
// is inside it whatever the triangle looks like.
const Vec3f centroid = (dst_mesh.vertices[size_t(t[0])] + dst_mesh.vertices[size_t(t[1])] +
dst_mesh.vertices[size_t(t[2])]) / 3.f;
size_t hit = 0;
Vec3f foot;
const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(soup.vertices, soup.indices,
tree, centroid, hit, foot);
if (d2 >= 0.f && hit < label.size())
out[j] = label[hit];
}
});
return out;
}
namespace {
// apply_uv_transform()'s per-layer constants, worked out once. Triplanar sampling runs the transform
// three times per point, and recomputing the rotation's cos/sin and the tiling reciprocal on every one
@@ -1840,16 +1932,13 @@ struct UVTransform
float scale, cs, sn, aspect;
Vec2f offset;
// `normalized_uv` drops the two steps that only mean something for a coordinate measured on the
// model - the millimetre tiling scale and the non-square aspect correction - and keeps the
// rotation and offset. See apply_uv_transform(); TextureProjectionMethod::ModelUV is what needs it.
UVTransform(const TextureDisplacementLayer &layer, float aspect_, bool normalized_uv = false)
UVTransform(const TextureDisplacementLayer &layer, float aspect_)
{
scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
cs = std::cos(rad);
sn = std::sin(rad);
aspect = normalized_uv ? 1.f : aspect_;
aspect = aspect_;
offset = layer.offset;
}
Vec2f operator()(const Vec2f &planar) const
@@ -1874,11 +1963,9 @@ inline Vec3f triplanar_weights(const Vec3f &normal)
}
} // namespace
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv)
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect)
{
// A uv that is already normalized has no millimetre scale to divide by and no aspect to correct -
// see the header. UVTransform does the same, and the two must stay in step.
const float scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
const Vec2f scaled = planar * scale;
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
@@ -1896,7 +1983,7 @@ Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &la
// Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a
// shear, and doing it the other way round would make "Rotation" skew the pattern instead of
// turning it.
if (!normalized_uv && aspect > 0.f && aspect != 1.f)
if (aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
return rotated + layer.offset;
@@ -1921,9 +2008,6 @@ float blend_displacement(float accumulated, float value, TextureBlendMode mode)
value;
return accumulated / divisor;
}
// Replaces instead of combining, so `accumulated` is deliberately unused: whatever the layers
// below put here is gone where this one is painted.
case TextureBlendMode::Override: return value;
case TextureBlendMode::Add:
default: return accumulated + value;
}
@@ -1953,21 +2037,14 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
// apply_uv_transform()). Every projection except the projective "from view" one funnels through
// here, so this one line is what makes them all aspect-correct.
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
// A ModelUV layer's precomputed uv is the model's own, already normalized; every other coordinate
// reaching the transform - the unwrap's, and every analytic projection's - is in millimetres. The
// `lscm_uv` test is what keeps the two apart: a ModelUV layer with no channel gets none, falls
// through to the millimetre methods below, and must be scaled like them.
const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV &&
lscm_uv != nullptr;
const UVTransform xf(layer, aspect, normalized_uv);
const UVTransform xf(layer, aspect);
auto sample_at = [&](const Vec2f &planar) {
return texture.sample(xf(planar), layer.tile_enabled, layer.tile_method);
};
// A precomputed per-patch uv - the LSCM solve, or the model's own channel - wins over the layer's
// own method (see the header): the caller only passes it when the patch actually has one, so a
// patch that failed to unwrap, or a volume with no UV channel, falls through to the analytic
// methods below as its documented fallback.
// Precomputed per-patch LSCM solve wins over the layer's own method (see the header): the
// caller only passes it when the patch actually parameterized successfully, so a patch that
// failed to unwrap falls through to the analytic methods below as its documented fallback.
if (lscm_uv != nullptr)
return sample_at(*lscm_uv);
@@ -1990,8 +2067,7 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
// Flat projection onto the captured projector plane. Single-valued per point, so unlike
// blended triplanar it is one sample, and it is what "project from view" places.
return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up)));
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::ModelUV: // the volume carries no UV channel - same fallback
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar:
default: break;
}
@@ -2026,12 +2102,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
// the two differ in what "nothing here" means. Height returns 0, which is a perfectly good height
// (no displacement); colour has no such neutral value - black is a colour - so every path that
// returns 0 there has to report false here instead, and the caller leaves the triangle uncoloured.
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
// Same reasoning as sample_layer_height(), and it has to match it exactly or a layer's colour and
// its relief would be read from different places in the image.
const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV &&
lscm_uv != nullptr;
const UVTransform xf(layer, aspect, normalized_uv);
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
const UVTransform xf(layer, aspect);
auto sample_at = [&](const Vec2f &planar) {
return texture.sample_color(xf(planar), layer.tile_enabled, layer.tile_method);
};
@@ -2045,8 +2117,6 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
};
if (lscm_uv != nullptr) {
// The coverage test goes through the same transform, so an untiled ModelUV layer is "there"
// exactly where the model's own uv lands inside the image.
if (!covered(*lscm_uv))
return false;
out = sample_at(*lscm_uv);
@@ -2086,8 +2156,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
out = sample_at(p);
return true;
}
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::ModelUV: // the volume carries no UV channel - same fallback
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar:
default: break;
}
@@ -2392,10 +2461,9 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
TextureBakeStats *stats)
{
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data, model_uv);
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data);
if (!combined)
return mesh; // nothing decodable to displace with
@@ -2565,8 +2633,7 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
// part of it (half a tile in the neighbour's colour, a tile edge that wanders).
const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize);
const ColorFieldSampler color_sampler =
want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure, model_uv) :
ColorFieldSampler{};
want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure) : ColorFieldSampler{};
//
// The *palette* index, not the printed filament. The decimation treats any edge whose two faces
// differ as a crease (TextureBakeDecimate.cpp), so it must only ever see where the **perceived**
@@ -2698,8 +2765,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
TextureBakeStats *stats)
{
// The classic path moves the vertices the mesh already has, so there is no budget to report on.
(void) stats;
@@ -2736,7 +2802,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (options.pipeline_v2)
return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, color, flip_normals,
debug, stats, model_uv);
debug, stats);
// Layers are combined in slot order, like stacked layers in an image editor: each one folds its
// own displacement into the running total via its blend mode (see TextureBlendMode).
@@ -2854,19 +2920,13 @@ static indexed_triangle_set build_texture_displacement_in_place(
selector_dirty = true;
const bool color_this_layer = want_color && layer->color_enabled;
// The model's own channel is per base-mesh *triangle*, so reading it needs to know which base
// triangle each (possibly brush-split) patch triangle came from - the colour pass's map, asked
// for here as well.
const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV &&
model_uv != nullptr && model_uv->size() == mesh.indices.size() * 3;
// A flat-colour image is matched against the filaments alone (see TextureColorRequest).
const ColorQuantizeFn &layer_quantize =
(color_this_layer && color->quantize_pure && analyze_texture_detail(*layer).flat_colors) ? color->quantize_pure
: color->quantize;
std::vector<int> patch_source; // sub-triangle -> base mesh triangle; built when colouring or mapping by ModelUV
std::vector<int> patch_source; // sub-triangle -> base mesh triangle, only built when colouring
const indexed_triangle_set patch =
selector.get_facets_strict(EnforcerBlockerType::ENFORCER,
(color_this_layer || model_uv_layer) ? &patch_source : nullptr);
selector.get_facets_strict(EnforcerBlockerType::ENFORCER, color_this_layer ? &patch_source : nullptr);
if (patch.indices.empty())
continue;
// get_facets_strict() returns the same vertex array whichever state is asked for (only the
@@ -2897,25 +2957,18 @@ static indexed_triangle_set build_texture_displacement_in_place(
// A real unwrap of the whole patch, computed once here rather than per vertex - it is a
// per-chart solve over the whole patch, not a per-point formula. Cached, so repeating this
// for every slider tweak costs a hash rather than a re-solve (see compute_patch_unwrap()).
// ModelUV takes its corners from the volume's own channel instead of solving anything, and
// collapses them to one per vertex the same way the unwrap does.
const std::vector<Vec2f> model_corner_uvs = model_uv_layer ?
compute_model_corner_uvs(mesh, *model_uv, patch, patch_source) :
std::vector<Vec2f>{};
const std::vector<Vec2f> patch_uvs = model_uv_layer ? collapse_corner_uvs(patch, model_corner_uvs) :
(layer->projection_method == TextureProjectionMethod::LSCM) ?
const std::vector<Vec2f> lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_uvs(patch, *layer) :
std::vector<Vec2f>{};
// The colour pass below samples per *triangle*, so it takes the per-corner unwrap instead: the
// per-vertex collapse above would hand a triangle at a seam the island layout did not join its
// neighbour's placement, painting one triangle per face from the wrong part of the texture.
// (The displacement itself stays on patch_uvs - a vertex has one position, so one height.)
const std::vector<Vec2f> patch_corner_uvs = model_uv_layer ? model_corner_uvs :
(layer->projection_method == TextureProjectionMethod::LSCM) ?
// (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.)
const std::vector<Vec2f> lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_corner_uvs(patch, *layer) :
std::vector<Vec2f>{};
const bool corner_uv_ok = !patch_corner_uvs.empty() &&
patch_corner_uvs.size() == patch.indices.size() * 3;
const bool corner_uv_ok = !lscm_corner_uvs.empty() &&
lscm_corner_uvs.size() == patch.indices.size() * 3;
// Colour, if this layer carries any. Area-weighted over each base triangle's *painted* part,
// so a triangle the brush only clipped a corner off takes the colour of that corner rather
@@ -2945,7 +2998,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
// the per-vertex height sampling uses, evaluated at the centroid instead.
Vec3f n = Vec3f::Zero();
Vec2f uv = Vec2f::Zero();
bool have_uv = !patch_uvs.empty();
bool have_uv = !lscm_uvs.empty();
for (int k = 0; k < 3; ++k) {
const int vi = t[k];
if (vi < int(vertex_normals.size()))
@@ -2953,9 +3006,9 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (!have_uv)
continue;
if (corner_uv_ok)
uv += patch_corner_uvs[j * 3 + size_t(k)];
else if (size_t(vi) < patch_uvs.size())
uv += patch_uvs[size_t(vi)];
uv += lscm_corner_uvs[j * 3 + size_t(k)];
else if (size_t(vi) < lscm_uvs.size())
uv += lscm_uvs[size_t(vi)];
else
have_uv = false;
}
@@ -3029,7 +3082,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
[&](const tbb::blocked_range<size_t> &range) {
for (size_t k = range.begin(); k < range.end(); ++k) {
const size_t vi = size_t(layer_vertices[k]);
const Vec2f *lscm_uv = patch_uvs.empty() ? nullptr : &patch_uvs[vi];
const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[vi];
const float h = sample_layer_height(height, *layer, mesh.vertices[vi], vertex_normals[vi],
patch_centroid, patch_axis, lscm_uv);
// midlevel is the height that means "stay put", so anything below it displaces
@@ -3162,8 +3215,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const TextureColorRequest *color,
const Transform3d &volume_to_world,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
TextureBakeStats *stats)
{
// An untransformed volume on an untransformed instance is by far the common case, and the round
// trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take
@@ -3173,7 +3225,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const Transform3d frame = texture_displacement_bake_frame(volume_to_world);
if (frame.matrix().isApprox(Transform3d::Identity().matrix()))
return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color,
false, debug, stats, model_uv);
false, debug, stats);
const Transform3d to_local = frame.inverse();
// A mirroring placement leaves the positions correct but every winding-derived normal pointing
@@ -3187,10 +3239,8 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
v = (frame * v.cast<double>()).cast<float>();
const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0;
// The UV channel rides along untouched: a uv is a coordinate in the texture, not on the model, so
// the frame the mesh is measured in does not reach it.
indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options,
progress, color, mirrored, debug, stats, model_uv);
progress, color, mirrored, debug, stats);
// Everything the bake recorded is in world millimetres, like `out` itself. The debug view draws in
// the volume's local frame, so the stages are brought back the same way the result is.
if (debug != nullptr)
@@ -3262,8 +3312,7 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume)
return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data,
volume.texture_displacement_options, {}, nullptr,
texture_displacement_volume_to_world(volume), nullptr, nullptr,
volume.has_texture_uv() ? &volume.texture_uv : nullptr);
texture_displacement_volume_to_world(volume));
}
void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t> &movable, float strength,
@@ -3327,18 +3376,16 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t>
namespace {
// One decoded texture + placement per sampleable layer, in blend (slot) order. Held by shared_ptr so
// the returned closure owns it for as long as the subdivider keeps calling back.
// A per-triangle uv map turned into something a *point* sampler can use. Neither of the two maps that
// are not a formula from position to uv - the LSCM unwrap and the model's own UV channel - can be
// evaluated at an arbitrary point directly, so a point is placed on the painted patch (the nearest
// patch triangle, and its barycentric coordinates there) and the uv is interpolated from that
// triangle's own per-corner uvs. Exact for a point on the base surface, which is where both samplers
// are queried: the displacement samples refined positions before moving them, and the colour pass
// samples the foot point on the painted pieces.
// An unwrap turned into something a *point* sampler can use. LSCM has no formula from position to
// uv - it is a per-triangle map - so a point is placed on the painted patch (the nearest patch
// triangle, and its barycentric coordinates there) and the uv is interpolated from that triangle's own
// per-corner uvs. Exact for a point on the base surface, which is where both samplers are queried: the
// displacement samples refined positions before moving them, and the colour pass samples the foot
// point on the painted pieces.
struct LscmLookup {
indexed_triangle_set patch; // the layer's painted patch, as the uvs were computed on
indexed_triangle_set patch; // the layer's painted patch, as the unwrap was solved on
AABBTreeIndirect::Tree3f tree;
// compute_lscm_corner_uvs(patch, layer), or compute_model_corner_uvs() for a ModelUV layer
std::vector<Vec2f> corner;
std::vector<Vec2f> corner; // compute_lscm_corner_uvs(patch, layer)
// False when `pos` is not on this layer's patch (farther than `tol`): there is no uv there, so the
// layer contributes nothing - the same as a non-tiled texture outside its placement.
@@ -3351,9 +3398,15 @@ struct LscmLookup {
if (d2 < 0.f || d2 > tol * tol || hit >= patch.indices.size())
return false;
const stl_triangle_vertex_indices &t = patch.indices[hit];
float w1 = 0.f, w2 = 0.f; // a degenerate triangle takes its centroid's uv
barycentric_weights(patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])], patch.vertices[size_t(t[2])],
foot, w1, w2);
const Vec3f &a = patch.vertices[size_t(t[0])], &b = patch.vertices[size_t(t[1])], &c = patch.vertices[size_t(t[2])];
const Vec3f e0 = b - a, e1 = c - a, ep = foot - a;
const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1);
const float den = d00 * d11 - d01 * d01;
float w1 = 1.f / 3.f, w2 = 1.f / 3.f; // a degenerate triangle takes its centroid's uv
if (std::abs(den) > 1e-20f) {
w1 = (d11 * dp0 - d01 * dp1) / den;
w2 = (d00 * dp1 - d01 * dp0) / den;
}
const Vec2f *c3 = &corner[hit * 3];
uv = (1.f - w1 - w2) * c3[0] + w1 * c3[1] + w2 * c3[2];
return true;
@@ -3412,7 +3465,7 @@ struct PreparedLayer {
// where a layer is placed. `need_color` additionally drops layers that cannot contribute colour.
std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
const indexed_triangle_set &base_mesh, const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data, bool need_color, const std::vector<Vec2f> *model_uv)
const TextureDisplacementFacetsData &facets_data, bool need_color)
{
auto prepared = std::make_shared<std::vector<PreparedLayer>>();
@@ -3443,14 +3496,9 @@ std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
if (tex.empty() || (need_color && !tex.has_color()))
continue;
const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV &&
model_uv != nullptr && model_uv->size() == base_mesh.indices.size() * 3;
TriangleSelector selector(selector_mesh);
selector.deserialize(data, false);
std::vector<int> patch_source; // only the model's own channel needs it; see the classic path
const indexed_triangle_set patch =
selector.get_facets_strict(EnforcerBlockerType::ENFORCER, model_uv_layer ? &patch_source : nullptr);
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
continue;
@@ -3460,16 +3508,12 @@ std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal);
std::shared_ptr<const LscmLookup> lscm;
if (model_uv_layer || layer->projection_method == TextureProjectionMethod::LSCM) {
if (layer->projection_method == TextureProjectionMethod::LSCM) {
// Solved on the very patch the classic path and the GUI solve it on (same geometry, seam
// angle and edges), so it hits the unwrap cache and lands exactly where the UV editor
// shows it, hand-placed islands and UV edits included. A ModelUV layer needs no solve at
// all - its corners are read straight out of the volume's channel - but it is looked up
// through the same per-triangle machinery, for the same reason: there is no formula from a
// point to its uv.
// shows it, hand-placed islands and UV edits included.
auto l = std::make_shared<LscmLookup>();
l->corner = model_uv_layer ? compute_model_corner_uvs(base_mesh, *model_uv, patch, patch_source) :
compute_lscm_corner_uvs(patch, *layer);
l->corner = compute_lscm_corner_uvs(patch, *layer);
if (l->corner.size() == patch.indices.size() * 3) {
l->patch = patch;
l->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(l->patch.vertices, l->patch.indices);
@@ -3495,12 +3539,11 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
ColorQuantizeFn quantize_pure,
const std::vector<Vec2f> *model_uv)
ColorQuantizeFn quantize_pure)
{
if (!quantize)
return nullptr;
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true, model_uv);
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true);
if (prepared->empty())
return nullptr;
// Per layer: a flat-colour image is matched against the filaments alone, when that quantizer
@@ -3531,10 +3574,9 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
const std::vector<Vec2f> *model_uv)
const TextureDisplacementFacetsData &facets_data)
{
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false, model_uv);
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false);
if (prepared->empty())
return nullptr;
+78 -114
View File
@@ -86,17 +86,6 @@ enum class TextureProjectionMethod : int
// that is inherent to view projection and is the user's call, not a bug. The projector's two
// in-plane axes live in TextureDisplacementLayer::view_project_right/up.
ViewProjected = 4,
// The model's own texture coordinates, as the file it was imported from carried them (see
// ModelVolume::texture_uv). Nothing is unwrapped or projected: the artist's layout *is* the
// placement, so a texture authored against that layout - the model's own albedo map, say - lands
// exactly where it was painted. Automatically picked for a new layer on a volume that has a UV
// channel, since re-unwrapping a model that already carries one can only be worse than using it.
//
// The uv is already normalized, so unlike every method above it is not measured in millimetres:
// "Tile size" and the non-square aspect correction do not apply to it (the layer's rotation and
// offset still do). Falls back to Triplanar when the volume has no channel, exactly as LSCM falls
// back when a patch cannot be flattened.
ModelUV = 5,
};
// Dihedral angle (degrees) above which an edge between two painted triangles becomes a chart seam
@@ -132,11 +121,6 @@ enum class TextureBlendMode : int
Subtract = 1, // acc - value (carve this layer's relief out of the layers below)
Multiply = 2, // acc * (value / 1mm) (mask/modulate the layers below by this layer)
Divide = 3, // acc / (value / 1mm) (inverse mask; guarded against a zero/near-zero divisor)
// acc = value: this layer replaces what is under it instead of combining with it, so where it is
// painted only its own relief remains. The image editor's "Normal" mode, under the name the panel
// uses. Note the stack's *colour* already behaves this way - a higher slot's colour overwrites a
// lower one's where they overlap - so this is what makes the displacement agree with it.
Override = 4,
};
// Combines one layer's signed displacement `value` (mm) into `accumulated` (mm) per `mode`.
@@ -170,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
@@ -309,6 +325,18 @@ struct TextureDisplacementLayer
// for the boundary to be drawn on.
bool color_enabled = false;
// Live, unsaved image adjustments from the gizmo's image-adjustment panel, applied by
// decode_height_texture() on top of the decoded image - so while the panel is open the relief, the
// colour, the thumbnails and the bake all see the adjusted image, and moving a slider really does
// change the displacement. See TextureAdjustments.
//
// Deliberately *not* serialized, by either save()/load() here or the .3mf: it is the state of an open
// editor, not a property of the layer. The panel resets it when it closes, and "Save as copy" resolves
// it by writing a new image and pointing the layer at that - which is the only way an adjustment
// becomes permanent. A project saved mid-adjustment therefore stores the unadjusted image, which is
// the honest answer: nothing was committed.
TextureAdjustments preview_adjust;
bool empty() const { return !image_data || image_data->empty(); }
template<class Archive> void save(Archive &ar) const
@@ -505,6 +533,36 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
// texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read.
bool height_texture_has_color(const TextureDisplacementLayer &layer);
// Applies `adj` to `src` and returns the result; `src` is left alone. An empty `src`, or an identity
// `adj`, gives back a copy.
//
// Order is tonal, then spatial: brightness, contrast and hue per texel first, then blur, then sharpness.
// Sharpness last is what lets the two spatial knobs be used together - blur to kill speckle, then
// sharpness to put the real edges back - instead of the blur simply undoing the sharpen.
//
// Grayscale in, grayscale out (`rgb` stays empty): promoting a grey image to RGB would hand the layer's
// colour feature a row of identical greys to "colour" the model with, and triple the stored PNG for it.
// For a colour image the height is recomputed as the luminance of the *adjusted* colour, with
// decode_height_texture()'s own coefficients, so the relief and the colour can never drift apart.
DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj);
// Blur radius, in texels, that TextureAdjustments::blur == 1 means on a `min(width, height)`-sized image:
// the same 5%-of-the-short-side span the Smoothing slider uses, under the same 48-texel cost cap, so the
// two controls bite at the same rate and a user moving between them is not surprised.
float adjustment_blur_radius(float blur, int width, int height);
// Box-filters `src` down so neither side exceeds `max_px`, preserving aspect; never upscales, and
// returns a copy when it already fits. Colour and height are filtered the same way, and the height
// stays the height - it is not recomputed from the filtered colour, because a box filter is linear and
// luminance is linear in the channels, so the two agree already.
//
// This is what lets the adjustment panel's live preview cost depend on the size of the box it is drawn
// in rather than on the size of the image: adjusting a downscaled copy per slider frame, and the
// full-size image only once, on commit. A box filter (rather than a plain decimation) is the point -
// these textures are high-frequency by nature, and dropping texels would alias the preview into
// showing detail the adjustment is not actually producing.
DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px);
// Maps a linear RGB colour in [0, 1] to an index into the caller's palette, or -1 for "no colour".
//
// Deliberately a callback rather than a function here: matching a colour to a filament is a
@@ -557,13 +615,7 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is
// `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the
// square case and leaves the coordinate exactly as it always was.
//
// `normalized_uv` says the coordinate is already a uv rather than a millimetre measurement on the
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tiling scale (mm
// per tile is meaningless for it) nor the aspect correction (the artist's layout already accounts for
// the image's proportions) applies, and only the rotation and offset are left.
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f,
bool normalized_uv = false);
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f);
// Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a
// local-space point, writing the resulting texture uv. Returns false - and leaves `uv` untouched -
@@ -713,80 +765,6 @@ std::vector<Vec2f> compute_lscm_uvs(const indexed_triangle_set &patch, const Tex
// Returns an empty vector if the patch has no triangles or the unwrap carries no source_face map.
std::vector<Vec2f> compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer);
// Three UVs per patch triangle, read out of the model's *own* UV channel - the
// TextureProjectionMethod::ModelUV counterpart of compute_lscm_corner_uvs(), and laid out the same way
// (corner k of triangle i at index 3i+k).
//
// `model_uv` is the volume's channel: three uvs per triangle of `base_mesh`, in its triangle order
// (see ModelVolume::texture_uv). `patch_source` maps each patch triangle back to the base-mesh
// triangle it was cut from - TriangleSelector::get_facets_strict()'s out_source. A patch triangle is
// always a piece of exactly one base triangle, so each of its corners is interpolated barycentrically
// inside that triangle's three uvs. That is what keeps a sub-triangle the brush split (whose corners
// are not base-mesh vertices at all) on the texture, and what keeps the two sides of a UV seam
// reading from their own island instead of from one averaged coordinate.
//
// Returns an empty vector when the channel does not match `base_mesh`, when `patch_source` does not
// match `patch`, or when either is empty - the callers treat that as "no placement" and fall back,
// exactly as they do for an unwrap that failed.
std::vector<Vec2f> compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
const std::vector<Vec2f> &model_uv,
const indexed_triangle_set &patch,
const std::vector<int> &patch_source);
// One UV per patch vertex, collapsed from a per-corner array: the first corner to reach a vertex wins.
// This is the per-corner/per-vertex split compute_lscm_uvs() documents, applied to any corner array -
// displacement is per-vertex, so a vertex on a seam has to settle on one of its copies.
// Returns an empty vector if `corner` is not three per patch triangle.
std::vector<Vec2f> collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector<Vec2f> &corner);
// Resamples a per-corner UV channel from `src_mesh` onto `dst_mesh`, for when something replaces a
// volume's mesh and the model's own UV channel has to come along: the colour import's painted mesh,
// the gizmo's remesh/subdivide, the default bake pipeline's rebuild. Each destination corner takes the
// uv of its closest point on the source surface, interpolated barycentrically inside the source
// triangle it lands on - the same transfer LscmLookup does per point, run over every corner.
//
// Exact wherever the two meshes describe the same surface, which is the case for every caller here
// (they subdivide, repair or displace it, they do not reshape it). A corner that lands nowhere near
// the source - farther than `max_distance` (negative: no limit) - takes (0, 0).
//
// One thing a positional transfer cannot reproduce: a *seam* vertex, where one position carries a
// different uv in each island that meets there. Position alone does not say which island a corner
// belongs to, so corners on a seam all take whichever island the nearest-triangle lookup lands on,
// and the texture can be off by up to one triangle in a band along the island borders. Doing better
// needs a triangle-to-triangle correspondence, which only some of the stages that replace a mesh
// have (a remesh has none at all).
//
// Returns an empty vector when `src_uv` is not three per source triangle, or when either mesh is empty.
std::vector<Vec2f> transfer_uv_channel(const indexed_triangle_set &src_mesh,
const std::vector<Vec2f> &src_uv,
const indexed_triangle_set &dst_mesh,
float max_distance = -1.f);
// Carries a per-facet paint annotation - the colour paint above all - from a mesh onto the mesh a
// bake produced from it: every triangle of `dst_mesh` takes the state painted at its own foot point on
// `src_mesh`, i.e. on the nearest point of the surface it was built from. Returned as one
// EnforcerBlockerType per `dst_mesh` triangle, 0 (NONE) where that foot point carried no paint.
//
// ModelVolume::restore_painting() cannot do this. Its remap pairs a source paint triangle with a
// target triangle that *overlaps* it to within 0.01 mm, which is right for a remesh or a
// simplification - they leave the surface where it was - and useless after a bake, which moves the
// surface by the relief depth. Measured on a model displaced by a tenth of its own size, that remap
// kept a quarter of the colour and left the rest unpainted; this keeps essentially all of it, because
// a foot point is exactly as far away as the relief is deep and the lookup does not care how far that
// is.
//
// The source's *sub*-triangles are what is looked up, not its triangles, so the part of a triangle a
// brush stroke only clipped a corner off keeps its own state rather than rounding to the whole
// triangle's. No distance limit is needed or wanted: the nearest point on the source surface is where
// a baked triangle came from however far the relief carried it, and an unpainted origin reports NONE
// on its own.
//
// Returns an empty vector when `src_paint` is empty or either mesh is - the caller then has nothing to
// carry, which is not the same as "carry nothing".
std::vector<uint8_t> transfer_facet_paint(const indexed_triangle_set &src_mesh,
const TriangleSelector::TriangleSplittingData &src_paint,
const indexed_triangle_set &dst_mesh);
// The TextureDisplacementLayer::lscm_uv_overrides key for one unwrapped vertex (an index into PatchUnwrap::uvs).
inline int lscm_uv_override_key(int unwrapped_vertex) { return -(unwrapped_vertex + 1); }
@@ -845,10 +823,6 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
//
// `color`, when given, also reports which filament each triangle should print in - see
// TextureColorRequest.
//
// `model_uv`, when given, is the volume's own UV channel (three uvs per base-mesh triangle, see
// ModelVolume::texture_uv) and is what a TextureProjectionMethod::ModelUV layer maps through. A layer
// set to ModelUV without it falls back to Triplanar.
struct TextureColorRequest
{
// RGB -> palette index. Supplied by the GUI, which owns both the perceptual matching and the list
@@ -923,9 +897,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
// stage, already brought back into `base_mesh`'s frame.
BakeStageRecorder *debug = nullptr,
// When given, receives what the bake spent.
TextureBakeStats *stats = nullptr,
// The model's own UV channel; see above.
const std::vector<Vec2f> *model_uv = nullptr);
TextureBakeStats *stats = nullptr);
// `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own.
// The mesh is shared by every instance, so a multi-instance object can only be baked for one of
@@ -1000,15 +972,11 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
ColorQuantizeFn quantize_pure = nullptr,
// The model's own UV channel, for ModelUV layers; see
// build_texture_displacement().
const std::vector<Vec2f> *model_uv = nullptr);
ColorQuantizeFn quantize_pure = nullptr);
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
const std::vector<Vec2f> *model_uv = nullptr);
const TextureDisplacementFacetsData &facets_data);
// Uniformly subdivides `mesh` (every triangle recursively split into 4 via edge midpoints, using a
// shared cache so a midpoint is computed once and reused by both triangles on either side of that
@@ -1213,10 +1181,6 @@ struct TextureDisplacementPrepareResult
{
indexed_triangle_set mesh;
TextureDisplacementFacetsData masks;
// The model's own UV channel, resampled onto `mesh` (see transfer_uv_channel()). Empty when the
// volume had none. Like the masks it is handed back rather than left to the caller to recompute,
// because only the run itself knows which stages replaced the mesh.
std::vector<Vec2f> uv;
// The remesh landed but no layer's paint survived being carried onto it. Nothing is committed:
// everything downstream is driven by that paint, so baking on would bake a flat mesh.
bool paint_lost = false;
-21
View File
@@ -25,7 +25,6 @@
#include "TextureToColor/ColorUtils.hpp"
#include "Model.hpp"
#include "TextureDisplacement.hpp"
#include "TextureToColor/TriMesh.hpp"
#include "TriangleMesh.hpp"
#include "TriangleSelector.hpp"
@@ -555,29 +554,9 @@ bool apply_painted_mesh_to_volume(
<< ", unit_scale=" << unit_scale << ")";
}
// The model's own UV channel is indexed by triangle, and this replaces the triangles, so it has
// to be resampled onto them. Kept rather than dropped because it is the layout the model was
// authored against: a texture-displacement layer set to ModelUV maps through it, and the colour
// import is exactly the path a model that carries one arrives by.
const indexed_triangle_set old_its = volume.has_texture_uv() ? volume.mesh().its : indexed_triangle_set{};
const std::vector<Vec2f> old_uv = volume.texture_uv;
volume.set_mesh(std::move(new_mesh));
volume.calculate_convex_hull();
// Resampled here and not further down, while the two meshes still share a frame: the painted mesh
// was just shifted by the same source.mesh_offset the old one was centred by, so the only thing
// between them is whatever drift the repair introduced. The re-centering below translates the mesh
// again, and a translation is exactly what a closest-point transfer cannot see through - it would
// read every uv off a point that far away. A uv is a coordinate in the texture, so it does not care
// about either shift; it just has to be read before the second one happens.
if (!old_its.indices.empty())
volume.texture_uv = transfer_uv_channel(old_its, old_uv, volume.mesh().its);
else
// No channel to carry, so make sure none is left behind either: a leftover of the right length
// would read as a valid channel against a mesh it was never measured on.
volume.reset_texture_uv();
// Re-center the replaced mesh so its bbox center sits at the origin,
// matching what center_geometry_after_creation did for the original mesh.
// CGAL repair / subdivision may shift the bbox center (drift); without
@@ -43,6 +43,7 @@
#include <cstdint>
#include <functional>
#include <chrono>
#include <future>
#include <cstdio>
#include <glad/gl.h>
#include <imgui.h>
@@ -120,6 +121,14 @@ constexpr int THUMBNAIL_MAX_PX = 128;
// blur of it - which is what made Fast look flatter and softer than the result it was previewing.
constexpr int HEIGHT_TEX_MAX_PX = 2048;
// Size the image-adjustment panel's live preview is computed and uploaded at. The panel draws the image
// in a box about 240 px wide, so there is nothing to show above this - and the preview is recomputed on
// every slider move, which is what makes its cost worth bounding. Measured on a 1024x1024 with every
// knob engaged, a full-size pass is 13-19 ms grayscale / 40-60 ms in colour (130-190 ms at 2048 in
// colour); the same pass on a 384 px copy is 3-4 ms / 9-12 ms, and it runs off the UI thread besides.
// The full-size image is only ever adjusted once, on commit.
constexpr int ADJUST_PREVIEW_MAX_PX = 384;
// How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine
// band (see collect_paint_region()). Two is enough to grade the size change without the band's own
// cost growing to matter: it is a ring around a perimeter, not an area.
@@ -525,6 +534,10 @@ void GLGizmoTextureDisplacement::on_shutdown()
// progress poll, and its completion handler then finds nothing to do.
m_preview_generation->fetch_add(1);
m_preview_job_pending = false;
// The adjustment panel holds a full-size decode of its layer's image, which is the largest thing
// here; a closed gizmo should not keep it alive. Discards the edit, as closing the gizmo on an open
// panel implies.
close_image_adjust();
// The palette caches hold the last volume's images; a closed gizmo should not keep them alive.
m_palette_cache.clear();
m_palette_quantizer = nullptr;
@@ -1221,33 +1234,9 @@ indexed_triangle_set GLGizmoTextureDisplacement::patch_in_world(const indexed_tr
return world;
}
std::vector<Vec2f> GLGizmoTextureDisplacement::model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
{
const ModelVolume *mv = texture_volume();
if (layer.projection_method != TextureProjectionMethod::ModelUV || patch_source == nullptr || mv == nullptr ||
!mv->has_texture_uv())
return {};
// The channel is indexed against the volume's *local* mesh, and a uv is a coordinate in the
// texture rather than on the model, so unlike every projected method this never goes through
// patch_in_world() - there is no millimetre measurement here for a world scale to change.
std::vector<Vec2f> uv = compute_model_corner_uvs(mv->mesh().its, mv->texture_uv, local_patch, *patch_source);
for (Vec2f &p : uv)
// Already a uv: no tiling scale, no aspect correction - see apply_uv_transform().
p = apply_uv_transform(p, layer, 1.f, /* normalized_uv */ true);
return uv;
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
const TextureDisplacementLayer &layer) const
{
if (layer.projection_method == TextureProjectionMethod::ModelUV)
// Collapsed to one per vertex the same way the unwrap is - a vertex has one position, so the
// displacement can only read one height for it.
return collapse_corner_uvs(local_patch, model_uv_corners(local_patch, layer, patch_source));
// The bake maps the texture in world millimetres, so "Tile size (mm)" means the same thing on a
// scaled instance as it does on an untouched one (see build_texture_displacement()). Everything
// that has to agree with the bake - the fast preview's uvs, the checker/distortion overlays -
@@ -1307,11 +1296,8 @@ int GLGizmoTextureDisplacement::layer_projection_frame(const indexed_triangle_se
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
const TextureDisplacementLayer &layer) const
{
if (layer.projection_method == TextureProjectionMethod::ModelUV)
return model_uv_corners(local_patch, layer, patch_source);
if (layer.projection_method == TextureProjectionMethod::LSCM) {
const indexed_triangle_set patch = patch_in_world(local_patch);
const float aspect = layer_texture_aspect(layer);
@@ -1321,7 +1307,7 @@ std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_corner_uvs(const in
return uv;
}
// Single-valued per point: fan the per-vertex result out over the corners.
const std::vector<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer, patch_source);
const std::vector<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer);
if (per_vertex.size() != local_patch.vertices.size())
return {};
std::vector<Vec2f> corner(local_patch.indices.size() * 3);
@@ -1342,11 +1328,7 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh()
// Uses the *live* selector (not the flushed model facet data), so this reflects an in-progress
// stroke immediately rather than only once it ends - the point of this preview mode is to be
// the fast, no-CPU-meshing one.
// The source map comes along because a ModelUV layer's uvs are read per base-mesh triangle; every
// other mapping ignores it.
std::vector<int> patch_source;
const indexed_triangle_set patch =
m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source);
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
return;
@@ -1370,8 +1352,7 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh()
// Per *corner*, not per vertex: the mesh below is flat (unshared) anyway, so each triangle can
// carry its own chart's UVs - see compute_layer_corner_uvs().
const TextureDisplacementLayer *active = active_layer();
std::vector<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active, &patch_source) :
std::vector<Vec2f>{};
std::vector<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector<Vec2f>{};
m_shaded_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3;
if (!m_shaded_preview_uses_vertex_uv)
corner_uv.clear();
@@ -2028,9 +2009,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
const ModelVolume *mv = texture_volume();
if (mv == nullptr || m_triangle_selectors.empty())
return;
std::vector<int> patch_source; // only ModelUV reads it; see compute_layer_vertex_uvs()
const indexed_triangle_set patch =
m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source);
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
return;
// Everything below works in the *patch's* vertex space, not the mesh's. Those agree only until a
@@ -2044,7 +2023,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
// The checker samples wherever the projection puts it; the projections the shader can't
// reconstruct (LSCM, ViewProjected) get a precomputed per-vertex uv, the rest project in-shader.
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer, &patch_source);
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer);
const bool have_uvs = uv.size() == patch.vertices.size();
m_uvcheck_uses_vertex_uv = have_uvs;
m_uvcheck_projection_mode = have_uvs ? 0 :
@@ -2053,7 +2032,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
// different uv in each island it borders, so the shared-vertex form drew one triangle per face from
// a neighbouring island's placement. Only the *drawing* needs this; the distortion metric below is
// a per-vertex average by construction and keeps using `uv`.
const std::vector<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer, &patch_source);
const std::vector<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer);
const bool have_corner_uvs = have_uvs && corner_uv.size() == patch.indices.size() * 3;
// Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible.
@@ -2329,8 +2308,6 @@ void GLGizmoTextureDisplacement::queue_preview_job()
if (input.options.pipeline_v2)
input.options.v2_max_triangles_k = 0;
input.volume_to_world = texture_displacement_volume_to_world(*mv);
if (mv->has_texture_uv())
input.model_uv = mv->texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data();
// Captured here rather than read in the handler: the palette is main-thread state, and the preview
@@ -2486,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) {
@@ -3712,10 +3692,9 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
// Paint / Erase, brush cursor shape, mapping, tiling, placement and the dock toggle.
"texture_displacement_add.svg", "texture_displacement_add_negative.svg", "circle_paint.svg",
"menu_obj_sphere.svg", "menu_obj_cube.svg", "menu_obj_cylinder.svg", "texture_displacement_map_unwrap.svg",
"texture_displacement_map_view.svg", "texture_displacement_map_model_uv.svg",
"texture_displacement_tile_repeat.svg", "menu_mirror_x.svg",
"texture_displacement_map_view.svg", "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg",
"texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg",
"texture_displacement_move_down.svg", "texture_displacement_drag.svg",
"texture_displacement_move_down.svg", "texture_displacement_drag.svg", "cog.svg",
"texture_displacement_select_all.svg", "texture_displacement_erase_all.svg",
};
std::vector<std::string> paths;
@@ -3760,11 +3739,6 @@ void GLGizmoTextureDisplacement::add_texture_layer()
TextureDisplacementLayer layer;
layer.slot = free_slot;
// A model that came with a UV channel is mapped by it. Re-unwrapping such a model throws away the
// layout it was authored against and can only be worse than using it, so the default follows the
// model rather than the other way round; the Mapping row still offers every other method.
if (mv->has_texture_uv())
layer.projection_method = TextureProjectionMethod::ModelUV;
// 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.
@@ -4024,6 +3998,321 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
m_picker_open_request = true;
}
}
bool GLGizmoTextureDisplacement::load_image_adjust_source(const TextureDisplacementLayer &layer)
{
if (layer.empty())
return false;
// The *raw* image: neither the layer's Smoothing nor the panel's own preview_adjust, so what the
// sliders act on is always the image as it is actually stored rather than the last preview of it.
// Without clearing preview_adjust here the adjustment would compound on itself every reload.
TextureDisplacementLayer raw = layer;
raw.smoothing = 0.f;
raw.preview_adjust = TextureAdjustments{};
DecodedHeightTexture decoded = decode_height_texture(raw);
if (decoded.empty())
return false;
m_adjust_source = std::move(decoded);
m_adjust_preview_source = downscale_height_texture(m_adjust_source, ADJUST_PREVIEW_MAX_PX);
m_adjust_source_image = layer.image_data.get();
// The unadjusted image, so the box is filled now rather than one background pass later. Pairing it
// with an identity `shown` is what makes update_image_adjust_preview() relaunch for the sliders'
// current values against the new source instead of deciding it is already showing them.
m_adjust_preview_shown = TextureAdjustments{};
m_adjust_preview_tex = upload_height_thumbnail(m_adjust_preview_source, ADJUST_PREVIEW_MAX_PX);
m_adjust_preview_valid = m_adjust_preview_tex != nullptr;
return true;
}
void GLGizmoTextureDisplacement::open_image_adjust(const TextureDisplacementLayer &layer)
{
if (layer.empty() || layer.slot < 0)
return;
close_image_adjust(); // reverts and clears any previous panel
if (!load_image_adjust_source(layer))
return;
m_adjust = TextureAdjustments{};
m_adjust_applied = TextureAdjustments{};
m_adjust_slot = layer.slot;
const ModelVolume *mv = texture_volume();
m_adjust_volume_id = mv != nullptr ? mv->id().id : 0;
}
void GLGizmoTextureDisplacement::close_image_adjust()
{
// Revert the layer's live adjustment. Every exit that reaches here is a discard: a commit resets
// preview_adjust itself, before pointing the layer at the file that already carries the edit, so this
// cannot undo a committed adjustment.
//
// Found by the id recorded when the panel opened rather than through texture_volume(), which answers
// for the *current* selection. One of the ways to get here is the selection having moved on, and
// reverting the newly selected object's layer instead would both miss the adjustment that is still
// live on the original volume and clear one the user never touched.
bool reverted = false;
if (m_adjust_slot >= 0 && m_adjust_volume_id != 0)
for (ModelObject *mo : wxGetApp().model().objects) {
for (ModelVolume *mv : mo->volumes) {
if (mv->id().id != m_adjust_volume_id)
continue;
for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
if (l.slot == m_adjust_slot && !l.preview_adjust.identity()) {
l.preview_adjust = TextureAdjustments{};
reverted = true;
}
}
}
// std::async's future blocks here until the pass finishes. That is a few milliseconds on an image
// this small, and the alternative - a detached thread writing into the members of a closed panel -
// is worse.
if (m_adjust_preview_job.valid())
m_adjust_preview_job.wait();
m_adjust_preview_job = {};
m_adjust_slot = -1;
m_adjust = TextureAdjustments{};
m_adjust_applied = TextureAdjustments{};
m_adjust_source = DecodedHeightTexture{};
m_adjust_preview_source = DecodedHeightTexture{};
m_adjust_source_image = nullptr;
m_adjust_volume_id = 0;
m_adjust_preview_tex.reset();
m_adjust_preview_valid = false;
// Only while the gizmo is up: on_shutdown() also comes through here, and rebuilding a preview for a
// gizmo that is closing would be work for a model nobody is looking at. The reverted layer data is
// what matters there, and that is already done above.
if (reverted && m_state == On) {
rebuild_preview();
m_parent.set_as_dirty();
}
}
void GLGizmoTextureDisplacement::update_image_adjust_preview()
{
if (m_adjust_preview_source.empty())
return;
// Collect a finished pass. wait_for(0) rather than get() outright: this runs inside the frame, and
// blocking on the worker here would hand back to the UI thread the very stall the worker exists to
// spare it.
if (m_adjust_preview_job.valid() &&
m_adjust_preview_job.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
const DecodedHeightTexture adjusted = m_adjust_preview_job.get();
m_adjust_preview_job = {};
if (std::unique_ptr<GLTexture> tex = upload_height_thumbnail(adjusted, ADJUST_PREVIEW_MAX_PX)) {
m_adjust_preview_tex = std::move(tex);
m_adjust_preview_shown = m_adjust_preview_running;
m_adjust_preview_valid = true;
}
}
// One pass at a time: whatever the sliders did while it ran is picked up by the next launch, so a
// drag collapses to the value the sliders ended on instead of queuing one pass per frame.
if (!m_adjust_preview_job.valid() && m_adjust != m_adjust_preview_shown) {
m_adjust_preview_running = m_adjust;
// Captured by value: the pass must not read members the UI thread goes on editing while it runs.
m_adjust_preview_job = std::async(std::launch::async,
[src = m_adjust_preview_source, adj = m_adjust_preview_running] {
return apply_texture_adjustments(src, adj);
});
}
// A pass in flight has no other way to get its result on screen: without a redraw request the canvas
// can sit idle, and the preview would only catch up on the next unrelated frame.
if (m_adjust_preview_job.valid())
m_parent.set_as_dirty();
}
void GLGizmoTextureDisplacement::commit_image_adjust(TextureDisplacementLayer &layer)
{
if (m_adjust_source.empty() || m_adjust.identity()) {
// Nothing was changed, so there is nothing to save.
close_image_adjust();
return;
}
// The full-size pass, once: 13-19 ms grayscale / 40-60 ms colour on a 1024x1024 with every knob
// engaged. Run on the UI thread deliberately, unlike the live preview: this is a single click, and the
// rebuild that follows it - a CPU mesh displacement of the painted patch - is already an order of
// magnitude more work, synchronously, so moving only this off the thread would buy nothing a user
// could perceive.
const DecodedHeightTexture adjusted = apply_texture_adjustments(m_adjust_source, m_adjust);
std::string error;
const std::string stem = layer.name.empty() ? std::string("texture") : layer.name;
const std::optional<TextureLibraryEntry> entry = save_texture_to_library(adjusted, stem, error);
if (!entry) {
show_error(nullptr, error);
return; // the panel stays open, and the layer keeps the live adjustment: the edit is not lost
}
// Cleared *before* the layer is pointed at the new file, and this ordering is the whole correctness of
// the commit: the saved image already has the adjustment baked into its pixels, so leaving
// preview_adjust set would apply brightness, contrast and the rest a second time on top of itself.
layer.preview_adjust = TextureAdjustments{};
// set_layer_texture() does the rest - the name, the recently-used ordering, a fresh image_data buffer
// (which is what invalidates every cache keyed by the old pointer), the undo snapshot and the rebuild.
set_layer_texture(layer, *entry);
close_image_adjust();
}
void GLGizmoTextureDisplacement::render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max)
{
if (m_adjust_slot < 0)
return;
ModelVolume *mv = texture_volume();
// A different volume is selected than the one this panel was opened on. Slots are numbered per
// volume, so looking m_adjust_slot up here would find an unrelated layer rather than nothing.
if (mv == nullptr || mv->id().id != m_adjust_volume_id) {
close_image_adjust();
return;
}
TextureDisplacementLayer *layer = nullptr;
for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
if (l.slot == m_adjust_slot)
layer = &l;
if (layer == nullptr) {
// The layer went away under the panel (removed, or merged away by an undo).
close_image_adjust();
return;
}
// The layer's image can be swapped from the texture picker while this panel is open - the panel is a
// window now, so the rest of the gizmo stays usable behind it. Re-read the source when that happens,
// keeping the slider positions: the user is adjusting this layer, and the 3D view has already moved to
// the new image (decode_height_texture() reads layer.image_data, which changed), so re-reading here is
// also what keeps the panel's photo and the model showing the same thing.
if (layer->image_data.get() != m_adjust_source_image && !load_image_adjust_source(*layer)) {
close_image_adjust(); // the new image cannot be decoded; nothing left to adjust
return;
}
const float img_w = std::round(m_imgui->scaled(16.f));
const float gap = m_imgui->scaled(0.5f);
// Placed beside the panel on first appearance only, and left wherever the user drags it after that -
// it is a window, not a popup, so its position is theirs to keep.
const float win_w = img_w + 2.f * ImGui::GetStyle().WindowPadding.x;
const bool left = panel_min.x - gap - win_w >= 0.f;
ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap - win_w : panel_max.x + gap,
panel_min.y + m_imgui->scaled(4.f)), ImGuiCond_Appearing);
bool keep_open = true;
if (!ImGui::Begin((_u8L("Adjust image") + "##texture_adjust").c_str(), &keep_open,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
if (!keep_open)
close_image_adjust();
return;
}
// Collect a finished pass and start one for the current slider values before the image is drawn, so
// the newest available result is what lands on screen this frame.
update_image_adjust_preview();
// The photo itself, at the image's own aspect so a non-square texture is not stretched.
const float aspect = m_adjust_source.height > 0 ? float(m_adjust_source.width) / float(m_adjust_source.height) : 1.f;
const float img_h = std::round(img_w / std::max(0.05f, aspect));
if (m_adjust_preview_valid && m_adjust_preview_tex)
ImGui::Image((ImTextureID) (intptr_t) m_adjust_preview_tex->get_id(), ImVec2(img_w, img_h));
else
ImGui::Dummy(ImVec2(img_w, img_h));
// The label column is sized off the widest of the five labels, so the tracks line up whatever the
// translation does to their lengths.
float label_w = 0.f;
for (const wxString &l : { _L("Brightness"), _L("Contrast"), _L("Sharpness"), _L("Blur"), _L("Hue") })
label_w = std::max(label_w, ImGui::CalcTextSize(into_u8(l).c_str()).x);
label_w += m_imgui->scaled(1.2f);
// An explicit track width rather than a fill-to-the-right one: this window is AlwaysAutoResize, so its
// width comes from its content, and an item asking for "whatever is left" would make the width depend
// on itself. The image above sets the content width, and the sliders match it.
const float slider_w = std::max(m_imgui->scaled(4.f), img_w - label_w);
const auto row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max,
const char *format, bool enabled, const std::string &tip) {
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
m_imgui->text(label);
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
ImGui::SetNextItemWidth(slider_w);
m_imgui->disabled_begin(!enabled);
ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp);
m_imgui->disabled_end();
// Only on an enabled row: a disabled item does not report hover, so a tooltip on one would never
// be shown. What has to be said about a disabled slider is written out below it instead.
if (enabled && !tip.empty() && ImGui::IsItemHovered())
m_imgui->tooltip(tip, ImGui::GetFontSize() * 20.f);
};
row("##adj_brightness", _L("Brightness"), &m_adjust.brightness, -1.f, 1.f, "%.2f", true, {});
row("##adj_contrast", _L("Contrast"), &m_adjust.contrast, -1.f, 1.f, "%.2f", true, {});
row("##adj_sharpness", _L("Sharpness"), &m_adjust.sharpness, 0.f, 1.f, "%.2f", true,
_u8L("Brings out the finest detail in the image. Applied after Blur, so the two can be used "
"together: blur away the speckle, then sharpen the edges that remain."));
row("##adj_blur", _L("Blur"), &m_adjust.blur, 0.f, 1.f, "%.2f", true,
_u8L("Blurs the image itself. The layer's own Smoothing slider softens the relief without altering "
"the image, and is the one to reach for first."));
// Grey has no hue to turn, and the shipped library is grayscale - so on most textures this is a
// slider that would visibly do nothing. Disabled with the reason given, rather than hidden.
const bool has_color = m_adjust_source.has_color();
row("##adj_hue", _L("Hue"), &m_adjust.hue_deg, -180.f, 180.f, "%.0f°", has_color, {});
if (!has_color) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + img_w);
ImGui::TextDisabled("%s", _u8L("This texture is grayscale, so it has no hue to shift.").c_str());
ImGui::PopTextWrapPos();
}
if (m_imgui->button(_L("Reset")))
m_adjust = TextureAdjustments{};
// Hand the sliders to the layer, which is what puts the adjusted image in front of the displacement:
// decode_height_texture() applies preview_adjust, so the relief, the colour and the thumbnails all
// follow from here. Written every frame rather than only on a change, so an undo snapshot or a
// reselection that resets the layer cannot leave the model showing an adjustment the panel has moved
// on from.
layer->preview_adjust = m_adjust;
if (m_adjust != m_adjust_applied) {
m_adjust_applied = m_adjust;
// Marked dirty rather than rebuilt here: the panel's own flag is consumed by the main panel's
// "rebuild once the mouse is released" rule, so dragging a slider recomputes the displacement
// once, on release, instead of on every frame of the drag. That is what keeps the UI responsive
// while a full-size resample and a mesh displacement happen behind it.
m_preview_params_dirty = true;
// This window is drawn after the block that consumes that flag, so the rebuild happens on a later
// frame - which means there has to *be* one. Cheap insurance: a slider release that generated no
// further canvas event would otherwise leave the flag set and the model unchanged until something
// unrelated redrew.
m_parent.set_as_dirty();
}
ImGui::Separator();
m_imgui->text_wrapped(_u8L("The model updates when you release a slider. Save as copy writes the "
"adjusted image to your texture library and points this layer at it."), img_w);
const bool cancel = m_imgui->button(_L("Cancel"));
ImGui::SameLine();
// Nothing to save until a slider has actually moved.
m_imgui->disabled_begin(m_adjust.identity());
const bool as_copy = m_imgui->button(_L("Save as copy"));
m_imgui->disabled_end();
ImGui::End();
if (cancel || !keep_open)
close_image_adjust();
else if (as_copy)
commit_image_adjust(*layer);
}
void GLGizmoTextureDisplacement::remove_texture_layer(int slot)
{
@@ -4082,6 +4371,7 @@ void GLGizmoTextureDisplacement::swap_layer_slots(int slot_a, int slot_b)
std::swap(m_thumbnails[a], m_thumbnails[b]);
std::swap(m_thumbnail_source[a], m_thumbnail_source[b]);
std::swap(m_thumbnail_smoothing[a], m_thumbnail_smoothing[b]);
std::swap(m_thumbnail_adjust[a], m_thumbnail_adjust[b]);
std::swap(m_layer_expanded[a], m_layer_expanded[b]);
if (m_active_layer_slot == slot_a)
m_active_layer_slot = slot_b;
@@ -4910,8 +5200,7 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve
TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const std::vector<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings, const DisplacementProgressFn &progress,
BakeStageRecorder *debug)
{
@@ -4938,13 +5227,6 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
bool had_paint = false;
for (const TriangleSelector::TriangleSplittingData &m : masks)
had_paint = had_paint || TriangleSelector::has_facets(m, EnforcerBlockerType::ENFORCER);
// The model's own UV channel for whatever `mesh` currently is. Every stage below keeps the input's
// surface - it evens out, refines or cuts the triangles lying on it - so each corner reads its uv
// straight off `base`'s channel at its own position instead of being carried stage by stage, which
// would compound each stage's approximation. Untouched while nothing has replaced the mesh yet.
const auto uv_now = [&]() {
return (model_uv.empty() || !changed) ? model_uv : transfer_uv_channel(base, model_uv, mesh.its);
};
if (!report(1))
return out;
@@ -4991,11 +5273,9 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
// Feature-adaptive: sample the combined displacement so refinement follows texture
// curvature. A null sampler (only LSCM layers, or nothing decodable) falls back to the
// length baseline alone.
const std::vector<Vec2f> uv_stage = uv_now();
const std::vector<Vec2f> *uv_ptr = uv_stage.empty() ? nullptr : &uv_stage;
HeightFieldSampler sampler;
if (params.subdiv_feature)
sampler = make_combined_displacement_sampler(mesh.its, layers, current, uv_ptr);
sampler = make_combined_displacement_sampler(mesh.its, layers, current);
// Colour boundaries need triangles of their own - the chord test cannot see them, since
// the height field is perfectly smooth across a change of filament.
ColorFieldSampler color;
@@ -5003,7 +5283,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
// boundaries are refined where the bake will actually change filament.
if (params.subdiv_color_edge_mm > 0.f && !color_settings.empty())
color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(color_settings.palette),
make_palette_quantizer(color_settings.palette_pure), uv_ptr);
make_palette_quantizer(color_settings.palette_pure));
// The refinement follows perceived colour: a mix is one colour here, however the slicer
// interleaves its filaments layer by layer.
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down
@@ -5062,10 +5342,8 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
std::vector<uint8_t> cut_region(mesh.its.indices.size(), 0);
for (size_t i = 0; i < cut_region.size() && i < source.size(); ++i)
cut_region[i] = (region[size_t(source[i])] & REFINE_PAINTED) ? 1 : 0;
const std::vector<Vec2f> uv_refined = uv_now();
const HeightFieldSampler refined_sampler =
make_combined_displacement_sampler(mesh.its, layers, current,
uv_refined.empty() ? nullptr : &uv_refined);
make_combined_displacement_sampler(mesh.its, layers, current);
std::vector<int> cut_source;
size_t cuts = 0;
indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler,
@@ -5089,7 +5367,6 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
if (!report(100) || !changed)
return out; // an empty result: nothing to commit, which is not a failure
out.uv = uv_now();
out.mesh = std::move(mesh.its);
out.masks = std::move(current);
return out;
@@ -5358,7 +5635,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace
const size_t slot = size_t(layer.slot);
if (m_thumbnails[slot] && m_thumbnail_source[slot] == layer.image_data.get() &&
m_thumbnail_smoothing[slot] == layer.smoothing)
m_thumbnail_smoothing[slot] == layer.smoothing && m_thumbnail_adjust[slot] == layer.preview_adjust)
return m_thumbnails[slot].get();
// Reuses the already-decoded, already-cached grayscale pixels (see decode_height_texture()'s
@@ -5372,6 +5649,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace
m_thumbnails[slot] = std::move(texture);
m_thumbnail_source[slot] = layer.image_data.get();
m_thumbnail_smoothing[slot] = layer.smoothing;
m_thumbnail_adjust[slot] = layer.preview_adjust;
return m_thumbnails[slot].get();
}
@@ -5379,7 +5657,8 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp
{
if (layer.empty() || !layer.color_enabled)
return nullptr;
if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing)
if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing &&
m_color_tex_adjust == layer.preview_adjust)
return m_color_tex.get();
std::unique_ptr<GLTexture> texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
@@ -5391,6 +5670,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp
m_color_tex = std::move(texture);
m_color_tex_source = layer.image_data.get();
m_color_tex_smoothing = layer.smoothing;
m_color_tex_adjust = layer.preview_adjust;
return m_color_tex.get();
}
@@ -5401,7 +5681,8 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDis
// One slot, not one per layer: the preview shader only ever shades the *active* layer, so a single
// full-resolution upload is enough and the VRAM cost stays at one texture rather than eight.
if (m_height_tex && m_height_tex_source == layer.image_data.get() && m_height_tex_smoothing == layer.smoothing)
if (m_height_tex && m_height_tex_source == layer.image_data.get() && m_height_tex_smoothing == layer.smoothing &&
m_height_tex_adjust == layer.preview_adjust)
return m_height_tex.get();
std::unique_ptr<GLTexture> texture = upload_height_thumbnail(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
@@ -5411,6 +5692,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDis
m_height_tex = std::move(texture);
m_height_tex_source = layer.image_data.get();
m_height_tex_smoothing = layer.smoothing;
m_height_tex_adjust = layer.preview_adjust;
return m_height_tex.get();
}
@@ -5741,8 +6023,6 @@ void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareP
input.layers = mv->texture_displacement_layers;
input.params = params;
input.snapshot_name = snapshot_name;
if (mv->has_texture_uv())
input.model_uv = mv->texture_uv;
if (params.subdiv_color_edge_mm > 0.f)
input.color = color_settings_for(*mv);
@@ -6469,7 +6749,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
const std::string not_painted = _u8L("not painted");
const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 3.f * frame_h + 2.f * gap_s;
const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 4.f * frame_h + 3.f * gap_s;
const float name_w = std::max(frame_h, ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::GetCursorPosX() - gap_s - right_w);
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(orca.x, orca.y, orca.z, 0.2f));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(orca.x, orca.y, orca.z, 0.35f));
@@ -6483,6 +6763,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
ImGui::TextDisabled("%s", not_painted.c_str());
}
// Between the name and the order arrows: it acts on the image the name refers to, so it
// belongs with the name rather than with the controls that reorder the stack.
ImGui::SameLine();
m_imgui->disabled_begin(busy || layer.empty());
if (icon_button(613 + layer.slot, "cog.svg", frame_h, _L("Adjust image"),
_u8L("Brightness, contrast, sharpness, blur and hue, shown on the image and on the "
"model as you set them. Save as copy writes the result to your texture library "
"and points this layer at it.")))
open_image_adjust(layer);
m_imgui->disabled_end();
ImGui::SameLine();
m_imgui->disabled_begin(busy || li == 0);
if (icon_button(611, "texture_displacement_move_up.svg", frame_h, _L("Move up"), _L("Move up - applied earlier"))) {
@@ -6511,22 +6801,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad);
hover_tip(_u8L("Height of the relief: how far white in the texture lifts the surface, in "
"millimetres. Black does not move it at all, unless Midlevel says otherwise."));
// Mapped by the model's own UV layout there is no millimetre scale to set: the layout is the
// placement (see TextureProjectionMethod::ModelUV), so the slider would move nothing.
const bool tile_size_na = layer.projection_method == TextureProjectionMethod::ModelUV;
m_imgui->disabled_begin(tile_size_na);
m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f,
"%.2f mm", true, card_pad);
m_imgui->disabled_end();
// AllowWhenDisabled, or the one case that needs explaining is the one that cannot be hovered.
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
m_imgui->tooltip(tile_size_na ?
_u8L("Mapped by the model's own UV layout, which is already the placement - one "
"copy of the texture covers it exactly, so there is no tile size to set.") :
_u8L("How wide one copy of the texture is on the model. Smaller repeats the "
"pattern more often and makes its detail finer; with Tile off, this is the "
"size of the single copy."),
wrap_w);
m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad);
hover_tip(_u8L("How wide one copy of the texture is on the model. Smaller repeats the pattern "
"more often and makes its detail finer; with Tile off, this is the size of the "
"single copy."));
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad);
hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an "
"edge of the model."));
@@ -6633,7 +6911,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
}
// Mapping, as one icon per TextureProjectionMethod, in the enum's own order.
// Mapping, as five icons in TextureProjectionMethod's own order.
{
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
@@ -6661,26 +6939,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
{ "texture_displacement_map_view.svg", _L("From view"),
_L("From view - projects straight onto the painted area from where you are looking, like a slide "
"projector.") },
{ "texture_displacement_map_model_uv.svg", _L("Model UV"),
_L("Model UV - uses the texture coordinates the model itself came with, so a texture made for "
"that model lands exactly where it was painted. Nothing is unwrapped, and Tile size does "
"not apply: the model's own layout is the placement.") },
};
// Only a volume that actually carries a UV channel can be mapped by it. Shown
// greyed out rather than hidden, so the reason is visible instead of the control
// simply being missing.
const wxString model_uv_na =
(mv != nullptr && mv->has_texture_uv()) ?
wxString() :
_L("This model carries no texture coordinates of its own. Only a model imported with a UV "
"channel - a textured OBJ, say - can be mapped by them.");
for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) {
if (mi > 0)
ImGui::SameLine(0.f, gap_s);
const auto method = static_cast<TextureProjectionMethod>(mi);
const wxString na = (method == TextureProjectionMethod::ModelUV) ? model_uv_na : wxString();
if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label,
mappings[mi].tip, na) &&
mappings[mi].tip) &&
layer.projection_method != method) {
// Capture the current view the moment "From view" is chosen, so it does something
// sensible immediately rather than projecting from a stale/default direction.
@@ -6810,33 +7075,18 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
const std::string blend_add = _u8L_CONTEXT("Add", "Texture Displacement");
const std::string blend_override = _u8L_CONTEXT("Override", "Texture Displacement");
const std::string blend_subtract = _u8L_CONTEXT("Subtract", "Texture Displacement");
const std::string blend_multiply = _u8L_CONTEXT("Multiply", "Texture Displacement");
const std::string blend_divide = _u8L("Divide");
const char *blend_items[] = { blend_add.c_str(), blend_override.c_str(), blend_subtract.c_str(),
blend_multiply.c_str(), blend_divide.c_str() };
// The combo's order is the panel's, not the enum's - Override reads as the sibling of Add and
// belongs next to it, while TextureBlendMode's numbering is fixed by what projects already
// store. Hence the explicit mapping rather than a cast of the index.
static const TextureBlendMode blend_modes[] = { TextureBlendMode::Add, TextureBlendMode::Override,
TextureBlendMode::Subtract, TextureBlendMode::Multiply,
TextureBlendMode::Divide };
static_assert(IM_ARRAYSIZE(blend_items) == IM_ARRAYSIZE(blend_modes),
"every Blend entry needs its mode");
const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(), blend_divide.c_str() };
// The first layer has nothing before it to combine with - build_texture_displacement() makes it
// add regardless - so its combo shows Add and cannot be changed.
const bool base_layer = li == 0;
int blend_mode = 0;
if (!base_layer)
for (int bi = 0; bi < int(IM_ARRAYSIZE(blend_modes)); ++bi)
if (blend_modes[bi] == layer.blend_mode)
blend_mode = bi;
int blend_mode = base_layer ? 0 : static_cast<int>(layer.blend_mode);
ImGui::SetNextItemWidth(-(card_pad + gap_s + icon_sm));
m_imgui->disabled_begin(base_layer);
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer &&
blend_mode >= 0 && blend_mode < int(IM_ARRAYSIZE(blend_modes))) {
layer.blend_mode = blend_modes[blend_mode];
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) {
layer.blend_mode = static_cast<TextureBlendMode>(blend_mode);
m_preview_params_dirty = true;
}
m_imgui->disabled_end();
@@ -6844,11 +7094,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Override replaces them, "
"so only this layer's relief is left where it is painted. Multiply "
"and Divide scale what is underneath, which turns this layer into a "
"mask over it - there, Depth acts as a strength, and 1 mm leaves "
"white areas untouched."),
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."),
wrap_w);
ImGui::SameLine(0.f, gap_s);
@@ -7323,10 +7572,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
set_active_layer(m_picker_slot);
// In this window's scope, where the popup's open request and the popup itself share one ID stack.
ImVec2 panel_rect_min, panel_rect_max;
{
const ImVec2 win_min = ImGui::GetWindowPos();
const ImVec2 win_sz = ImGui::GetWindowSize();
render_texture_library_popup(win_min, ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y));
// Only recorded here. The image-adjustment panel is a top-level window rather than a popup, so it
// cannot be begun inside this one; it is drawn after GizmoImguiEnd() below.
panel_rect_min = win_min;
panel_rect_max = ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y);
}
// ImGui sliders report "changed" continuously on every frame while being dragged, not just once on
@@ -7563,6 +7817,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_parent.request_extra_frame();
}
GizmoImguiEnd();
// Outside the gizmo's window, because it is a window of its own: a floating, non-modal panel that
// leaves the 3D view live so the relief can be watched while the sliders move. No-op when closed.
render_image_adjust_window(panel_rect_min, panel_rect_max);
ImGuiWrapper::pop_toolbar_style();
// Drawn last, over everything, via the foreground draw list: the +/- add-remove sign next to the 3D
@@ -23,6 +23,7 @@
#include "libslic3r/Point.hpp"
#include <imgui.h>
#include <map>
#include <future>
#include <memory>
#include <optional>
#include "slic3r/GUI/Gizmos/GLGizmoBase.hpp"
@@ -61,12 +62,6 @@ public:
static TextureDisplacementPrepareResult prepare_mesh(const indexed_triangle_set &base,
const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers,
// `base`'s own UV channel, when it has one
// (ModelVolume::texture_uv). Resampled onto
// the result and reported back in it, and fed
// to the samplers so a ModelUV layer drives
// the refinement through its own layout.
const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings,
const DisplacementProgressFn &progress,
@@ -405,6 +400,68 @@ private:
void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry);
void import_custom_texture(TextureDisplacementLayer &layer);
// ---- The image-adjustment panel ----
//
// Brightness/contrast/sharpness/blur/hue over one layer's image, shown on the image itself and
// updating as the sliders move. What it produces is not a set of layer parameters: it replaces the
// layer's *image*, so the adjusted pixels feed the colour and the displacement together and a
// brightness change moves the relief too (see TextureAdjustments).
//
// An ordinary floating window rather than a popup, and deliberately so. A popup dims the canvas
// behind it and closes on any click outside itself, which between them make it impossible to watch the
// relief change on the model - which is the whole point of adjusting the image here rather than in an
// image editor. A window leaves the 3D view live, clickable and orbitable while the panel is open.
void render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max);
// Opens the panel for `layer`, decoding its image into m_adjust_source. Sliders never re-decode
// the PNG.
void open_image_adjust(const TextureDisplacementLayer &layer);
// Reads `layer`'s stored image into the panel's source buffers and records which image it came from.
// Called when the panel opens and again whenever the layer's image is swapped under it, which is why
// it leaves the slider values alone. False when the image cannot be decoded.
bool load_image_adjust_source(const TextureDisplacementLayer &layer);
// Resolves the panel: writes the adjusted image into the user's texture library as a new file and
// points `layer` at it, which is the only way an adjustment becomes permanent. Closes the panel.
void commit_image_adjust(TextureDisplacementLayer &layer);
// Closes the panel and reverts the layer's live preview_adjust to identity, so every exit that is not
// a commit - Cancel, the window's close button, the gizmo closing - leaves the layer as it was found.
void close_image_adjust();
// Collects a finished preview and starts one for the current slider values. One at a time, so a drag
// coalesces into "whatever the sliders last said" instead of a queue of superseded frames.
void update_image_adjust_preview();
int m_adjust_slot = -1; // the layer the panel is open for; -1 when closed
TextureAdjustments m_adjust; // what the sliders currently say
// What the layer's live preview_adjust was last set from, so a slider move can be told from a frame in
// which nothing happened and only the former asks the displacement to be recomputed.
TextureAdjustments m_adjust_applied;
// The layer's image as it was when the panel opened, and a small copy of it. The sliders are applied
// to the small copy for the live preview and to the full-size one exactly once, on commit. Measured on
// a 1024x1024 with every knob engaged, a full-size pass costs 13-19 ms grayscale and 40-60 ms in
// colour (130-190 ms at 2048 in colour) - far too much to repeat per slider frame. The same pass on a
// 384 px copy is 3-4 ms / 9-12 ms, and that is all the preview box can show anyway.
DecodedHeightTexture m_adjust_source;
DecodedHeightTexture m_adjust_preview_source;
// Which image_data the two buffers above were decoded from, so the panel can notice the layer's image
// being swapped from the texture picker while it is open and re-read it. Compared, never dereferenced.
const void *m_adjust_source_image = nullptr;
// And which volume the panel was opened on. Slot numbers are per volume, so without this a change of
// selection would silently rebind the open panel to whichever layer happens to hold the same slot on
// the newly selected object - a different image, under a panel the user opened for this one.
size_t m_adjust_volume_id = 0;
// The preview image on screen, re-uploaded whenever a background pass finishes.
std::unique_ptr<GLTexture> m_adjust_preview_tex;
// The live preview runs on its own thread rather than on the UI thread or on the plater's job
// worker. Not the UI thread because even the small pass is milliseconds and this runs while a slider
// is being dragged; not the job worker because that is a single FIFO shared with Bake and the
// displacement preview (see queue_preview_job()), where a per-frame image filter would sit behind -
// and delay - multi-second mesh work.
std::future<DecodedHeightTexture> m_adjust_preview_job;
TextureAdjustments m_adjust_preview_running; // what the in-flight pass is computing
TextureAdjustments m_adjust_preview_shown; // what m_adjust_preview_tex holds
bool m_adjust_preview_valid = false;
// Paint / Erase, from the panel. See get_left_button_state_type().
bool m_erase_mode = false;
// Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation.
@@ -824,31 +881,18 @@ private:
// Merges chart `b`'s join group into chart `a`'s (materialising `groups` to `chart_count` first).
static void join_island_groups(std::vector<int> &groups, int a, int b, int chart_count);
// Final per-vertex texture uv for the projections the shader can't reconstruct itself - LSCM (an
// unwrap), ModelUV (the model's own channel) and ViewProjected (a projector plane the shader
// doesn't know). One entry per patch/base vertex, already through apply_uv_transform(). Empty for
// Triplanar/Cylindrical/Spherical, which the shader projects on its own. Shared by the shaded
// preview and the UV-check overlay.
//
// `patch_source` maps each patch triangle back to its base-mesh triangle
// (TriangleSelector::get_facets_strict()'s out_source). Only ModelUV needs it - its channel is per
// base triangle - and without it a ModelUV layer reports no uvs and the preview falls back.
// unwrap) and ViewProjected (a projector plane the shader doesn't know). One entry per patch/base
// vertex, already through apply_uv_transform(). Empty for Triplanar/Cylindrical/Spherical, which
// the shader projects on its own. Shared by the shaded preview and the UV-check overlay.
std::vector<Vec2f> compute_layer_vertex_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source = nullptr) const;
const TextureDisplacementLayer &layer) const;
// The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what
// the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different
// UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined
// seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is
// single-valued per point, so there a corner's UV is just its vertex's.
std::vector<Vec2f> compute_layer_corner_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source = nullptr) const;
// The model's own channel over `local_patch`, already through apply_uv_transform() - three per
// patch triangle. Empty when the volume has no channel, or when `patch_source` is missing. Shared
// by the two functions above so the per-vertex and per-corner forms can never disagree.
std::vector<Vec2f> model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const;
const TextureDisplacementLayer &layer) const;
// The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two
// wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the
// shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview
@@ -897,6 +941,7 @@ private:
enum class UVBackground { None, Height, Checker };
UVBackground m_uv_editor_bg = UVBackground::None;
float m_uv_editor_bg_smoothing = -1.f; // smoothing the height backdrop was uploaded at
TextureAdjustments m_uv_editor_bg_adjust; // and the live image adjustment it was uploaded at
const void *m_uv_editor_bg_image = nullptr; // the layer image it was uploaded from
// Per-chart distortion heatmap colour for the UV pane (#7/#14), computed once when the unwrap is
// re-solved (relative stretch doesn't change when islands are merely moved), fed to the canvas only
@@ -940,6 +985,10 @@ private:
std::array<const void *, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnail_source{};
// The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it.
std::array<float, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnail_smoothing{};
// And the live image adjustment each was built at, so moving a slider in the image-adjustment panel
// re-uploads it. Part of the key for the same reason smoothing is: preview_adjust changes the pixels
// decode_height_texture() hands back, and every one of these textures is a copy of those pixels.
std::array<TextureAdjustments, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnail_adjust{};
// Full-resolution height texture for the preview shader, keyed the same way (see
// get_layer_height_texture()). A smoothing change re-uploads it, so the fast preview shows the
@@ -947,12 +996,14 @@ private:
std::unique_ptr<GLTexture> m_height_tex;
const void *m_height_tex_source = nullptr;
float m_height_tex_smoothing = -1.f;
TextureAdjustments m_height_tex_adjust;
// The same, for the layer's *colour*, which the fast preview quantizes per fragment so it shows
// the image at texel resolution rather than at the mesh's. Null for a grayscale texture.
std::unique_ptr<GLTexture> m_color_tex;
const void *m_color_tex_source = nullptr;
float m_color_tex_smoothing = -1.f;
TextureAdjustments m_color_tex_adjust;
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
std::map<std::string, LibraryTexture> m_library_textures;
@@ -8,7 +8,6 @@
#include "libslic3r/TextureDisplacement.hpp"
#include <exception>
#include <cstddef>
#include <cstdint>
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
@@ -27,7 +26,6 @@
#include "slic3r/Utils/UndoRedo.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <array>
namespace Slic3r::GUI {
@@ -76,8 +74,7 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
}
return true;
},
color, m_input.volume_to_world, nullptr, &m_stats,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv));
color, m_input.volume_to_world, nullptr, &m_stats));
// Always finish at 100: this is what closes the notification. Reported even on cancel, where
// build_texture_displacement() returns an empty mesh and finalize() commits nothing.
@@ -129,73 +126,10 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
{ mix.a_percent(), 100 - mix.a_percent() });
});
// Whether the bake replaced the triangles or only moved the vertices it was given. The classic
// path is topology-preserving by contract, so every per-triangle annotation on the volume still
// means what it did; the default pipeline refines, cuts and simplifies, so none of them do.
// Both are checked: the flag is what the bake promised, the count is what it delivered. Only
// the triangles matter - the annotations are per facet and reference no vertex index - so a
// vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a
// reason to remap anything.
const bool topology_changed = m_input.options.pipeline_v2 ||
m_result.its.indices.size() != m_input.base_mesh.indices.size();
// Carry the volume's existing paint - the model's own colours above all - across that rebuild.
// Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that
// no longer existed, which is what scrambled the colours of an imported coloured model even
// with colour switched off on every layer and nothing in the bake that was meant to touch them.
//
// transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants
// a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is
// nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact:
// triangle i is still triangle i, so the paint is already exactly right and carrying it at all
// could only lose detail.
std::array<TriangleSelector::TriangleSplittingData, 4> before;
if (topology_changed) {
before[0] = volume->supported_facets.get_data();
before[1] = volume->seam_facets.get_data();
before[2] = volume->mmu_segmentation_facets.get_data();
before[3] = volume->fuzzy_skin_facets.get_data();
}
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
if (topology_changed) {
const std::array<FacetsAnnotation *, 4> target{ &volume->supported_facets, &volume->seam_facets,
&volume->mmu_segmentation_facets,
&volume->fuzzy_skin_facets };
for (size_t ch = 0; ch < target.size(); ++ch) {
const std::vector<uint8_t> state =
transfer_facet_paint(m_input.base_mesh, before[ch], volume->mesh().its);
// Nothing was painted in this channel, or there is nothing to paint it onto: either
// way the annotation must not keep describing the mesh that is gone.
if (state.empty()) {
target[ch]->reset();
continue;
}
TriangleSelector selector(volume->mesh());
bool any = false;
for (size_t i = 0; i < state.size(); ++i)
if (state[i] != 0) {
selector.set_facet(int(i), EnforcerBlockerType(state[i]));
any = true;
}
if (any)
target[ch]->set(selector);
else
target[ch]->reset();
}
}
// The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started
// from: a displaced vertex came from its foot point on that surface, which is the position its
// uv belongs to - so no distance limit, the relief depth is exactly how far it has moved.
if (!m_input.model_uv.empty())
volume->texture_uv = topology_changed ? transfer_uv_channel(m_input.base_mesh, m_input.model_uv,
volume->mesh().its) :
m_input.model_uv;
// Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there
// rather than replacing it: a triangle the texture does not colour keeps its existing filament,
// and one the user never painted at all stays at NONE, which already means "the volume's own
@@ -225,6 +159,7 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
// pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old
// topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and
// then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object().
const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(),
[slot](const TextureDisplacementLayer &l) { return l.slot == slot; });
@@ -284,8 +219,6 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol
input.take_snapshot = take_snapshot;
input.volume_id = volume.id();
input.base_mesh = volume.mesh().its;
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
input.layers = volume.texture_displacement_layers;
input.options = volume.texture_displacement_options;
input.volume_to_world = texture_displacement_volume_to_world(volume);
@@ -34,9 +34,6 @@ struct TextureDisplacementBakeInput
// writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU
// paint gizmo writes - alongside the displaced geometry.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV
// layer maps through, and what the commit puts back on the baked mesh.
std::vector<Vec2f> model_uv;
// Whether this job pushes its own undo step when it commits. False when the caller has already
// taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes
// and subdivides first and has to undo as a single action.
@@ -17,7 +17,6 @@
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TriangleMesh.hpp"
namespace Slic3r::GUI {
@@ -53,14 +52,13 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
indexed_triangle_set mesh = m_input.base_mesh;
TextureDisplacementFacetsData masks = m_input.facets_data;
std::vector<Vec2f> uv = m_input.model_uv;
// The default pipeline can only move vertices the mesh already has, so its recipe starts with the
// preparation - which is where "first it remeshes, then it refines" actually happens. The
// experimental pipeline refines as part of the bake and has nothing to prepare.
if (m_input.run_prepare && !m_input.options.pipeline_v2) {
const TextureDisplacementPrepareResult prepared =
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_params,
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params,
m_input.color,
// Preparation is roughly half the run; the bake
// takes the progress bar from there.
@@ -79,14 +77,12 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
if (!prepared.mesh.indices.empty()) {
mesh = prepared.mesh;
masks = prepared.masks;
uv = prepared.uv; // resampled onto the prepared mesh, as the prepare commit stores it
}
}
build_texture_displacement(mesh, m_input.layers, masks, m_input.options,
[&report](int pct) { return report(50 + pct / 2); },
/* color */ nullptr, m_input.volume_to_world, &recorder, /* stats */ nullptr,
uv.empty() ? nullptr : &uv);
/* color */ nullptr, m_input.volume_to_world, &recorder);
m_stages = recorder.take();
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
@@ -119,8 +115,6 @@ void queue_texture_displacement_debug(const ModelVolume &volume, const TextureCo
input.check_topology = check_topology;
input.color = color;
input.volume_to_world = texture_displacement_volume_to_world(volume);
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
@@ -47,9 +47,6 @@ struct TextureDisplacementDebugInput
// Only the quantizer is used, and only by the refinement's colour criterion - the debug run
// produces no colour of its own.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), so the recipe captured here is the one a
// ModelUV layer actually bakes through. Empty when the volume has none.
std::vector<Vec2f> model_uv;
// The edge scan behind the open / non-manifold counts is a sort over every half-edge, which on a
// multi-million triangle stage costs more than the stage did. On by default because a stage that
@@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl)
// idle loop.
int last_reported = 1;
m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers,
m_input.model_uv, m_input.params, m_input.color,
m_input.params, m_input.color,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
@@ -106,10 +106,6 @@ void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr &
volume->restore_painting(saved_painting);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)]));
// The model's own UV channel, resampled onto the new mesh by the run. Also put back after
// restore_painting(), which resets it along with the extra facets for the same reason: it is
// indexed by triangle and the triangles have changed.
volume->texture_uv = std::move(m_result.uv);
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
@@ -9,7 +9,6 @@
#include "Job.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TextureDisplacement.hpp"
namespace Slic3r {
@@ -48,10 +47,6 @@ struct TextureDisplacementPrepareInput
// skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows
// perceived colour, never the interleaving that realises a mix.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh()
// resamples it onto the mesh it produces, and the commit puts it back - like the masks, it is
// indexed by triangle and so cannot survive the stages on its own.
std::vector<Vec2f> model_uv;
// The undo step the commit opens. Standard mode's Bake names it after the bake, because the
// displacement job that follows commits into this same step rather than pushing its own.
std::string snapshot_name;
@@ -48,8 +48,7 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
(!m_current_generation ||
m_current_generation->load() == m_generation);
},
color, m_input.volume_to_world, nullptr, nullptr,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv);
color, m_input.volume_to_world);
}
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
@@ -29,9 +29,6 @@ struct TextureDisplacementPreviewInput
// Empty unless a layer is colouring, in which case the preview reports the palette entry per
// triangle alongside the mesh, so the Normal view shows the colours the bake will produce.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the
// same layout it will bake through. Empty when the volume has none.
std::vector<Vec2f> model_uv;
};
// A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per
+77 -30
View File
@@ -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
+10
View File
@@ -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_
+248 -309
View File
@@ -28,6 +28,7 @@
#include <algorithm>
#include <array>
#include <functional>
#include <cmath>
#include <fstream>
#include <limits>
@@ -2175,352 +2176,290 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu
CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{}));
}
// ---------------------------------------------------------------------------------------------
// The model's own UV channel, and the Override blend mode
// ---------------------------------------------------------------------------------------------
// ---- Image adjustments (the gizmo's image-adjustment panel) ----------------------------------------
// A grayscale image that is black in its left half and white in its right half, so where a sample
// lands along u is readable straight off the height it comes back with.
static std::shared_ptr<std::vector<unsigned char>> make_left_right_split_png(size_t w = 16, size_t h = 16)
// 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)
{
std::vector<uint8_t> pixels(w * h);
for (size_t y = 0; y < h; ++y)
for (size_t x = 0; x < w; ++x)
pixels[y * w + x] = (x < w / 2) ? 0 : 255;
const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png");
REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels));
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;
}
std::vector<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
// 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));
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
return t;
}
// Where a sheet coordinate lands in uv: across [UV_LO, UV_LO + UV_SPAN] rather than the full [0, 1].
// The inset keeps the tests off the exact edge of an untiled placement, where there is deliberately no
// texture at all rather than its border texel (see DecodedHeightTexture::sample()) - a real model's
// channel does reach 1.0, and a vertex sitting exactly there displaces by nothing on purpose.
static constexpr float UV_LO = 0.1f;
static constexpr float UV_SPAN = 0.8f;
static float sheet_uv_at(float coord, float size, bool mirror = false)
TEST_CASE("an adjustment that moves no slider returns the image unchanged", "[TextureDisplacement]")
{
const float t = coord / size;
return UV_LO + UV_SPAN * (mirror ? 1.f - t : t);
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());
}
// One UV per triangle corner: u follows x, v follows y. `mirror_u` mirrors u, which is what the tests
// use to show the bake is really reading the channel rather than projecting the position.
static std::vector<Vec2f> sheet_uv_channel(const indexed_triangle_set &sheet, float size, bool mirror_u = false)
TEST_CASE("brightness reaches pure white and pure black only at the ends of its range", "[TextureDisplacement]")
{
std::vector<Vec2f> uv(sheet.indices.size() * 3);
for (size_t f = 0; f < sheet.indices.size(); ++f)
for (int k = 0; k < 3; ++k) {
const Vec3f &p = sheet.vertices[size_t(sheet.indices[f][k])];
uv[f * 3 + size_t(k)] = Vec2f(sheet_uv_at(p.x(), size, mirror_u), sheet_uv_at(p.y(), size));
}
return uv;
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("Override blend replaces the displacement under it instead of combining with it",
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]")
{
CHECK_THAT(blend_displacement(3.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f));
// Nothing underneath yet is still just the value, so an Override layer at the bottom of the
// stack behaves exactly like the Add the bake forces there.
CHECK_THAT(blend_displacement(0.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f));
// The sign carries, and a zero sample really does flatten what is under it - that is the point.
CHECK_THAT(blend_displacement(3.f, -1.f, TextureBlendMode::Override), WithinAbs(-1.f, 1e-6f));
CHECK_THAT(blend_displacement(3.f, 0.f, TextureBlendMode::Override), WithinAbs(0.f, 1e-6f));
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("an Override layer leaves only its own relief where two layers overlap", "[TextureDisplacement]")
TEST_CASE("blur removes the finest detail and sharpness exaggerates an edge", "[TextureDisplacement]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
// 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));
TextureDisplacementLayer base;
base.slot = 0;
base.depth_mm = 2.0f;
base.tiling_scale = 5.0f;
base.image_data = make_flat_gray_png(255); // full depth everywhere
// 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;
TextureDisplacementLayer second = base;
second.slot = 1;
second.depth_mm = 0.5f;
second.blend_mode = TextureBlendMode::Override;
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
}
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(cube);
facets[1] = facets[0];
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());
const indexed_triangle_set overridden = build_texture_displacement(cube, {base, second}, facets, classic_options());
REQUIRE(overridden.vertices.size() == cube.vertices.size());
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);
}
// Add would have piled the two up to 2.5 mm; Override keeps the upper layer's 0.5 mm alone.
TextureDisplacementLayer added = second;
added.blend_mode = TextureBlendMode::Add;
const indexed_triangle_set summed = build_texture_displacement(cube, {base, added}, facets, classic_options());
REQUIRE(summed.vertices.size() == cube.vertices.size());
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());
for (size_t i = 0; i < cube.vertices.size(); ++i) {
if (!above_resting_plane(cube, i))
continue;
CHECK_THAT((overridden.vertices[i] - cube.vertices[i]).norm(), WithinAbs(0.5f, 1e-3f));
CHECK_THAT((summed.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.5f, 1e-3f));
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("the ModelUV mapping displaces through the model's own UV channel", "[TextureDisplacement]")
TEST_CASE("a full turn of the hue leaves a colour where it started", "[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set sheet = make_flat_sheet(size, 1.f);
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.slot = 0;
layer.depth_mm = 1.0f;
layer.image_data = make_left_right_split_png();
layer.tile_enabled = false; // the layout is the placement; nothing should repeat
layer.projection_method = TextureProjectionMethod::ModelUV;
layer.image_data = make_checkerboard_png(32, 32);
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(sheet);
const DecodedHeightTexture raw = decode_height_texture(layer);
REQUIRE_FALSE(raw.empty());
const std::vector<Vec2f> uv = sheet_uv_channel(sheet, size);
const std::vector<Vec2f> flipped = sheet_uv_channel(sheet, size, /* mirror_u */ true);
// An untouched panel, and a closed one, cost nothing and change nothing.
layer.preview_adjust = TextureAdjustments{};
CHECK(decode_height_texture(layer).pixels == raw.pixels);
const indexed_triangle_set out = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, nullptr,
Transform3d::Identity(), nullptr, nullptr, &uv);
REQUIRE(out.vertices.size() == sheet.vertices.size());
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);
// The image is black for u < 0.5 and white above it, and the channel's u rises with x across the
// sheet, so it comes out flat on its left half and raised by the full depth on its right.
size_t low = 0, high = 0;
for (size_t i = 0; i < sheet.vertices.size(); ++i) {
const float x = sheet.vertices[i].x();
const float dz = out.vertices[i].z() - sheet.vertices[i].z();
if (x < 0.4f * size) {
CHECK_THAT(dz, WithinAbs(0.f, 1e-3f));
++low;
} else if (x > 0.6f * size) {
CHECK_THAT(dz, WithinAbs(layer.depth_mm, 1e-3f));
++high;
}
}
REQUIRE(low > 0);
REQUIRE(high > 0);
// Mirroring the channel mirrors the relief: the mapping really is the channel's, not a planar
// projection of the position (which would be unchanged by it).
const indexed_triangle_set mirrored = build_texture_displacement(sheet, {layer}, facets, classic_options(), {},
nullptr, Transform3d::Identity(), nullptr, nullptr,
&flipped);
REQUIRE(mirrored.vertices.size() == sheet.vertices.size());
for (size_t i = 0; i < sheet.vertices.size(); ++i) {
const float x = sheet.vertices[i].x();
if (x < 0.4f * size)
CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(layer.depth_mm, 1e-3f));
else if (x > 0.6f * size)
CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(0.f, 1e-3f));
}
// 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("a ModelUV layer falls back to Triplanar when the volume carries no UV channel",
"[TextureDisplacement]")
TEST_CASE("changing a live adjustment is not served the previous one from cache", "[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set sheet = make_flat_sheet(size, 2.f);
// 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.slot = 0;
layer.depth_mm = 1.0f;
layer.tiling_scale = 5.0f;
layer.image_data = make_flat_gray_png(255);
layer.image_data = make_checkerboard_png(32, 32);
const DecodedHeightTexture raw = decode_height_texture(layer);
REQUIRE_FALSE(raw.empty());
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(sheet);
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);
layer.projection_method = TextureProjectionMethod::Triplanar;
const indexed_triangle_set as_triplanar = build_texture_displacement(sheet, {layer}, facets, classic_options());
layer.projection_method = TextureProjectionMethod::ModelUV;
const indexed_triangle_set no_channel = build_texture_displacement(sheet, {layer}, facets, classic_options());
REQUIRE(no_channel.vertices.size() == as_triplanar.vertices.size());
for (size_t i = 0; i < as_triplanar.vertices.size(); ++i)
CHECK_THAT((no_channel.vertices[i] - as_triplanar.vertices[i]).norm(), WithinAbs(0.f, 1e-5f));
}
TEST_CASE("a bake with colour off on every layer reports no colour at all", "[TextureDisplacement]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.depth_mm = 1.0f;
layer.tiling_scale = 5.0f;
layer.image_data = make_flat_rgb_png(200, 40, 10); // has colour to give...
layer.color_enabled = false; // ...but is not asked for it
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(cube);
std::vector<uint8_t> triangle_color;
TextureColorRequest request;
request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked
request.out_triangle = &triangle_color;
const indexed_triangle_set out = build_texture_displacement(cube, {layer}, facets, classic_options(), {}, &request);
REQUIRE_FALSE(out.vertices.empty());
// Every entry 0 ("this triangle takes no colour from the texture"), so the commit leaves whatever
// the model was already painted with exactly as it was.
for (const uint8_t entry : triangle_color)
CHECK(entry == 0);
}
TEST_CASE("transfer_uv_channel resamples a UV channel onto a subdivided copy of the same surface",
"[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set coarse = make_flat_sheet(size, 5.f);
const indexed_triangle_set fine = make_flat_sheet(size, 1.f);
const std::vector<Vec2f> uv = sheet_uv_channel(coarse, size);
const std::vector<Vec2f> moved = transfer_uv_channel(coarse, uv, fine);
REQUIRE(moved.size() == fine.indices.size() * 3);
// The channel is affine in position here (u = x/size, v = y/size), so a correct transfer
// reproduces it exactly at every corner of the finer mesh.
for (size_t f = 0; f < fine.indices.size(); ++f)
for (int k = 0; k < 3; ++k) {
const Vec3f &p = fine.vertices[size_t(fine.indices[f][k])];
CHECK_THAT(moved[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f));
CHECK_THAT(moved[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f));
}
// A mismatched channel is refused rather than read past its end.
CHECK(transfer_uv_channel(coarse, {Vec2f::Zero()}, fine).empty());
CHECK(transfer_uv_channel(coarse, uv, indexed_triangle_set{}).empty());
}
TEST_CASE("the model's per-corner UVs survive a brush split and collapse to one per vertex",
"[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set sheet = make_flat_sheet(size, 5.f);
const std::vector<Vec2f> uv = sheet_uv_channel(sheet, size);
// The whole sheet as a patch, with the source map the channel is read through.
const TriangleMesh tm(sheet);
TriangleSelector selector(tm);
for (int f = 0; f < int(sheet.indices.size()); ++f)
selector.set_facet(f, EnforcerBlockerType::ENFORCER);
std::vector<int> source;
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, &source);
REQUIRE(patch.indices.size() == source.size());
const std::vector<Vec2f> corner = compute_model_corner_uvs(sheet, uv, patch, source);
REQUIRE(corner.size() == patch.indices.size() * 3);
// An unsplit patch triangle *is* its source triangle, so its corners reproduce the channel.
for (size_t f = 0; f < patch.indices.size(); ++f)
for (int k = 0; k < 3; ++k) {
const Vec3f &p = patch.vertices[size_t(patch.indices[f][k])];
CHECK_THAT(corner[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f));
CHECK_THAT(corner[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f));
}
const std::vector<Vec2f> per_vertex = collapse_corner_uvs(patch, corner);
REQUIRE(per_vertex.size() == patch.vertices.size());
for (size_t i = 0; i < patch.vertices.size(); ++i) {
CHECK_THAT(per_vertex[i].x(), WithinAbs(sheet_uv_at(patch.vertices[i].x(), size), 1e-4f));
CHECK_THAT(per_vertex[i].y(), WithinAbs(sheet_uv_at(patch.vertices[i].y(), size), 1e-4f));
}
// Mismatched inputs report nothing rather than guessing.
CHECK(compute_model_corner_uvs(sheet, {Vec2f::Zero()}, patch, source).empty());
CHECK(compute_model_corner_uvs(sheet, uv, patch, {}).empty());
CHECK(collapse_corner_uvs(patch, {Vec2f::Zero()}).empty());
}
TEST_CASE("facet paint is carried onto a rebuilt mesh by each facet's foot point", "[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f);
// Two filaments, split down the middle - the pattern the transfer has to reproduce.
const TriangleMesh coarse_mesh(coarse);
TriangleSelector painter(coarse_mesh);
for (size_t t = 0; t < coarse.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = coarse.indices[t];
const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() +
coarse.vertices[size_t(tri[2])].x()) / 3.f;
painter.set_facet(int(t), x < 0.5f * size ? EnforcerBlockerType::Extruder1 : EnforcerBlockerType::Extruder2);
}
const TriangleSelector::TriangleSplittingData paint = painter.serialize();
// A finer mesh, lifted clear of the original surface the way a bake's relief lifts it. This is
// what ModelVolume::restore_painting()'s 0.01 mm overlap remap cannot follow.
indexed_triangle_set lifted = make_flat_sheet(size, 1.f);
for (Vec3f &v : lifted.vertices)
v.z() += 0.5f;
const std::vector<uint8_t> state = transfer_facet_paint(coarse, paint, lifted);
REQUIRE(state.size() == lifted.indices.size());
size_t left = 0, right = 0;
for (size_t t = 0; t < lifted.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = lifted.indices[t];
const float x = (lifted.vertices[size_t(tri[0])].x() + lifted.vertices[size_t(tri[1])].x() +
lifted.vertices[size_t(tri[2])].x()) / 3.f;
// Only away from the split: a facet straddling it may legitimately land on either side.
if (x < 0.4f * size) {
CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder1));
++left;
} else if (x > 0.6f * size) {
CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder2));
++right;
}
}
REQUIRE(left > 0);
REQUIRE(right > 0);
// An unpainted source has nothing to carry, which is not the same as carrying NONE everywhere:
// the caller has to tell those apart to decide between resetting the annotation and writing one.
CHECK(transfer_facet_paint(coarse, TriangleSelector::TriangleSplittingData{}, lifted).empty());
CHECK(transfer_facet_paint(coarse, paint, indexed_triangle_set{}).empty());
}
TEST_CASE("an unpainted part of the source carries no paint onto the rebuilt mesh", "[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f);
// Only the left half is painted at all; the right half must come back NONE rather than borrowing
// the nearest painted colour from across the model.
const TriangleMesh coarse_mesh(coarse);
TriangleSelector painter(coarse_mesh);
for (size_t t = 0; t < coarse.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = coarse.indices[t];
const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() +
coarse.vertices[size_t(tri[2])].x()) / 3.f;
if (x < 0.5f * size)
painter.set_facet(int(t), EnforcerBlockerType::Extruder3);
}
const std::vector<uint8_t> state = transfer_facet_paint(coarse, painter.serialize(), coarse);
REQUIRE(state.size() == coarse.indices.size());
for (size_t t = 0; t < coarse.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = coarse.indices[t];
const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() +
coarse.vertices[size_t(tri[2])].x()) / 3.f;
if (x < 0.4f * size)
CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder3));
else if (x > 0.6f * size)
CHECK(state[t] == uint8_t(EnforcerBlockerType::NONE));
}
// 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);
}