mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-31 22:06:59 +00:00
Add color suport for textures
This commit is contained in:
@@ -21,6 +21,15 @@ const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074);
|
||||
const vec3 ZERO = vec3(0.0, 0.0, 0.0);
|
||||
|
||||
uniform vec4 uniform_color;
|
||||
// The printable palette, in **CIELAB** as well as RGB, and how many entries are real. Lab because the
|
||||
// match has to be perceptual - the same reason the CPU side uses CIEDE2000 - and converting the
|
||||
// palette once on the CPU is what lets the fragment shader match with a plain squared distance.
|
||||
// Count 0 means nothing is colouring, and every fragment falls back to uniform_color as before.
|
||||
uniform vec3 palette_lab[64];
|
||||
uniform vec3 palette_rgb[64];
|
||||
uniform int palette_count;
|
||||
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
|
||||
uniform bool has_color_tex;
|
||||
uniform bool volume_mirrored;
|
||||
|
||||
uniform mat4 view_model_matrix;
|
||||
@@ -79,6 +88,47 @@ vec2 project_uv(vec3 p, vec3 n)
|
||||
return r + uv_offset;
|
||||
}
|
||||
|
||||
// sRGB -> CIELAB, matching slic3r/Utils/ColorSpaceConvert's RGB2Lab so this picks the same entry the
|
||||
// bake does.
|
||||
vec3 srgb_to_lab(vec3 c)
|
||||
{
|
||||
vec3 v = vec3(c.r > 0.04045 ? pow((c.r + 0.055) / 1.055, 2.4) : c.r / 12.92,
|
||||
c.g > 0.04045 ? pow((c.g + 0.055) / 1.055, 2.4) : c.g / 12.92,
|
||||
c.b > 0.04045 ? pow((c.b + 0.055) / 1.055, 2.4) : c.b / 12.92);
|
||||
vec3 xyz = vec3(dot(v, vec3(0.4124, 0.3576, 0.1805)) / 0.95047,
|
||||
dot(v, vec3(0.2126, 0.7152, 0.0722)),
|
||||
dot(v, vec3(0.0193, 0.1192, 0.9505)) / 1.08883);
|
||||
vec3 f = vec3(xyz.x > 0.008856 ? pow(xyz.x, 1.0 / 3.0) : (7.787 * xyz.x) + 16.0 / 116.0,
|
||||
xyz.y > 0.008856 ? pow(xyz.y, 1.0 / 3.0) : (7.787 * xyz.y) + 16.0 / 116.0,
|
||||
xyz.z > 0.008856 ? pow(xyz.z, 1.0 / 3.0) : (7.787 * xyz.z) + 16.0 / 116.0);
|
||||
return vec3(116.0 * f.y - 16.0, 500.0 * (f.x - f.y), 200.0 * (f.y - f.z));
|
||||
}
|
||||
|
||||
// Nearest printable colour to a sampled one. Quantizing per *fragment* rather than per facet is the
|
||||
// whole point of this path: it shows the image at the texture's resolution instead of the mesh's,
|
||||
// which is what you need while choosing a texture and placing it. The Normal view is where the
|
||||
// facet-resolution truth - what actually bakes - is shown.
|
||||
//
|
||||
// Squared distance in Lab (CIE76) rather than the CPU's CIEDE2000: the two agree except on near-ties,
|
||||
// and CIEDE2000 per fragment across 64 entries is not worth its cost in a preview.
|
||||
vec3 quantize_to_palette(vec3 rgb)
|
||||
{
|
||||
vec3 lab = srgb_to_lab(rgb);
|
||||
int best = 0;
|
||||
float bd = 1.0e20;
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
if (i >= palette_count)
|
||||
break;
|
||||
vec3 d = lab - palette_lab[i];
|
||||
float d2 = dot(d, d);
|
||||
if (d2 < bd) {
|
||||
bd = d2;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return palette_rgb[best];
|
||||
}
|
||||
|
||||
void main()
|
||||
{
|
||||
if (any(lessThan(clipping_planes_dots, ZERO)))
|
||||
@@ -88,6 +138,12 @@ void main()
|
||||
if (volume_mirrored)
|
||||
triangle_normal = -triangle_normal;
|
||||
|
||||
// Where the colour is read from. Both branches below already compute the uv this fragment's
|
||||
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
|
||||
// has to follow it exactly, or the colour would slide off the relief as the camera orbits.
|
||||
vec2 color_uv = vec2(0.0);
|
||||
bool have_uv = false;
|
||||
|
||||
if (use_vertex_uv) {
|
||||
// Mikkelsen surface-gradient bump; see the 140 variant for the full rationale. Scale-exact
|
||||
// for a conformal LSCM map (no global 1/tiling assumption), and gated by the paint weight
|
||||
@@ -95,6 +151,8 @@ void main()
|
||||
vec2 uv = (island_active > 0.5)
|
||||
? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr
|
||||
: vertex_uv;
|
||||
color_uv = uv;
|
||||
have_uv = true;
|
||||
float h = texture2D(height_tex, uv).r;
|
||||
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
|
||||
vec3 sigmaS = dFdx(model_pos.xyz);
|
||||
@@ -151,6 +209,9 @@ void main()
|
||||
}
|
||||
}
|
||||
|
||||
color_uv = uv; // after the parallax march, so colour and relief stay registered
|
||||
have_uv = true;
|
||||
|
||||
float hL = texture2D(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
|
||||
float hR = texture2D(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
|
||||
float hD = texture2D(height_tex, uv - vec2(0.0, height_tex_texel.y)).r;
|
||||
@@ -183,5 +244,11 @@ void main()
|
||||
NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0);
|
||||
intensity.x += NdotL * LIGHT_FRONT_DIFFUSE;
|
||||
|
||||
gl_FragColor = vec4(vec3(intensity.y) + uniform_color.rgb * intensity.x, uniform_color.a);
|
||||
// Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour.
|
||||
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
|
||||
// the same material under the same light, and the relief this preview exists to show is unaffected.
|
||||
vec3 albedo = uniform_color.rgb;
|
||||
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
|
||||
albedo = quantize_to_palette(texture2D(color_tex, color_uv).rgb);
|
||||
gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,15 @@ const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074);
|
||||
const vec3 ZERO = vec3(0.0, 0.0, 0.0);
|
||||
|
||||
uniform vec4 uniform_color;
|
||||
// The printable palette, in **CIELAB** as well as RGB, and how many entries are real. Lab because the
|
||||
// match has to be perceptual - the same reason the CPU side uses CIEDE2000 - and converting the
|
||||
// palette once on the CPU is what lets the fragment shader match with a plain squared distance.
|
||||
// Count 0 means nothing is colouring, and every fragment falls back to uniform_color as before.
|
||||
uniform vec3 palette_lab[64];
|
||||
uniform vec3 palette_rgb[64];
|
||||
uniform int palette_count;
|
||||
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
|
||||
uniform bool has_color_tex;
|
||||
uniform bool volume_mirrored;
|
||||
|
||||
uniform mat4 view_model_matrix;
|
||||
@@ -145,6 +154,47 @@ vec2 project_uv(vec3 p, vec3 n)
|
||||
return r + uv_offset;
|
||||
}
|
||||
|
||||
// sRGB -> CIELAB, matching slic3r/Utils/ColorSpaceConvert's RGB2Lab so this picks the same entry the
|
||||
// bake does.
|
||||
vec3 srgb_to_lab(vec3 c)
|
||||
{
|
||||
vec3 v = vec3(c.r > 0.04045 ? pow((c.r + 0.055) / 1.055, 2.4) : c.r / 12.92,
|
||||
c.g > 0.04045 ? pow((c.g + 0.055) / 1.055, 2.4) : c.g / 12.92,
|
||||
c.b > 0.04045 ? pow((c.b + 0.055) / 1.055, 2.4) : c.b / 12.92);
|
||||
vec3 xyz = vec3(dot(v, vec3(0.4124, 0.3576, 0.1805)) / 0.95047,
|
||||
dot(v, vec3(0.2126, 0.7152, 0.0722)),
|
||||
dot(v, vec3(0.0193, 0.1192, 0.9505)) / 1.08883);
|
||||
vec3 f = vec3(xyz.x > 0.008856 ? pow(xyz.x, 1.0 / 3.0) : (7.787 * xyz.x) + 16.0 / 116.0,
|
||||
xyz.y > 0.008856 ? pow(xyz.y, 1.0 / 3.0) : (7.787 * xyz.y) + 16.0 / 116.0,
|
||||
xyz.z > 0.008856 ? pow(xyz.z, 1.0 / 3.0) : (7.787 * xyz.z) + 16.0 / 116.0);
|
||||
return vec3(116.0 * f.y - 16.0, 500.0 * (f.x - f.y), 200.0 * (f.y - f.z));
|
||||
}
|
||||
|
||||
// Nearest printable colour to a sampled one. Quantizing per *fragment* rather than per facet is the
|
||||
// whole point of this path: it shows the image at the texture's resolution instead of the mesh's,
|
||||
// which is what you need while choosing a texture and placing it. The Normal view is where the
|
||||
// facet-resolution truth - what actually bakes - is shown.
|
||||
//
|
||||
// Squared distance in Lab (CIE76) rather than the CPU's CIEDE2000: the two agree except on near-ties,
|
||||
// and CIEDE2000 per fragment across 64 entries is not worth its cost in a preview.
|
||||
vec3 quantize_to_palette(vec3 rgb)
|
||||
{
|
||||
vec3 lab = srgb_to_lab(rgb);
|
||||
int best = 0;
|
||||
float bd = 1.0e20;
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
if (i >= palette_count)
|
||||
break;
|
||||
vec3 d = lab - palette_lab[i];
|
||||
float d2 = dot(d, d);
|
||||
if (d2 < bd) {
|
||||
bd = d2;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return palette_rgb[best];
|
||||
}
|
||||
|
||||
void main()
|
||||
{
|
||||
if (any(lessThan(clipping_planes_dots, ZERO)))
|
||||
@@ -154,6 +204,12 @@ void main()
|
||||
if (volume_mirrored)
|
||||
triangle_normal = -triangle_normal;
|
||||
|
||||
// Where the colour is read from. Both branches below already compute the uv this fragment's
|
||||
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
|
||||
// has to follow it exactly, or the colour would slide off the relief as the camera orbits.
|
||||
vec2 color_uv = vec2(0.0);
|
||||
bool have_uv = false;
|
||||
|
||||
if (use_vertex_uv) {
|
||||
// Precomputed-uv (LSCM) path - Mikkelsen's surface-gradient bump ("Bump Mapping
|
||||
// Unparametrized Surfaces on the GPU"). The perturbed normal is derived straight from the
|
||||
@@ -171,6 +227,8 @@ void main()
|
||||
vec2 uv = (island_active > 0.5)
|
||||
? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr
|
||||
: vertex_uv;
|
||||
color_uv = uv;
|
||||
have_uv = true;
|
||||
float h = texture(height_tex, uv).r;
|
||||
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
|
||||
vec3 sigmaS = dFdx(model_pos.xyz);
|
||||
@@ -230,6 +288,9 @@ void main()
|
||||
}
|
||||
}
|
||||
|
||||
color_uv = uv; // after the parallax march, so colour and relief stay registered
|
||||
have_uv = true;
|
||||
|
||||
float hL = texture(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
|
||||
float hR = texture(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
|
||||
float hD = texture(height_tex, uv - vec2(0.0, height_tex_texel.y)).r;
|
||||
@@ -267,5 +328,11 @@ void main()
|
||||
NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0);
|
||||
intensity.x += NdotL * LIGHT_FRONT_DIFFUSE;
|
||||
|
||||
out_color = vec4(vec3(intensity.y) + uniform_color.rgb * intensity.x, uniform_color.a);
|
||||
// Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour.
|
||||
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
|
||||
// the same material under the same light, and the relief this preview exists to show is unaffected.
|
||||
vec3 albedo = uniform_color.rgb;
|
||||
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
|
||||
albedo = quantize_to_palette(texture(color_tex, color_uv).rgb);
|
||||
out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
|
||||
bool DecodedHeightTexture::texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method,
|
||||
TexelTap &tap) const
|
||||
{
|
||||
if (empty())
|
||||
return 0.f;
|
||||
return false;
|
||||
|
||||
auto repeat01 = [](float x) {
|
||||
x = std::fmod(x, 1.f);
|
||||
@@ -43,7 +44,7 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
|
||||
// Outside the single, non-repeating placement entirely: no texture there, not "smeared
|
||||
// edge pixel" - clamping the *coordinate* to [0, 1] would otherwise keep returning the
|
||||
// border row/column's height forever in every direction, stretching it out to infinity.
|
||||
return 0.f;
|
||||
return false;
|
||||
|
||||
float u, v;
|
||||
if (!tile_enabled) {
|
||||
@@ -59,14 +60,26 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
|
||||
|
||||
const float fx = u * float(width);
|
||||
const float fy = v * float(height);
|
||||
int x0 = std::clamp(int(std::floor(fx)), 0, width - 1);
|
||||
int y0 = std::clamp(int(std::floor(fy)), 0, height - 1);
|
||||
const int x0 = std::clamp(int(std::floor(fx)), 0, width - 1);
|
||||
const int y0 = std::clamp(int(std::floor(fy)), 0, height - 1);
|
||||
// Neighbour for bilinear filtering: wrap for tiling methods, clamp at the edge otherwise (a
|
||||
// repeating neighbour would incorrectly blend against the opposite edge of the image).
|
||||
const int x1 = tile_enabled ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
|
||||
const int y1 = tile_enabled ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
|
||||
const float tx = fx - std::floor(fx);
|
||||
const float ty = fy - std::floor(fy);
|
||||
tap.x0 = x0;
|
||||
tap.y0 = y0;
|
||||
tap.x1 = tile_enabled ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
|
||||
tap.y1 = tile_enabled ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
|
||||
tap.tx = fx - std::floor(fx);
|
||||
tap.ty = fy - std::floor(fy);
|
||||
return true;
|
||||
}
|
||||
|
||||
float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
|
||||
{
|
||||
TexelTap tap;
|
||||
if (!texel_tap(uv, tile_enabled, tile_method, tap))
|
||||
return 0.f;
|
||||
const int x0 = tap.x0, y0 = tap.y0, x1 = tap.x1, y1 = tap.y1;
|
||||
const float tx = tap.tx, ty = tap.ty;
|
||||
|
||||
auto at = [this](int x, int y) { return float(pixels[size_t(y) * size_t(width) + size_t(x)]) / 255.f; };
|
||||
const float top = at(x0, y0) * (1.f - tx) + at(x1, y0) * tx;
|
||||
@@ -74,6 +87,21 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
|
||||
return top * (1.f - ty) + bottom * ty;
|
||||
}
|
||||
|
||||
Vec3f DecodedHeightTexture::sample_color(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
|
||||
{
|
||||
TexelTap tap;
|
||||
if (!has_color() || !texel_tap(uv, tile_enabled, tile_method, tap))
|
||||
return Vec3f::Zero();
|
||||
|
||||
auto at = [this](int x, int y) {
|
||||
const size_t i = (size_t(y) * size_t(width) + size_t(x)) * 3;
|
||||
return Vec3f(float(rgb[i]) / 255.f, float(rgb[i + 1]) / 255.f, float(rgb[i + 2]) / 255.f);
|
||||
};
|
||||
const Vec3f top = at(tap.x0, tap.y0) * (1.f - tap.tx) + at(tap.x1, tap.y0) * tap.tx;
|
||||
const Vec3f bottom = at(tap.x0, tap.y1) * (1.f - tap.tx) + at(tap.x1, tap.y1) * tap.tx;
|
||||
return top * (1.f - tap.ty) + bottom * tap.ty;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Decoding a PNG (zlib inflate + defilter) is real work, and image_data never changes in place
|
||||
// once assigned to a layer (a new texture always gets a brand new image_data), so the decoded
|
||||
@@ -156,18 +184,40 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
if (!have_raw) {
|
||||
const png::ReadBuf rbuf{ layer.image_data->data(), layer.image_data->size() };
|
||||
if (!png::is_png(rbuf))
|
||||
// Only 8-bit grayscale PNG height maps are supported. The GUI is responsible for
|
||||
// converting any imported image (jpg, color png, ...) to that format on import, so this
|
||||
// code never needs a dependency on wxWidgets/libjpeg to decode arbitrary user images.
|
||||
// PNG only. The GUI converts any other imported format (jpg, bmp, ...) on import, so this
|
||||
// code needs no dependency on wxWidgets/libjpeg to read arbitrary user images.
|
||||
return result;
|
||||
|
||||
png::ImageGreyscale img;
|
||||
if (!png::decode_png(rbuf, img) || img.cols == 0 || img.rows == 0)
|
||||
return result;
|
||||
if (png::decode_png(rbuf, img) && img.cols > 0 && img.rows > 0) {
|
||||
// The shipped library, and anything imported before colour was kept.
|
||||
result.width = int(img.cols);
|
||||
result.height = int(img.rows);
|
||||
result.pixels = std::move(img.buf);
|
||||
} else {
|
||||
// A colour source: keep the colour, and take the height from its luminance. The
|
||||
// coefficients are wxImage::ConvertToGreyscale()'s, which is what the importer used to
|
||||
// apply on the way in - so a texture that used to be flattened to grey at import time
|
||||
// displaces identically now that its colour is preserved.
|
||||
png::ImageColorscale col;
|
||||
if (!png::decode_colored_png(rbuf, col) || col.cols == 0 || col.rows == 0 ||
|
||||
col.bytes_per_pixel < 3)
|
||||
return result;
|
||||
|
||||
result.width = int(img.cols);
|
||||
result.height = int(img.rows);
|
||||
result.pixels = std::move(img.buf);
|
||||
const size_t n = size_t(col.cols) * size_t(col.rows);
|
||||
const size_t bpp = size_t(col.bytes_per_pixel);
|
||||
result.width = int(col.cols);
|
||||
result.height = int(col.rows);
|
||||
result.pixels.resize(n);
|
||||
result.rgb.resize(n * 3);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const uint8_t r = col.buf[i * bpp], g = col.buf[i * bpp + 1], b = col.buf[i * bpp + 2];
|
||||
result.rgb[i * 3] = r;
|
||||
result.rgb[i * 3 + 1] = g;
|
||||
result.rgb[i * 3 + 2] = b;
|
||||
result.pixels[i] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b));
|
||||
}
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
|
||||
// Opportunistically drop entries for image_data that no longer exists anywhere, so the
|
||||
@@ -184,6 +234,20 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
|
||||
const int max_radius = std::clamp(int(std::lround(0.02f * std::min(result.width, result.height))), 1, 32);
|
||||
const int radius = std::max(1, int(std::lround(layer.smoothing * float(max_radius))));
|
||||
smooth_height_pixels(result.pixels, result.width, result.height, radius);
|
||||
// Colour gets the same blur, per channel. It is the same knob for the same reason: detail in
|
||||
// the image finer than the mesh can carry is noise either way, and low-passing it here is the
|
||||
// cheapest place to remove it - one blur of the texture, rather than a fight per triangle.
|
||||
if (result.has_color()) {
|
||||
const size_t n = size_t(result.width) * size_t(result.height);
|
||||
std::vector<uint8_t> channel(n);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
channel[i] = result.rgb[i * 3 + size_t(c)];
|
||||
smooth_height_pixels(channel, result.width, result.height, radius);
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
result.rgb[i * 3 + size_t(c)] = channel[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1029,6 +1093,109 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
|
||||
w.z() * sample_at(Vec2f(position.x(), position.y()));
|
||||
}
|
||||
|
||||
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
|
||||
const Vec3f &position, const Vec3f &normal, Vec3f &out, const Vec3f &patch_center,
|
||||
const Vec3f &patch_axis, const Vec2f *lscm_uv)
|
||||
{
|
||||
if (!texture.has_color())
|
||||
return false;
|
||||
|
||||
// Deliberately a transcription of sample_layer_height()'s dispatch rather than a shared template:
|
||||
// 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;
|
||||
auto sample_at = [&](const Vec2f &planar) {
|
||||
return texture.sample_color(apply_uv_transform(planar, layer, aspect), layer.tile_enabled,
|
||||
layer.tile_method);
|
||||
};
|
||||
// Outside a non-tiled placement there is no texture at all - the same hard edge sample() gives the
|
||||
// height. Checked explicitly because sample_color() reports it as black, which is a real colour.
|
||||
auto covered = [&](const Vec2f &planar) {
|
||||
if (layer.tile_enabled)
|
||||
return true;
|
||||
const Vec2f uv = apply_uv_transform(planar, layer, aspect);
|
||||
return uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f;
|
||||
};
|
||||
|
||||
if (lscm_uv != nullptr) {
|
||||
if (!covered(*lscm_uv))
|
||||
return false;
|
||||
out = sample_at(*lscm_uv);
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (layer.projection_method) {
|
||||
case TextureProjectionMethod::Cylindrical: {
|
||||
const Vec2f p = project_cylindrical(position, patch_center, patch_axis);
|
||||
if (!covered(p))
|
||||
return false;
|
||||
out = sample_at(p);
|
||||
return true;
|
||||
}
|
||||
case TextureProjectionMethod::Spherical: {
|
||||
const Vec2f p = project_spherical(position, patch_center);
|
||||
if (!covered(p))
|
||||
return false;
|
||||
out = sample_at(p);
|
||||
return true;
|
||||
}
|
||||
case TextureProjectionMethod::ViewProjected:
|
||||
if (layer.view_project_projective) {
|
||||
// The frame's own rectangle is the placement, so no apply_uv_transform() - see
|
||||
// sample_layer_height(). A point behind the projector has no uv, hence no colour.
|
||||
Vec2f uv;
|
||||
if (!project_uv_projective(layer.view_project_matrix, position, uv))
|
||||
return false;
|
||||
if (!layer.tile_enabled && (uv.x() < 0.f || uv.x() >= 1.f || uv.y() < 0.f || uv.y() >= 1.f))
|
||||
return false;
|
||||
out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method);
|
||||
return true;
|
||||
} else {
|
||||
const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up));
|
||||
if (!covered(p))
|
||||
return false;
|
||||
out = sample_at(p);
|
||||
return true;
|
||||
}
|
||||
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
|
||||
case TextureProjectionMethod::Triplanar:
|
||||
default: break;
|
||||
}
|
||||
|
||||
// Blended tri-planar, weighted exactly as the height is, so colour and relief stay registered
|
||||
// across the cross-fade band at a 90-degree edge.
|
||||
Vec3f w = normal.cwiseAbs();
|
||||
w = Vec3f(std::pow(w.x(), TRIPLANAR_BLEND_SHARPNESS), std::pow(w.y(), TRIPLANAR_BLEND_SHARPNESS),
|
||||
std::pow(w.z(), TRIPLANAR_BLEND_SHARPNESS));
|
||||
const float w_sum = w.x() + w.y() + w.z();
|
||||
if (w_sum < 1e-8f) {
|
||||
const Vec2f p(position.x(), position.y());
|
||||
if (!covered(p))
|
||||
return false;
|
||||
out = sample_at(p);
|
||||
return true;
|
||||
}
|
||||
w /= w_sum;
|
||||
|
||||
// A blend of three planes is only "not covered" where *every* contributing plane is outside the
|
||||
// placement; where some are, the covered ones are renormalised so the colour does not fade toward
|
||||
// black at the edge of an untiled tri-planar layer.
|
||||
const std::array<Vec2f, 3> planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()),
|
||||
Vec2f(position.x(), position.y()) };
|
||||
Vec3f acc = Vec3f::Zero();
|
||||
float acc_w = 0.f;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
if (w[i] > 0.f && covered(planes[size_t(i)])) {
|
||||
acc += w[i] * sample_at(planes[size_t(i)]);
|
||||
acc_w += w[i];
|
||||
}
|
||||
if (acc_w <= 0.f)
|
||||
return false;
|
||||
out = acc / acc_w;
|
||||
return true;
|
||||
}
|
||||
|
||||
indexed_triangle_set extract_painted_patch(const indexed_triangle_set &base_mesh,
|
||||
const TriangleSelector::TriangleSplittingData &facet_data)
|
||||
{
|
||||
@@ -1135,11 +1302,71 @@ std::vector<float> patch_boundary_distance(const indexed_triangle_set &patch, co
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
// Majority filter over face adjacency: each triangle takes the most common colour among itself and
|
||||
// the (up to three) triangles across its edges. Ties, and a triangle whose own colour is already the
|
||||
// most common, keep what they had - so the filter only ever removes a facet that disagrees with its
|
||||
// whole neighbourhood, and cannot drift a large region.
|
||||
//
|
||||
// Read from a snapshot of the previous pass, so the result does not depend on triangle order.
|
||||
// Uncoloured triangles (-1) neither vote nor get voted on: the paint boundary is not noise.
|
||||
void despeckle_triangle_colors(const indexed_triangle_set &mesh, std::vector<int> &color, int passes)
|
||||
{
|
||||
if (passes <= 0 || color.size() != mesh.indices.size())
|
||||
return;
|
||||
const std::vector<Vec3i32> neighbors = its_face_neighbors(mesh);
|
||||
if (neighbors.size() != mesh.indices.size())
|
||||
return;
|
||||
|
||||
std::vector<int> prev;
|
||||
for (int pass = 0; pass < passes; ++pass) {
|
||||
prev = color;
|
||||
tbb::parallel_for(tbb::blocked_range<size_t>(0, color.size()),
|
||||
[&](const tbb::blocked_range<size_t> &range) {
|
||||
for (size_t i = range.begin(); i < range.end(); ++i) {
|
||||
if (prev[i] < 0)
|
||||
continue;
|
||||
// At most four candidates (self plus three neighbours), so counting by a linear scan
|
||||
// is cheaper than any map.
|
||||
int cand[4] = { prev[i], -1, -1, -1 };
|
||||
int count[4] = { 1, 0, 0, 0 };
|
||||
int n = 1;
|
||||
for (int e = 0; e < 3; ++e) {
|
||||
const int nb = neighbors[i][e];
|
||||
if (nb < 0 || size_t(nb) >= prev.size() || prev[size_t(nb)] < 0)
|
||||
continue;
|
||||
const int c = prev[size_t(nb)];
|
||||
int k = 0;
|
||||
for (; k < n; ++k)
|
||||
if (cand[k] == c) {
|
||||
++count[k];
|
||||
break;
|
||||
}
|
||||
if (k == n && n < 4) {
|
||||
cand[n] = c;
|
||||
count[n] = 1;
|
||||
++n;
|
||||
}
|
||||
}
|
||||
// Strictly greater, so a tie leaves the triangle alone.
|
||||
int best = 0;
|
||||
for (int k = 1; k < n; ++k)
|
||||
if (count[k] > count[best])
|
||||
best = k;
|
||||
if (count[best] > count[0])
|
||||
color[i] = cand[best];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementFacetsData &facets_data,
|
||||
const TextureDisplacementOptions &options,
|
||||
const DisplacementProgressFn &progress)
|
||||
const DisplacementProgressFn &progress,
|
||||
const TextureColorRequest *color)
|
||||
{
|
||||
// Returns true to keep going. An aborted run returns {} (see the header): an empty mesh is the
|
||||
// one result no caller can mistake for a finished bake and commit onto the volume.
|
||||
@@ -1223,6 +1450,19 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
|
||||
std::vector<bool> on_patch_border(mesh.vertices.size(), false);
|
||||
bool any_displacement = false;
|
||||
|
||||
// Colour is accumulated per *triangle*, not per vertex: it ends up in the volume's
|
||||
// mmu_segmentation_facets, which assigns one filament to a whole facet. Layers are visited in
|
||||
// ascending slot order, so a higher layer simply overwrites a lower one's colour where they
|
||||
// overlap - the painter's-algorithm reading of a layer stack, and the one that matches how the
|
||||
// panel lists them.
|
||||
const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize);
|
||||
// Palette indices, not filament indices: -1 for "no colour here". Kept in perceived-colour space
|
||||
// for the whole pass so the despeckle filter below operates on what the eye sees, and the
|
||||
// interleaving that turns a mixed entry into two real filaments happens once, at the very end.
|
||||
std::vector<int> triangle_palette;
|
||||
if (want_color)
|
||||
triangle_palette.assign(mesh.indices.size(), -1);
|
||||
|
||||
const TriangleMesh selector_mesh(mesh);
|
||||
// One selector for the whole stack, re-deserialized per layer. Its constructor computes
|
||||
// its_face_neighbors() and its_face_normals() over the *entire* mesh, which on a subdivided model
|
||||
@@ -1251,7 +1491,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
|
||||
selector.deserialize(data, selector_dirty);
|
||||
selector_dirty = true;
|
||||
|
||||
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER);
|
||||
const bool color_this_layer = want_color && layer->color_enabled;
|
||||
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 ? &patch_source : nullptr);
|
||||
if (patch.indices.empty())
|
||||
continue;
|
||||
// get_facets_strict() returns the same vertex array whichever state is asked for (only the
|
||||
@@ -1309,6 +1552,65 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
|
||||
compute_lscm_uvs(patch, *layer) :
|
||||
std::vector<Vec2f>{};
|
||||
|
||||
// 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
|
||||
// than of the whole triangle's worth of texture - and so a triangle straddling a colour
|
||||
// boundary lands on whichever side covers more of it, instead of on whichever sub-triangle
|
||||
// happened to be emitted first. One quantize call per triangle, after the averaging.
|
||||
if (color_this_layer) {
|
||||
const DecodedHeightTexture &tex = height;
|
||||
if (tex.has_color()) {
|
||||
std::vector<Vec3f> sum(mesh.indices.size(), Vec3f::Zero());
|
||||
std::vector<float> sum_area(mesh.indices.size(), 0.f);
|
||||
for (size_t j = 0; j < patch.indices.size() && j < patch_source.size(); ++j) {
|
||||
const size_t S = size_t(patch_source[j]);
|
||||
if (S >= mesh.indices.size())
|
||||
continue;
|
||||
const stl_triangle_vertex_indices &t = patch.indices[j];
|
||||
const Vec3f &pa = patch.vertices[size_t(t[0])];
|
||||
const Vec3f &pb = patch.vertices[size_t(t[1])];
|
||||
const Vec3f &pc = patch.vertices[size_t(t[2])];
|
||||
const float area2 = (pb - pa).cross(pc - pa).norm();
|
||||
if (area2 <= 0.f)
|
||||
continue;
|
||||
const Vec3f centroid = (pa + pb + pc) / 3.f;
|
||||
|
||||
// The normal the triplanar blend weights by, and the unwrap coordinate the LSCM
|
||||
// path needs, both averaged over the sub-triangle's corners - the same quantities
|
||||
// the per-vertex height sampling uses, evaluated at the centroid instead.
|
||||
Vec3f n = Vec3f::Zero();
|
||||
Vec2f uv = Vec2f::Zero();
|
||||
bool have_uv = !lscm_uvs.empty();
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
const int vi = t[k];
|
||||
if (vi < int(vertex_normals.size()))
|
||||
n += vertex_normals[size_t(vi)];
|
||||
if (have_uv && size_t(vi) < lscm_uvs.size())
|
||||
uv += lscm_uvs[size_t(vi)];
|
||||
else
|
||||
have_uv = false;
|
||||
}
|
||||
n = (n.norm() > 1e-8f) ? Vec3f(n.normalized()) : average_normal;
|
||||
uv /= 3.f;
|
||||
|
||||
Vec3f rgb;
|
||||
if (sample_layer_color(tex, *layer, centroid, n, rgb, patch_centroid, patch_axis,
|
||||
have_uv ? &uv : nullptr)) {
|
||||
sum[S] += area2 * rgb;
|
||||
sum_area[S] += area2;
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < mesh.indices.size(); ++i)
|
||||
if (sum_area[i] > 0.f) {
|
||||
const int idx = color->quantize(sum[i] / sum_area[i]);
|
||||
// A quantizer that declines this colour leaves whatever a lower layer put
|
||||
// there, rather than punching a hole in it.
|
||||
if (idx >= 0)
|
||||
triangle_palette[i] = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edge smoothing: a per-vertex weight in [0, 1] that fades the displacement to zero toward the
|
||||
// patch boundary. amount->0 leaves only the very edge softened; amount->1 fades the whole patch
|
||||
// flat. k = (1-a)/a turns the normalized boundary distance into that weight (see the header).
|
||||
@@ -1424,6 +1726,29 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
|
||||
|
||||
if (!report(99))
|
||||
return {};
|
||||
if (want_color) {
|
||||
// Despeckle in perceived-colour space, then resolve each entry to a real filament. The order
|
||||
// matters both ways round: filtering after the interleave would erase the bands it is supposed
|
||||
// to keep, and interleaving before the filter would have the filter treat two halves of one
|
||||
// blended colour as a disagreement.
|
||||
despeckle_triangle_colors(mesh, triangle_palette, color->despeckle_passes);
|
||||
|
||||
std::vector<uint8_t> out_color(mesh.indices.size(), 0);
|
||||
for (size_t i = 0; i < mesh.indices.size(); ++i) {
|
||||
if (triangle_palette[i] < 0)
|
||||
continue;
|
||||
const stl_triangle_vertex_indices &t = mesh.indices[i];
|
||||
const Vec3f centroid = (mesh.vertices[size_t(t[0])] + mesh.vertices[size_t(t[1])] +
|
||||
mesh.vertices[size_t(t[2])]) / 3.f;
|
||||
const int filament = color->resolve ? color->resolve(triangle_palette[i], centroid)
|
||||
: triangle_palette[i];
|
||||
if (filament >= 0)
|
||||
out_color[i] = uint8_t(std::min(filament + 1, 255));
|
||||
}
|
||||
// Handed over only on a run that completed: every early return above is a cancellation, and
|
||||
// the caller must not commit a half-computed colouring any more than a half-displaced mesh.
|
||||
*color->out_triangle = std::move(out_color);
|
||||
}
|
||||
return mesh;
|
||||
}
|
||||
|
||||
@@ -1495,22 +1820,26 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t>
|
||||
}
|
||||
}
|
||||
|
||||
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementFacetsData &facets_data)
|
||||
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.
|
||||
struct PreparedLayer {
|
||||
DecodedHeightTexture tex;
|
||||
TextureDisplacementLayer layer; // a copy of the params (depth/tiling/rotation/offset/blend/...)
|
||||
Vec3f center; // patch centroid, for Cylindrical/Spherical
|
||||
Vec3f axis; // cylinder axis, for Cylindrical
|
||||
};
|
||||
|
||||
// Shared by both point samplers, so the height field and the colour field can never disagree about
|
||||
// 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)
|
||||
{
|
||||
// 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.
|
||||
struct Prepared {
|
||||
DecodedHeightTexture tex;
|
||||
TextureDisplacementLayer layer; // a copy of the params (depth/tiling/rotation/offset/blend/...)
|
||||
Vec3f center; // patch centroid, for Cylindrical/Spherical
|
||||
Vec3f axis; // cylinder axis, for Cylindrical
|
||||
};
|
||||
auto prepared = std::make_shared<std::vector<Prepared>>();
|
||||
auto prepared = std::make_shared<std::vector<PreparedLayer>>();
|
||||
|
||||
if (base_mesh.indices.empty())
|
||||
return nullptr;
|
||||
return prepared;
|
||||
|
||||
std::vector<const TextureDisplacementLayer *> ordered;
|
||||
for (const TextureDisplacementLayer &l : layers)
|
||||
@@ -1525,11 +1854,13 @@ HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set
|
||||
for (const TextureDisplacementLayer *layer : ordered) {
|
||||
if (layer->projection_method == TextureProjectionMethod::LSCM)
|
||||
continue; // no per-point UV -> not sampleable here (caller falls back to uniform for these)
|
||||
if (need_color && !layer->color_enabled)
|
||||
continue;
|
||||
const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)];
|
||||
if (data.triangles_to_split.empty())
|
||||
continue;
|
||||
const DecodedHeightTexture tex = decode_height_texture(*layer);
|
||||
if (tex.empty())
|
||||
if (tex.empty() || (need_color && !tex.has_color()))
|
||||
continue;
|
||||
|
||||
TriangleSelector selector(selector_mesh);
|
||||
@@ -1563,14 +1894,47 @@ HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set
|
||||
|
||||
prepared->push_back({ tex, *layer, centroid, axis });
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementFacetsData &facets_data,
|
||||
ColorQuantizeFn quantize)
|
||||
{
|
||||
if (!quantize)
|
||||
return nullptr;
|
||||
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true);
|
||||
if (prepared->empty())
|
||||
return nullptr;
|
||||
|
||||
return [prepared, quantize = std::move(quantize)](const Vec3f &pos, const Vec3f &normal) -> int {
|
||||
// Last one wins: `prepared` is in ascending slot order and the bake lets a higher layer
|
||||
// overwrite a lower one's colour, so the sampler has to resolve overlaps the same way.
|
||||
int result = -1;
|
||||
for (const PreparedLayer &p : *prepared) {
|
||||
Vec3f rgb;
|
||||
if (sample_layer_color(p.tex, p.layer, pos, normal, rgb, p.center, p.axis, nullptr))
|
||||
if (const int idx = quantize(rgb); idx >= 0)
|
||||
result = idx;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementFacetsData &facets_data)
|
||||
{
|
||||
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false);
|
||||
if (prepared->empty())
|
||||
return nullptr;
|
||||
|
||||
return [prepared](const Vec3f &pos, const Vec3f &normal) -> float {
|
||||
float total = 0.f;
|
||||
bool any = false;
|
||||
for (const Prepared &p : *prepared) {
|
||||
for (const PreparedLayer &p : *prepared) {
|
||||
const float h = sample_layer_height(p.tex, p.layer, pos, normal, p.center, p.axis, nullptr);
|
||||
const float sign = p.layer.invert ? -1.f : 1.f;
|
||||
const float signed_h = (h - p.layer.midlevel) * p.layer.depth_mm * sign;
|
||||
@@ -1646,7 +2010,8 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
std::vector<int> *out_source, const HeightFieldSampler &sampler,
|
||||
float chord_tolerance_mm, float min_edge_length_mm,
|
||||
float border_edge_length_mm,
|
||||
const DisplacementProgressFn &progress)
|
||||
const DisplacementProgressFn &progress,
|
||||
const ColorFieldSampler &color, float color_edge_length_mm)
|
||||
{
|
||||
// Neighbour slots that are not a triangle index.
|
||||
constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone
|
||||
@@ -1682,6 +2047,8 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
// Feature-adaptive when a sampler and a positive tolerance are supplied; otherwise refinement is
|
||||
// driven by the length baseline alone.
|
||||
const bool feature_mode = bool(sampler) && chord_tolerance_mm > 0.f;
|
||||
const bool color_mode = bool(color) && color_edge_length_mm > 0.f;
|
||||
const float color_sq = color_edge_length_mm > 0.f ? color_edge_length_mm * color_edge_length_mm : 0.f;
|
||||
const float min_floor_sq = min_edge_length_mm > 0.f ? min_edge_length_mm * min_edge_length_mm : 0.f;
|
||||
const float target_sq = target_edge_length_mm > 0.f ? target_edge_length_mm * target_edge_length_mm : 0.f;
|
||||
const float border_sq = border_edge_length_mm > 0.f ? border_edge_length_mm * border_edge_length_mm : 0.f;
|
||||
@@ -1690,7 +2057,7 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
// (children inherit their parent's src), so a wrong size would be an out-of-bounds read. Guard it.
|
||||
if (refine_region.size() != mesh.indices.size() || int(tris.size()) + 2 > max_triangles)
|
||||
return emit();
|
||||
if (!feature_mode && target_sq <= 0.f && border_sq <= 0.f)
|
||||
if (!feature_mode && !color_mode && target_sq <= 0.f && border_sq <= 0.f)
|
||||
return emit(); // no criterion at all
|
||||
if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; }))
|
||||
return emit(); // nothing flagged: no-op
|
||||
@@ -1757,6 +2124,34 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
return vheight[v];
|
||||
};
|
||||
|
||||
// Per-vertex filament index, sampled lazily and cached the same way the heights are. Needs the
|
||||
// vertex normals, which feature mode also builds - so colour mode builds them when it is on alone.
|
||||
std::vector<int> vcolor;
|
||||
std::vector<uint8_t> vcolor_valid;
|
||||
if (color_mode) {
|
||||
if (vnormal.empty()) {
|
||||
vnormal.assign(verts.size(), Vec3f::Zero());
|
||||
for (const Tri &t : tris) {
|
||||
const Vec3f fn = (verts[t.v[1]] - verts[t.v[0]]).cross(verts[t.v[2]] - verts[t.v[0]]);
|
||||
for (int i = 0; i < 3; ++i)
|
||||
vnormal[t.v[i]] += fn;
|
||||
}
|
||||
for (Vec3f &n : vnormal) {
|
||||
const float l = n.norm();
|
||||
n = (l > 1e-12f) ? Vec3f(n / l) : Vec3f(Vec3f::UnitZ());
|
||||
}
|
||||
}
|
||||
vcolor.assign(verts.size(), -2); // -2 = not sampled yet; -1 = sampled, no colour there
|
||||
vcolor_valid.assign(verts.size(), 0);
|
||||
}
|
||||
auto color_of = [&](int v) -> int {
|
||||
if (!vcolor_valid[v]) {
|
||||
vcolor[v] = color(verts[v], vnormal[v]);
|
||||
vcolor_valid[v] = 1;
|
||||
}
|
||||
return vcolor[v];
|
||||
};
|
||||
|
||||
auto elen_sq = [&](int a, int b) -> float { return (verts[a] - verts[b]).squaredNorm(); };
|
||||
|
||||
// The one edge of a triangle taken as its "longest": greatest squared length, exact ties broken by
|
||||
@@ -1788,6 +2183,38 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
std::vector<float> tri_err;
|
||||
if (feature_mode)
|
||||
tri_err.assign(tris.size(), -1.f);
|
||||
// True when this triangle straddles a colour boundary: its corners, its edge midpoints and its
|
||||
// centroid do not all take the same filament. The midpoints and centroid matter for the same
|
||||
// reason they do in detail_error() - a boundary can cross a triangle without separating any two of
|
||||
// its corners. Cached per triangle; a split invalidates both children.
|
||||
std::vector<uint8_t> tri_color_split; // 0 = unknown, 1 = straddles, 2 = uniform
|
||||
if (color_mode)
|
||||
tri_color_split.assign(tris.size(), 0);
|
||||
auto straddles_color = [&](int ti) -> bool {
|
||||
if (tri_color_split[ti] != 0)
|
||||
return tri_color_split[ti] == 1;
|
||||
const Tri &t = tris[ti];
|
||||
const Vec3f pa = verts[t.v[0]], pb = verts[t.v[1]], pc = verts[t.v[2]];
|
||||
const Vec3f na = vnormal[t.v[0]], nb = vnormal[t.v[1]], nc = vnormal[t.v[2]];
|
||||
const int ca = color_of(t.v[0]);
|
||||
bool split = color_of(t.v[1]) != ca || color_of(t.v[2]) != ca;
|
||||
if (!split) {
|
||||
static const float BARY[4][3] = { { 0.5f, 0.5f, 0.f }, { 0.f, 0.5f, 0.5f },
|
||||
{ 0.5f, 0.f, 0.5f }, { 1.f / 3, 1.f / 3, 1.f / 3 } };
|
||||
for (const auto &w : BARY) {
|
||||
Vec3f n = w[0] * na + w[1] * nb + w[2] * nc;
|
||||
const float nl = n.norm();
|
||||
n = (nl > 1e-12f) ? Vec3f(n / nl) : na;
|
||||
if (color(w[0] * pa + w[1] * pb + w[2] * pc, n) != ca) {
|
||||
split = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tri_color_split[ti] = split ? 1 : 2;
|
||||
return split;
|
||||
};
|
||||
|
||||
auto detail_error = [&](int ti) -> float {
|
||||
if (tri_err[ti] >= 0.f)
|
||||
return tri_err[ti];
|
||||
@@ -1828,6 +2255,10 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
p = (target_sq > 0.f) ? ll / target_sq : 0.f;
|
||||
if (feature_mode)
|
||||
p = std::max(p, detail_error(ti) / chord_tolerance_mm);
|
||||
// Colour is per facet, so a colour boundary can only be drawn where there are edges along
|
||||
// it. Length target, floored by min_edge_length_mm above, exactly like the border band.
|
||||
if (color_mode && straddles_color(ti))
|
||||
p = std::max(p, ll / color_sq);
|
||||
}
|
||||
// The band straddling the paint's edge, refined by plain edge length. Deliberately *not* run
|
||||
// through detail_error(): outside the paint the sampler still reports full relief (it has no
|
||||
@@ -1878,13 +2309,19 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
|
||||
const int m = int(verts.size());
|
||||
verts.push_back(0.5f * (verts[a] + verts[b]));
|
||||
if (feature_mode) {
|
||||
if (feature_mode || color_mode) {
|
||||
const Vec3f mn = vnormal[a] + vnormal[b];
|
||||
const float ml = mn.norm();
|
||||
vnormal.push_back(ml > 1e-12f ? Vec3f(mn / ml) : vnormal[a]);
|
||||
}
|
||||
if (feature_mode) {
|
||||
vheight.push_back(0.f);
|
||||
vheight_valid.push_back(0);
|
||||
}
|
||||
if (color_mode) {
|
||||
vcolor.push_back(-2);
|
||||
vcolor_valid.push_back(0);
|
||||
}
|
||||
|
||||
// Near side: ti becomes (a, m, c), the new triangle is (m, b, c). Both keep the original
|
||||
// a->b->c winding.
|
||||
@@ -1903,6 +2340,10 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
tri_err.push_back(-1.f);
|
||||
tri_err[ti] = -1.f;
|
||||
}
|
||||
if (color_mode) {
|
||||
tri_color_split.push_back(0);
|
||||
tri_color_split[ti] = 0;
|
||||
}
|
||||
touched.assign({ ti, t2 });
|
||||
|
||||
if (n < 0) {
|
||||
@@ -1926,6 +2367,10 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
tri_err.push_back(-1.f);
|
||||
tri_err[n] = -1.f;
|
||||
}
|
||||
if (color_mode) {
|
||||
tri_color_split.push_back(0);
|
||||
tri_color_split[n] = 0;
|
||||
}
|
||||
|
||||
// Stitch the two sides back together: whichever far child holds `a` borders the near child
|
||||
// that holds `a`. (Which one that is depends on how n happens to be wound.)
|
||||
|
||||
@@ -274,6 +274,17 @@ struct TextureDisplacementLayer
|
||||
// it as the "Base" layer and hides the control).
|
||||
TextureBlendMode blend_mode = TextureBlendMode::Add;
|
||||
|
||||
// Colour this layer's painted area from the texture's own colours, on top of displacing by its
|
||||
// height. Only meaningful when the texture actually has colour (DecodedHeightTexture::has_color()):
|
||||
// the shipped library is grayscale, so this does nothing there.
|
||||
//
|
||||
// Colour lands in the volume's mmu_segmentation_facets - the same per-triangle filament assignment
|
||||
// the MMU paint gizmo writes - so its resolution is the *mesh's*, not the image's, and a triangle
|
||||
// gets exactly one filament. That is why the adaptive subdivision has a colour criterion of its
|
||||
// own (see subdivide_mesh_adaptive()): without triangles along a colour boundary there is nothing
|
||||
// for the boundary to be drawn on.
|
||||
bool color_enabled = false;
|
||||
|
||||
bool empty() const { return !image_data || image_data->empty(); }
|
||||
|
||||
template<class Archive> void save(Archive &ar) const
|
||||
@@ -283,7 +294,7 @@ struct TextureDisplacementLayer
|
||||
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
|
||||
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
|
||||
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
|
||||
lscm_uv_overrides, view_project_projective, view_project_matrix);
|
||||
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled);
|
||||
}
|
||||
template<class Archive> void load(Archive &ar)
|
||||
{
|
||||
@@ -295,7 +306,7 @@ struct TextureDisplacementLayer
|
||||
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
|
||||
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
|
||||
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
|
||||
view_project_matrix);
|
||||
view_project_matrix, color_enabled);
|
||||
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
|
||||
tile_method = static_cast<TextureTileMethod>(tile_method_int);
|
||||
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
|
||||
@@ -303,6 +314,22 @@ struct TextureDisplacementLayer
|
||||
}
|
||||
};
|
||||
|
||||
// How a *mixed* palette entry - one that names two filaments rather than one - is turned into real
|
||||
// per-facet paint. An MMU extrudes one filament at a time, so an intermediate colour exists only by
|
||||
// interleaving two of them finely enough that the eye does the blending.
|
||||
enum class ColorMixMode : int
|
||||
{
|
||||
// Horizontal bands: which of the two filaments a point takes depends on its height, so
|
||||
// consecutive print layers alternate. This is how filament-blend prints actually work, and on a
|
||||
// vertical-ish surface it reads as a genuinely smooth colour. On a near-horizontal surface a whole
|
||||
// layer is one band, so the blend disappears - that is what XYDither is for.
|
||||
ZBands = 0,
|
||||
// An ordered (Bayer) checkerboard across the surface, at any orientation. Independent of layer
|
||||
// height, but its cell is around the size of one facet, so a fine mix can read as texture rather
|
||||
// than as a clean blend.
|
||||
XYDither = 1,
|
||||
};
|
||||
|
||||
// Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next
|
||||
// to texture_displacement_layers and consumed by build_texture_displacement().
|
||||
struct TextureDisplacementOptions
|
||||
@@ -338,33 +365,118 @@ struct TextureDisplacementOptions
|
||||
// deliberately (which is a blunter version of the per-layer edge-smoothing falloff).
|
||||
bool smooth_skip_border = true;
|
||||
|
||||
// Colour, all of which belongs to the stack rather than to any one layer: it is about how the
|
||||
// printer will realise the colours, not about which image they came from.
|
||||
|
||||
// Interleave pairs of filaments to get colours between them - so four loaded filaments offer far
|
||||
// more than four colours. Off means every triangle takes one of the loaded filaments exactly.
|
||||
bool color_mix_enabled = true;
|
||||
ColorMixMode color_mix_mode = ColorMixMode::ZBands;
|
||||
// Majority-filter passes over the assigned colours. See TextureColorRequest::despeckle_passes -
|
||||
// this is the control for it, and 2 is enough to clear the salt-and-pepper an image with detail
|
||||
// finer than the mesh leaves behind, without eating features that are genuinely a facet wide.
|
||||
int color_despeckle = 2;
|
||||
|
||||
template<class Archive> void serialize(Archive &ar)
|
||||
{
|
||||
ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border);
|
||||
int mix_mode = int(color_mix_mode);
|
||||
ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border,
|
||||
color_mix_enabled, mix_mode, color_despeckle);
|
||||
color_mix_mode = ColorMixMode(mix_mode);
|
||||
}
|
||||
};
|
||||
|
||||
// Decoded 8-bit grayscale height sample, independent of any GUI/OpenGL texture object so it can
|
||||
// be evaluated from a background bake Job as well as from GUI-side preview code.
|
||||
// Decoded height (and, for a colour source image, colour) samples, independent of any GUI/OpenGL
|
||||
// texture object so they can be evaluated from a background bake Job as well as from GUI-side
|
||||
// preview code.
|
||||
struct DecodedHeightTexture
|
||||
{
|
||||
std::vector<uint8_t> pixels; // row-major, top-to-bottom, one byte per pixel
|
||||
std::vector<uint8_t> pixels; // height: row-major, top-to-bottom, one byte per pixel
|
||||
// Colour: the same grid, three bytes per pixel, or empty when the source image was grayscale.
|
||||
// A grayscale height map has no colour to give - `pixels` is not a colour, it is a height - so
|
||||
// has_color() is what the whole colour feature keys off: a layer set to colour a model with a
|
||||
// grayscale texture on it simply colours nothing.
|
||||
std::vector<uint8_t> rgb;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool empty() const { return width <= 0 || height <= 0 || pixels.empty(); }
|
||||
bool has_color() const { return !empty() && rgb.size() == size_t(width) * size_t(height) * 3; }
|
||||
// Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is false,
|
||||
// a uv outside [0, 1) samples as 0 - the texture simply is not there, rather than its border
|
||||
// row/column being smeared outward forever (which is what clamping the coordinate would do, and
|
||||
// was a real reported bug). Callers rely on this to get a hard edge: it is how the projection
|
||||
// frame's border becomes the edge of the displacement.
|
||||
float sample(const Vec2f &uv, bool tile_enabled = true, TextureTileMethod tile_method = TextureTileMethod::Repeat) const;
|
||||
// The same sample, in colour: linear RGB components in [0, 1]. Outside a non-tiled placement, and
|
||||
// for a grayscale source, this is (0, 0, 0) - callers pair it with has_color() and with the
|
||||
// height's own coverage rather than trying to read "no texture here" out of the colour itself.
|
||||
Vec3f sample_color(const Vec2f &uv, bool tile_enabled = true,
|
||||
TextureTileMethod tile_method = TextureTileMethod::Repeat) const;
|
||||
|
||||
// Where a uv lands on the texel grid: the four texels of the bilinear tap and their weights.
|
||||
// Shared by sample() and sample_color(), so a layer's height and its colour can never end up
|
||||
// read from different places in the image. False means the uv is outside a non-tiled placement -
|
||||
// no texture there at all (see sample()).
|
||||
struct TexelTap
|
||||
{
|
||||
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
|
||||
float tx = 0.f, ty = 0.f;
|
||||
};
|
||||
bool texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &out) const;
|
||||
};
|
||||
|
||||
// Decode a layer's raw image bytes into sampleable grayscale height data. Returns an empty
|
||||
// DecodedHeightTexture if image_data is empty or is not an 8-bit grayscale PNG.
|
||||
// Decode a layer's raw image bytes into sampleable height data, plus colour when the source has any.
|
||||
// Both 8-bit grayscale PNGs (the shipped library, and anything imported before colour was kept) and
|
||||
// colour PNGs are accepted; for a colour source the height is its luminance, using the same
|
||||
// coefficients wxImage::ConvertToGreyscale() uses, so a texture imported as colour displaces exactly
|
||||
// as it did when the importer flattened it to grey on the way in. Returns an empty
|
||||
// DecodedHeightTexture if image_data is empty or is not a PNG at all.
|
||||
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer);
|
||||
|
||||
// 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
|
||||
// *perceptual* question (CIEDE2000 over CIELAB), and that machinery - slic3r/Utils/ColorSpaceConvert
|
||||
// and GuiColor - lives on the GUI side along with the list of filaments actually loaded. libslic3r
|
||||
// samples the image and decides *where* colour changes; the GUI decides *which* filament each colour
|
||||
// is. See GLGizmoTextureDisplacement::make_palette_quantizer().
|
||||
using ColorQuantizeFn = std::function<int(const Vec3f &)>;
|
||||
|
||||
// Resolves a palette index plus a surface position to the filament index that position should print
|
||||
// in. A pure entry ignores the position; a mixed one interleaves its two filaments per ColorMixMode.
|
||||
//
|
||||
// Deliberately separate from ColorQuantizeFn, and deliberately *not* used by the subdivision's colour
|
||||
// criterion: that criterion asks where the **perceived** colour changes, and must not see the
|
||||
// interleaving. Refining on every band or dither-cell boundary would spend the whole triangle budget
|
||||
// drawing a pattern the eye is supposed to blend away.
|
||||
using ColorResolveFn = std::function<int(int palette_index, const Vec3f &pos)>;
|
||||
|
||||
// One printable colour: either a loaded filament on its own, or a blend of two of them realised by
|
||||
// interleaving (see ColorMixMode). Plain data, so it can be captured into a background job.
|
||||
struct PrintableColor
|
||||
{
|
||||
Vec3f rgb = Vec3f::Zero(); // what it looks like; for a mix, the perceptual average of the two
|
||||
int a = 0; // filament index
|
||||
int b = 0; // the second filament; == a for a pure entry
|
||||
int num = 1; // a's share of the interleave, out of `den`
|
||||
int den = 1;
|
||||
bool is_mix() const { return a != b; }
|
||||
};
|
||||
|
||||
// Everything needed to colour a mesh, captured on the main thread and handed to a job. An empty
|
||||
// palette means nothing is colouring, which is the state every one of these paths starts in.
|
||||
struct TextureColorSettings
|
||||
{
|
||||
std::vector<PrintableColor> palette;
|
||||
ColorMixMode mix_mode = ColorMixMode::ZBands;
|
||||
float layer_height = 0.2f; // sizes the Z bands
|
||||
float dither_cell_mm = 0.4f; // sizes the XY dither cells
|
||||
int despeckle_passes = 2;
|
||||
|
||||
bool empty() const { return palette.empty(); }
|
||||
};
|
||||
|
||||
// Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any
|
||||
// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather
|
||||
// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas
|
||||
@@ -409,6 +521,16 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
|
||||
const Vec3f &patch_center = Vec3f::Zero(), const Vec3f &patch_axis = Vec3f::UnitZ(),
|
||||
const Vec2f *lscm_uv = nullptr);
|
||||
|
||||
// The same sample, in colour, through the identical projection/tiling/placement path - so a layer's
|
||||
// colour lands on the model exactly where its relief does, whatever projection it is using. Returns
|
||||
// false (leaving `out` untouched) when the texture has no colour, or when the point falls outside a
|
||||
// non-tiled placement, or behind a projective "from view" projector: all three mean "this layer does
|
||||
// not colour this point", which is different from "this layer colours it black".
|
||||
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
|
||||
const Vec3f &position, const Vec3f &normal, Vec3f &out,
|
||||
const Vec3f &patch_center = Vec3f::Zero(), const Vec3f &patch_axis = Vec3f::UnitZ(),
|
||||
const Vec2f *lscm_uv = nullptr);
|
||||
|
||||
// Area-weighted centroid and average normal of a layer's currently painted patch, in mesh-local
|
||||
// coordinates - the same measurements build_texture_displacement() uses to pick its dominant
|
||||
// projection axis. Used by the GUI to anchor the on-canvas "adjust texture placement" gizmo to
|
||||
@@ -551,11 +673,38 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
|
||||
// committed. It exists because this is the one call in the feature that can take seconds on a
|
||||
// subdivided mesh, and without it the progress notification the Job framework puts on screen sits at
|
||||
// 0% for the whole run and offers no way to close it (its close button only appears at 100%).
|
||||
//
|
||||
// `color`, when given, also reports which filament each triangle should print in - see
|
||||
// TextureColorRequest.
|
||||
struct TextureColorRequest
|
||||
{
|
||||
// RGB -> palette index. Supplied by the GUI, which owns both the perceptual matching and the list
|
||||
// of filaments actually loaded (see ColorQuantizeFn).
|
||||
ColorQuantizeFn quantize;
|
||||
// Palette index + position -> filament. Optional: without it a palette index is taken to be a
|
||||
// filament index directly, which is the no-mixing case.
|
||||
ColorResolveFn resolve;
|
||||
// Majority-filter passes over the *perceived* colour, before any interleaving is resolved.
|
||||
//
|
||||
// Sampling a detailed image once per triangle leaves salt-and-pepper wherever the image's own
|
||||
// detail is finer than the mesh: two neighbouring facets land either side of some contour and flip
|
||||
// colour independently. Replacing each facet's colour with the most common one among itself and
|
||||
// its edge neighbours removes exactly that, and leaves any feature wider than a facet alone. 0
|
||||
// turns it off.
|
||||
int despeckle_passes = 0;
|
||||
// Filled per *base mesh* triangle (the bake is topology-preserving, so this indexes the returned
|
||||
// mesh too): the quantize callback's index plus one, or 0 for "this triangle takes no colour from
|
||||
// the texture". The +1 is not arbitrary - it lines up with EnforcerBlockerType, where 0 is NONE
|
||||
// ("use the volume's own filament") and 1..16 are Extruder1..16, so the caller can hand these
|
||||
// straight to a TriangleSelector without a second mapping table.
|
||||
std::vector<uint8_t> *out_triangle = nullptr;
|
||||
};
|
||||
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementFacetsData &facets_data,
|
||||
const TextureDisplacementOptions &options = {},
|
||||
const DisplacementProgressFn &progress = {});
|
||||
const DisplacementProgressFn &progress = {},
|
||||
const TextureColorRequest *color = nullptr);
|
||||
|
||||
// Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from
|
||||
// `volume` and forwards to the overload above.
|
||||
@@ -591,6 +740,22 @@ using HeightFieldSampler = std::function<float(const Vec3f &pos, const Vec3f &no
|
||||
// and edge-smoothing's boundary falloff is ignored. LSCM layers have no per-point UV and are skipped.
|
||||
// Returns a null sampler (bool false) when no layer can be sampled - the caller then falls back to
|
||||
// uniform adaptive subdivision.
|
||||
// Which filament the texture stack would put at a point, as a palette index (or -1 for "no colour
|
||||
// here"). The colour analogue of HeightFieldSampler, and used the same way: to decide where the
|
||||
// adaptive subdivision needs triangles. Colour lands per *facet*, so a colour boundary is a step the
|
||||
// mesh can only draw if there are edges along it - the chord-error test that drives the height
|
||||
// refinement is blind to it, exactly as it is blind to the paint's own border.
|
||||
using ColorFieldSampler = std::function<int(const Vec3f &pos, const Vec3f &normal)>;
|
||||
|
||||
// The colour counterpart of make_combined_displacement_sampler(), over the same layers, and skipping
|
||||
// the same ones (LSCM has no per-point UV). Layers without color_enabled, and layers whose texture is
|
||||
// grayscale, contribute nothing; a higher slot wins over a lower one where they overlap, matching the
|
||||
// bake. Returns null when no layer can colour anything, in which case there is nothing to refine for.
|
||||
ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementFacetsData &facets_data,
|
||||
ColorQuantizeFn quantize);
|
||||
|
||||
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementFacetsData &facets_data);
|
||||
@@ -678,6 +843,15 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl
|
||||
// plain edge length is bounded (it is a thin ring, and a length target always terminates) and needs
|
||||
// no paint-aware sampler.
|
||||
//
|
||||
// `color`, with a positive `color_edge_length_mm`, adds a third criterion inside the painted area: a
|
||||
// triangle whose corners, edge midpoints and centroid do not all map to the *same* filament straddles
|
||||
// a colour boundary, and is refined by plain edge length down to that target. Length rather than any
|
||||
// error measure, for the same reason the border band uses length - the thing being fixed is the size
|
||||
// of the triangles spanning a step, not the curvature of anything - and because a step's error never
|
||||
// falls however fine the mesh gets, so only a length target (floored by min_edge_length_mm) is
|
||||
// guaranteed to terminate. Without this a colour boundary lands on whatever triangles the *height*
|
||||
// happened to need, which on a flat surface is none at all.
|
||||
//
|
||||
// `progress`, when given, is called with a 0..100 percentage of the triangle budget spent; returning
|
||||
// false stops the refinement early. What it hands back then is still a complete, conformal mesh - the
|
||||
// loop only ever finishes whole bisections - so a caller that wants to discard it has to do so itself.
|
||||
@@ -688,7 +862,9 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
|
||||
const HeightFieldSampler &sampler = nullptr,
|
||||
float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f,
|
||||
float border_edge_length_mm = 0.f,
|
||||
const DisplacementProgressFn &progress = nullptr);
|
||||
const DisplacementProgressFn &progress = nullptr,
|
||||
const ColorFieldSampler &color = nullptr,
|
||||
float color_edge_length_mm = 0.f);
|
||||
|
||||
// The recipe for getting a mesh ready to receive displacement: even out the triangle density, then
|
||||
// refine it where the texture bends. Either stage is skipped when its target is <= 0. Pure data, and
|
||||
@@ -709,6 +885,10 @@ struct TextureDisplacementPrepareParams
|
||||
float subdiv_border_mm = 0.f; // "Edge detail": the band straddling the paint's edge, 0 = off
|
||||
bool subdiv_feature = false; // follow texture curvature, not just edge length
|
||||
int subdiv_added_triangles = 0; // budget, *added* to the mesh's own count
|
||||
// Edge length triangles straddling a *colour* boundary are refined to, 0 = do not look at colour.
|
||||
// Separate from the height criteria because colour lands per facet: a flat surface carrying a
|
||||
// sharp colour edge needs triangles along that edge even though its height is perfectly smooth.
|
||||
float subdiv_color_edge_mm = 0.f;
|
||||
};
|
||||
|
||||
// What a preparation run produced. An empty `mesh` means there was nothing to do and the caller must
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include "libslic3r/AABBTreeIndirect.hpp"
|
||||
#include "libslic3r/Color.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/MeshBoolean.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
@@ -15,6 +17,7 @@
|
||||
#include "slic3r/GUI/GUI.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/GUI_ObjectList.hpp"
|
||||
#include "slic3r/GUI/GuiColor.hpp"
|
||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||
#include "slic3r/GUI/MainFrame.hpp" // wxGetApp().mainframe, as the projector window's parent
|
||||
#include "slic3r/GUI/MsgDialog.hpp"
|
||||
@@ -212,6 +215,14 @@ TriangleSelector::TriangleSplittingData remap_texture_paint_spatial(
|
||||
return dst_sel.serialize();
|
||||
}
|
||||
|
||||
// Edge of the RGB lookup cube make_palette_quantizer() builds. 24 gives 13824 cells - far finer than
|
||||
// the difference between any two printable colours - and costs one DeltaE00 per cell per palette
|
||||
// entry to fill.
|
||||
constexpr int PALETTE_LUT_EDGE = 24;
|
||||
|
||||
// Ceiling on the printable palette, which bounds that fill cost (and the shader's uniform array).
|
||||
constexpr int PALETTE_MAX_ENTRIES = 64;
|
||||
|
||||
std::unique_ptr<GLTexture> upload_height_thumbnail(const DecodedHeightTexture &decoded, int max_px = THUMBNAIL_MAX_PX)
|
||||
{
|
||||
if (decoded.empty())
|
||||
@@ -247,6 +258,46 @@ std::unique_ptr<GLTexture> upload_height_thumbnail(const DecodedHeightTexture &d
|
||||
return texture;
|
||||
}
|
||||
|
||||
// The same upload, of the texture's *colour* rather than its height, for the fast preview to quantize
|
||||
// per fragment. Null for a grayscale texture - there is nothing to show.
|
||||
//
|
||||
// Box-filtered down like the height is, and for a sharper reason: the fast preview quantizes every
|
||||
// fragment independently, so any texel-scale noise left in the image becomes a scatter of single-pixel
|
||||
// colour flips on screen. Filtering on the way to the GPU is where that is cheapest to remove.
|
||||
std::unique_ptr<GLTexture> upload_color_texture(const DecodedHeightTexture &decoded, int max_px)
|
||||
{
|
||||
if (!decoded.has_color())
|
||||
return nullptr;
|
||||
|
||||
const int scale = std::max(1, (std::max(decoded.width, decoded.height) + max_px - 1) / max_px);
|
||||
const int w = std::max(1, decoded.width / scale);
|
||||
const int h = std::max(1, decoded.height / scale);
|
||||
|
||||
std::vector<unsigned char> rgba(size_t(w) * size_t(h) * 4);
|
||||
for (int y = 0; y < h; ++y)
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const int x0 = x * decoded.width / w, x1 = std::max(x0 + 1, (x + 1) * decoded.width / w);
|
||||
const int y0 = y * decoded.height / h, y1 = std::max(y0 + 1, (y + 1) * decoded.height / h);
|
||||
unsigned int sum[3] = { 0, 0, 0 };
|
||||
unsigned int n = 0;
|
||||
for (int sy = y0; sy < y1 && sy < decoded.height; ++sy)
|
||||
for (int sx = x0; sx < x1 && sx < decoded.width; ++sx, ++n) {
|
||||
const size_t si = (size_t(sy) * size_t(decoded.width) + size_t(sx)) * 3;
|
||||
for (int c = 0; c < 3; ++c)
|
||||
sum[c] += decoded.rgb[si + size_t(c)];
|
||||
}
|
||||
const size_t di = (size_t(y) * size_t(w) + size_t(x)) * 4;
|
||||
for (int c = 0; c < 3; ++c)
|
||||
rgba[di + size_t(c)] = (n > 0) ? static_cast<unsigned char>(sum[size_t(c)] / n) : 0;
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
|
||||
auto texture = std::make_unique<GLTexture>();
|
||||
if (!texture->load_from_raw_data(std::move(rgba), (unsigned int) w, (unsigned int) h, false, false))
|
||||
return nullptr;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// Intersects the camera ray through `mouse_pos` (screen coords) with the plane passing through
|
||||
// `plane_point_local`/`plane_normal_local` (mesh-local coords, transformed to world by `trafo`).
|
||||
// Returns false if the ray is parallel to the plane or the plane is behind the camera.
|
||||
@@ -887,7 +938,16 @@ void GLGizmoTextureDisplacement::render_preview_mesh()
|
||||
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
||||
const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) * trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
|
||||
shader->set_uniform("view_normal_matrix", view_normal_matrix);
|
||||
m_preview_glmodel.render();
|
||||
if (m_preview_color_runs.empty()) {
|
||||
m_preview_glmodel.render();
|
||||
} else {
|
||||
// set_color() writes the uniform the shader reads, so one call per group is all the
|
||||
// per-triangle colour this needs.
|
||||
for (const PreviewColorRun &run : m_preview_color_runs) {
|
||||
m_preview_glmodel.set_color(run.color);
|
||||
m_preview_glmodel.render(run.range, shader);
|
||||
}
|
||||
}
|
||||
shader->stop_using();
|
||||
}
|
||||
|
||||
@@ -972,6 +1032,13 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh()
|
||||
if (!m_bump_preview_uses_vertex_uv)
|
||||
vertex_uv.clear();
|
||||
|
||||
// Colour is quantized per *fragment* in the shader now (see the .fs), so this mesh carries no
|
||||
// colour of its own - the palette and the colour texture are uniforms, and every pixel matches the
|
||||
// image rather than the facet it landed on. What the *bake* will produce, at facet resolution, is
|
||||
// what the Normal view shows.
|
||||
m_bump_preview_palette = (active != nullptr && active->color_enabled) ? cached_palette()
|
||||
: std::vector<PaletteEntry>{};
|
||||
|
||||
GLModel::Geometry init_data;
|
||||
// P3N3T2: normal.x carries the paint weight, tex_coord the precomputed uv (see the vertex shader).
|
||||
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 };
|
||||
@@ -1003,8 +1070,10 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh()
|
||||
vcount += 3;
|
||||
}
|
||||
};
|
||||
emit_triangles(patch, 1.f); // painted -> bumped
|
||||
emit_triangles(rest, 0.f); // untouched surface -> flat, so it still shows but isn't bumped
|
||||
emit_triangles(patch, 1.f); // painted -> bumped, and coloured by the shader
|
||||
// Untouched surface: flat, so it still shows but isn't bumped - and uncoloured, which is what the
|
||||
// bake leaves it as (EnforcerBlockerType::NONE, i.e. the volume's own filament).
|
||||
emit_triangles(rest, 0.f);
|
||||
|
||||
m_bump_preview_glmodel.init_from(std::move(init_data));
|
||||
// GLModel::render() unconditionally re-sets the shader's "uniform_color" from this internal
|
||||
@@ -1189,6 +1258,29 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh()
|
||||
// When set, the shader samples at the per-vertex uv baked into the mesh (LSCM) rather than
|
||||
// projecting; see rebuild_bump_preview_mesh().
|
||||
shader->set_uniform("use_vertex_uv", m_bump_preview_uses_vertex_uv);
|
||||
|
||||
// The filament palette the mesh's per-triangle indices refer to. Count 0 means "no layer is
|
||||
// colouring", and the shader keeps the model's own colour for every fragment.
|
||||
// The printable palette, in RGB for display and in Lab for the match. Uploaded rather than
|
||||
// matched on the CPU because the quantization is per fragment here.
|
||||
const GLTexture *color_tex = get_layer_color_texture(*layer);
|
||||
const int palette_count =
|
||||
(color_tex != nullptr) ? int(std::min(m_bump_preview_palette.size(), size_t(PALETTE_MAX_ENTRIES))) : 0;
|
||||
shader->set_uniform("palette_count", palette_count);
|
||||
shader->set_uniform("has_color_tex", color_tex != nullptr);
|
||||
for (int i = 0; i < palette_count; ++i) {
|
||||
const Vec3f &rgb = m_bump_preview_palette[size_t(i)].rgb;
|
||||
float l, a, b;
|
||||
RGB2Lab(rgb.x() * 255.f, rgb.y() * 255.f, rgb.z() * 255.f, &l, &a, &b);
|
||||
shader->set_uniform(("palette_rgb[" + std::to_string(i) + "]").c_str(), rgb);
|
||||
shader->set_uniform(("palette_lab[" + std::to_string(i) + "]").c_str(), Vec3f(l, a, b));
|
||||
}
|
||||
if (color_tex != nullptr) {
|
||||
shader->set_uniform("color_tex", 1);
|
||||
glsafe(::glActiveTexture(GL_TEXTURE1));
|
||||
glsafe(::glBindTexture(GL_TEXTURE_2D, (GLuint) color_tex->get_id()));
|
||||
glsafe(::glActiveTexture(GL_TEXTURE0));
|
||||
}
|
||||
// The live UV-editor island drag rides this 2x3 affine (identity except mid-drag); only the flagged
|
||||
// island's vertices apply it, so a drag is a uniform update rather than a mesh rebuild.
|
||||
const Eigen::Matrix<float, 2, 3> &d = m_bump_island_delta;
|
||||
@@ -1564,11 +1656,20 @@ void GLGizmoTextureDisplacement::queue_preview_job()
|
||||
input.options = mv->texture_displacement_options;
|
||||
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: get_extruders_colors() is main-thread state and
|
||||
// the preview has to be grouped against the same palette it was computed with, not whatever is
|
||||
// loaded by the time it lands.
|
||||
input.color = color_settings_for(*mv);
|
||||
// The filament list the result's indices refer to, captured with the job rather than read back
|
||||
// when it lands - loading a filament meanwhile must not recolour a preview computed against a
|
||||
// different list.
|
||||
const std::vector<ColorRGBA> filaments = m_palette_filaments;
|
||||
|
||||
m_preview_job_running = true;
|
||||
auto &worker = wxGetApp().plater()->get_ui_job_worker();
|
||||
queue_job(worker, std::make_unique<TextureDisplacementPreviewJob>(std::move(input), generation, m_preview_generation,
|
||||
[this](indexed_triangle_set its, uint64_t result_generation) {
|
||||
[this, filaments](TextureDisplacementPreviewResult result, uint64_t result_generation) {
|
||||
indexed_triangle_set its = std::move(result.mesh);
|
||||
m_preview_job_running = false;
|
||||
if (result_generation != m_preview_generation->load()) {
|
||||
// Superseded while this was computing (it will have aborted early and come back
|
||||
@@ -1580,7 +1681,29 @@ void GLGizmoTextureDisplacement::queue_preview_job()
|
||||
// rather than blanking it; there is no new result to show, not a new empty one.
|
||||
} else {
|
||||
m_preview_glmodel.reset();
|
||||
m_preview_glmodel.init_from(its);
|
||||
m_preview_color_runs.clear();
|
||||
if (result.triangle_color.size() == its.indices.size() && !filaments.empty()) {
|
||||
// Group by *filament*, not by palette entry: what the bake wrote is the resolved
|
||||
// filament, interleaving already applied, so this shows the real banding rather
|
||||
// than the flat average the eye will turn it into.
|
||||
indexed_triangle_set sorted;
|
||||
sorted.vertices = its.vertices;
|
||||
sorted.indices.reserve(its.indices.size());
|
||||
for (int want = 0; want <= int(filaments.size()); ++want) {
|
||||
const size_t first = sorted.indices.size();
|
||||
for (size_t i = 0; i < its.indices.size(); ++i)
|
||||
if (int(result.triangle_color[i]) == want)
|
||||
sorted.indices.push_back(its.indices[i]);
|
||||
if (sorted.indices.size() == first)
|
||||
continue;
|
||||
m_preview_color_runs.push_back(
|
||||
{ { first * 3, sorted.indices.size() * 3 },
|
||||
want == 0 ? GLVolume::NEUTRAL_COLOR : filaments[size_t(want - 1)] });
|
||||
}
|
||||
m_preview_glmodel.init_from(sorted);
|
||||
} else {
|
||||
m_preview_glmodel.init_from(its);
|
||||
}
|
||||
m_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR);
|
||||
// Keep the displaced mesh so the wireframe overlay can be drawn on it (the
|
||||
// true-displacement view), then refresh the wireframe from it.
|
||||
@@ -3277,10 +3400,199 @@ TextureDisplacementFacetsData GLGizmoTextureDisplacement::facets_data_of(const M
|
||||
return out;
|
||||
}
|
||||
|
||||
bool GLGizmoTextureDisplacement::any_layer_colors(const ModelVolume &mv)
|
||||
{
|
||||
for (const TextureDisplacementLayer &layer : mv.texture_displacement_layers)
|
||||
if (layer.color_enabled && !layer.empty() && decode_height_texture(layer).has_color())
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
TextureColorSettings GLGizmoTextureDisplacement::color_settings_for(const ModelVolume &mv)
|
||||
{
|
||||
TextureColorSettings out;
|
||||
if (!any_layer_colors(mv))
|
||||
return out; // nothing is colouring: every colour path stays switched off
|
||||
out.palette = cached_palette();
|
||||
out.mix_mode = mv.texture_displacement_options.color_mix_mode;
|
||||
out.despeckle_passes = mv.texture_displacement_options.color_despeckle;
|
||||
out.layer_height = print_layer_height();
|
||||
// The dither cell is tied to the colour-detail target: a cell much smaller than a facet cannot be
|
||||
// drawn at all, and one much larger stops reading as a blend and starts reading as a check.
|
||||
out.dither_cell_mm = std::max(m_subdivide_color_mm, 0.05f) * 2.f;
|
||||
return out;
|
||||
}
|
||||
|
||||
const std::vector<GLGizmoTextureDisplacement::PaletteEntry> &GLGizmoTextureDisplacement::cached_palette()
|
||||
{
|
||||
// Rebuilt only when the loaded filaments or the mixing setting actually change. The bump preview
|
||||
// rebuilds on every paint stroke and the subdivide preview on every slider frame, and filling the
|
||||
// quantizer's lookup cube for a 64-entry palette is tens of milliseconds - paying that per stroke
|
||||
// is the difference between painting that keeps up and painting that stutters.
|
||||
const ModelVolume *mv = texture_volume();
|
||||
const bool mixing = mv != nullptr && mv->texture_displacement_options.color_mix_enabled;
|
||||
std::vector<ColorRGBA> filaments = filament_palette();
|
||||
if (m_palette_cache.empty() || filaments != m_palette_filaments || mixing != m_palette_mixing) {
|
||||
m_palette_filaments = std::move(filaments);
|
||||
m_palette_mixing = mixing;
|
||||
m_palette_cache = make_palette(m_palette_filaments, mixing);
|
||||
m_palette_quantizer = make_palette_quantizer(m_palette_cache);
|
||||
}
|
||||
return m_palette_cache;
|
||||
}
|
||||
|
||||
std::vector<ColorRGBA> GLGizmoTextureDisplacement::filament_palette()
|
||||
{
|
||||
std::vector<ColorRGBA> palette = wxGetApp().plater()->get_extruders_colors();
|
||||
// mmu_segmentation_facets encodes the filament in a 6-bit prefix code and stops at Extruder16.
|
||||
if (palette.size() > size_t(EnforcerBlockerType::ExtruderMax))
|
||||
palette.resize(size_t(EnforcerBlockerType::ExtruderMax));
|
||||
return palette;
|
||||
}
|
||||
|
||||
float GLGizmoTextureDisplacement::print_layer_height()
|
||||
{
|
||||
try {
|
||||
const DynamicPrintConfig &cfg = wxGetApp().preset_bundle->prints.get_edited_preset().config;
|
||||
if (const ConfigOptionFloat *opt = cfg.option<ConfigOptionFloat>("layer_height"); opt != nullptr)
|
||||
if (opt->value > 1e-3)
|
||||
return float(opt->value);
|
||||
} catch (...) {
|
||||
}
|
||||
return 0.2f;
|
||||
}
|
||||
|
||||
std::vector<GLGizmoTextureDisplacement::PaletteEntry> GLGizmoTextureDisplacement::make_palette(
|
||||
const std::vector<ColorRGBA> &filaments, bool mixing)
|
||||
{
|
||||
std::vector<PaletteEntry> out;
|
||||
const int n = int(filaments.size());
|
||||
for (int i = 0; i < n; ++i)
|
||||
out.push_back({ Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b()),
|
||||
i, i, 1, 1 });
|
||||
if (!mixing || n < 2)
|
||||
return out;
|
||||
|
||||
// How many intermediate steps each pair gets, chosen so the whole palette stays under
|
||||
// PALETTE_MAX_ENTRIES. Fewer filaments means more room for mixes, which is also what you want:
|
||||
// with two filaments the mixes are the only way to get anywhere, and with sixteen there is little
|
||||
// point mixing at all. `den` is also the band/dither repeat, so a small one is a short pattern.
|
||||
const int pairs = n * (n - 1) / 2;
|
||||
int steps = 0;
|
||||
for (int s = 5; s >= 1; --s)
|
||||
if (n + pairs * s <= PALETTE_MAX_ENTRIES) {
|
||||
steps = s;
|
||||
break;
|
||||
}
|
||||
if (steps == 0)
|
||||
return out;
|
||||
const int den = steps + 1;
|
||||
|
||||
for (int i = 0; i < n; ++i)
|
||||
for (int j = i + 1; j < n; ++j) {
|
||||
float la, aa, ba, lb, ab, bb;
|
||||
RGB2Lab(filaments[size_t(i)].r() * 255.f, filaments[size_t(i)].g() * 255.f,
|
||||
filaments[size_t(i)].b() * 255.f, &la, &aa, &ba);
|
||||
RGB2Lab(filaments[size_t(j)].r() * 255.f, filaments[size_t(j)].g() * 255.f,
|
||||
filaments[size_t(j)].b() * 255.f, &lb, &ab, &bb);
|
||||
for (int k = 1; k <= steps; ++k) {
|
||||
// k/den of filament i, the rest of j - averaged in Lab, which is what the eye does
|
||||
// when the two are interleaved too finely to resolve.
|
||||
const float t = float(k) / float(den);
|
||||
float r, g, b;
|
||||
Lab2RGB(la * t + lb * (1.f - t), aa * t + ab * (1.f - t), ba * t + bb * (1.f - t), &r, &g, &b);
|
||||
out.push_back({ Vec3f(std::clamp(r / 255.f, 0.f, 1.f), std::clamp(g / 255.f, 0.f, 1.f),
|
||||
std::clamp(b / 255.f, 0.f, 1.f)),
|
||||
i, j, k, den });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ColorResolveFn GLGizmoTextureDisplacement::make_mix_resolver(const std::vector<PaletteEntry> &palette,
|
||||
ColorMixMode mode, float layer_height,
|
||||
float cell_mm)
|
||||
{
|
||||
if (palette.empty())
|
||||
return nullptr;
|
||||
auto entries = std::make_shared<std::vector<PaletteEntry>>(palette);
|
||||
const float band = std::max(layer_height, 0.01f);
|
||||
const float cell = std::max(cell_mm, 0.01f);
|
||||
|
||||
return [entries, mode, band, cell](int index, const Vec3f &pos) -> int {
|
||||
if (index < 0 || size_t(index) >= entries->size())
|
||||
return -1;
|
||||
const PaletteEntry &e = (*entries)[size_t(index)];
|
||||
if (!e.is_mix())
|
||||
return e.a;
|
||||
|
||||
// Which of the two filaments this point falls on. Both patterns are *ordered*, never random:
|
||||
// the eye blends a regular pattern into a flat colour, and turns a random one into noise.
|
||||
if (mode == ColorMixMode::ZBands) {
|
||||
// One band per print layer. floorf, not a cast, so this stays correct below z = 0.
|
||||
const int slot = int(std::floor(pos.z() / band));
|
||||
const int phase = ((slot % e.den) + e.den) % e.den;
|
||||
return phase < e.num ? e.a : e.b;
|
||||
}
|
||||
// Ordered 4x4 Bayer over the surface, indexed by position so the pattern is stable in space
|
||||
// rather than in triangle order (which would move under any remesh, and read as noise).
|
||||
static const int BAYER[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 };
|
||||
const int gx = ((int(std::floor(pos.x() / cell)) % 4) + 4) % 4;
|
||||
const int gy = ((int(std::floor(pos.y() / cell)) % 4) + 4) % 4;
|
||||
// A third axis would be ideal, but the two dominant ones are enough for a surface pattern and
|
||||
// keep the cell square on the faces that matter.
|
||||
const float threshold = (float(BAYER[gy * 4 + gx]) + 0.5f) / 16.f;
|
||||
return (float(e.num) / float(e.den)) > threshold ? e.a : e.b;
|
||||
};
|
||||
}
|
||||
|
||||
ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::vector<PaletteEntry> &palette)
|
||||
{
|
||||
if (palette.empty())
|
||||
return nullptr;
|
||||
|
||||
// Lab once per entry, not once per lookup.
|
||||
struct Lab { float l, a, b; };
|
||||
std::vector<Lab> palette_lab(palette.size());
|
||||
for (size_t i = 0; i < palette.size(); ++i)
|
||||
RGB2Lab(palette[i].rgb.x() * 255.f, palette[i].rgb.y() * 255.f, palette[i].rgb.z() * 255.f,
|
||||
&palette_lab[i].l, &palette_lab[i].a, &palette_lab[i].b);
|
||||
|
||||
constexpr int E = PALETTE_LUT_EDGE;
|
||||
auto lut = std::make_shared<std::vector<uint8_t>>(size_t(E) * E * E, 0);
|
||||
tbb::parallel_for(tbb::blocked_range<int>(0, E), [&](const tbb::blocked_range<int> &range) {
|
||||
for (int r = range.begin(); r < range.end(); ++r)
|
||||
for (int g = 0; g < E; ++g)
|
||||
for (int b = 0; b < E; ++b) {
|
||||
// Cell centre, so the quantization error is symmetric across the cell.
|
||||
float l0, a0, b0;
|
||||
RGB2Lab((r + 0.5f) / E * 255.f, (g + 0.5f) / E * 255.f, (b + 0.5f) / E * 255.f, &l0, &a0, &b0);
|
||||
int best = 0;
|
||||
float best_d = std::numeric_limits<float>::max();
|
||||
for (size_t i = 0; i < palette_lab.size(); ++i) {
|
||||
const float d = DeltaE00(l0, a0, b0, palette_lab[i].l, palette_lab[i].a, palette_lab[i].b);
|
||||
if (d < best_d) {
|
||||
best_d = d;
|
||||
best = int(i);
|
||||
}
|
||||
}
|
||||
(*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best);
|
||||
}
|
||||
});
|
||||
|
||||
return [lut](const Vec3f &rgb) -> int {
|
||||
constexpr int E = PALETTE_LUT_EDGE;
|
||||
const int r = std::clamp(int(rgb.x() * E), 0, E - 1);
|
||||
const int g = std::clamp(int(rgb.y() * E), 0, E - 1);
|
||||
const int b = std::clamp(int(rgb.z() * E), 0, E - 1);
|
||||
return int((*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)]);
|
||||
};
|
||||
}
|
||||
|
||||
TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
|
||||
const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks,
|
||||
const std::vector<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams ¶ms,
|
||||
const DisplacementProgressFn &progress)
|
||||
const std::vector<PrintableColor> &palette, const DisplacementProgressFn &progress)
|
||||
{
|
||||
TextureDisplacementPrepareResult out;
|
||||
const auto report = [&progress](int pct) { return !progress || progress(pct); };
|
||||
@@ -3335,6 +3647,13 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
|
||||
HeightFieldSampler sampler;
|
||||
if (params.subdiv_feature)
|
||||
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;
|
||||
if (params.subdiv_color_edge_mm > 0.f && !palette.empty())
|
||||
color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(palette));
|
||||
// Note the sampler is built on the *quantizer* alone - the refinement follows perceived
|
||||
// colour, never the interleaving that realises a mix (see ColorResolveFn).
|
||||
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down
|
||||
// to); in plain adaptive mode the target edge length is the only criterion, so the floor
|
||||
// must not be allowed to silently override a target the user set below it.
|
||||
@@ -3350,7 +3669,8 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
|
||||
sampler, tol, floor, params.subdiv_border_mm, [&](int pct) {
|
||||
aborted = !report(50 + pct / 2);
|
||||
return !aborted;
|
||||
});
|
||||
},
|
||||
color, params.subdiv_color_edge_mm);
|
||||
if (aborted)
|
||||
return {};
|
||||
if (refined.indices.size() != mesh.its.indices.size()) {
|
||||
@@ -3387,6 +3707,7 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive()
|
||||
params.subdiv_detail_mm = m_subdivide_detail_mm;
|
||||
params.subdiv_min_edge_mm = m_subdivide_min_edge_mm;
|
||||
params.subdiv_border_mm = m_subdivide_border_mm;
|
||||
params.subdiv_color_edge_mm = m_subdivide_color_mm;
|
||||
params.subdiv_feature = m_subdivide_feature;
|
||||
params.subdiv_added_triangles = m_subdivide_budget_k * 1000;
|
||||
queue_prepare(params, _u8L("Adaptive subdivide for texture displacement"), /* then_bake */ false,
|
||||
@@ -3560,9 +3881,17 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
|
||||
// Min-edge floor. Plain adaptive: tol 0, so only the target-edge-length criterion applies.
|
||||
const float tol = m_subdivide_feature ? m_subdivide_detail_mm : 0.f;
|
||||
const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f;
|
||||
// Same colour criterion Apply will use, so the previewed wireframe is the mesh that commits.
|
||||
ColorFieldSampler color;
|
||||
if (m_subdivide_color_mm > 0.f && any_layer_colors(*mv)) {
|
||||
cached_palette(); // refreshes m_palette_quantizer if the filaments changed
|
||||
color = make_combined_color_sampler(mv->mesh().its, mv->texture_displacement_layers, facets,
|
||||
m_palette_quantizer);
|
||||
}
|
||||
its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
|
||||
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
|
||||
nullptr, sampler, tol, floor, m_subdivide_border_mm);
|
||||
nullptr, sampler, tol, floor, m_subdivide_border_mm, nullptr, color,
|
||||
m_subdivide_color_mm);
|
||||
} else {
|
||||
if (m_subdivide_count < 1)
|
||||
return;
|
||||
@@ -3634,6 +3963,25 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace
|
||||
return m_thumbnails[slot].get();
|
||||
}
|
||||
|
||||
GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisplacementLayer &layer)
|
||||
{
|
||||
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)
|
||||
return m_color_tex.get();
|
||||
|
||||
std::unique_ptr<GLTexture> texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
|
||||
if (!texture) {
|
||||
m_color_tex.reset();
|
||||
m_color_tex_source = nullptr;
|
||||
return nullptr;
|
||||
}
|
||||
m_color_tex = std::move(texture);
|
||||
m_color_tex_source = layer.image_data.get();
|
||||
m_color_tex_smoothing = layer.smoothing;
|
||||
return m_color_tex.get();
|
||||
}
|
||||
|
||||
GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDisplacementLayer &layer)
|
||||
{
|
||||
if (layer.empty())
|
||||
@@ -3670,7 +4018,7 @@ void GLGizmoTextureDisplacement::bake(bool own_snapshot)
|
||||
}
|
||||
|
||||
m_bake_in_progress = true;
|
||||
queue_texture_displacement_bake(*mv, [this]() {
|
||||
queue_texture_displacement_bake(*mv, color_settings_for(*mv), [this]() {
|
||||
m_bake_in_progress = false;
|
||||
// Baking replaces the volume's mesh (new id, new topology) without changing the object's
|
||||
// id or volume count, so GLGizmoPainterBase::data_changed()'s usual change-detection never
|
||||
@@ -3700,6 +4048,10 @@ static constexpr float STD_SUBDIV_MIN_EDGE_MM = 0.02f;
|
||||
// how clean the rim of an unpainted island looks: the bake steps the surface from full displacement to
|
||||
// zero across that band, and nothing else in the criteria can see the step (see collect_paint_region()).
|
||||
static constexpr float STD_SUBDIV_BORDER_MM = 0.4f;
|
||||
// Edge length a colour boundary is refined to. Finer than the border band because a colour edge is
|
||||
// what the eye actually lands on - a stepped outline around a printed decal reads as a defect in a
|
||||
// way a slightly coarse relief transition does not - and it costs triangles along an outline only.
|
||||
static constexpr float STD_SUBDIV_COLOR_MM = 0.25f;
|
||||
|
||||
bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
|
||||
{
|
||||
@@ -3721,6 +4073,7 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
|
||||
pin(m_subdivide_detail_mm, STD_SUBDIV_DETAIL_MM);
|
||||
pin(m_subdivide_min_edge_mm, STD_SUBDIV_MIN_EDGE_MM);
|
||||
pin(m_subdivide_border_mm, STD_SUBDIV_BORDER_MM);
|
||||
pin(m_subdivide_color_mm, STD_SUBDIV_COLOR_MM);
|
||||
// Deliberately *not* pinned: the triangle budget stays visible and editable in Standard mode, so
|
||||
// pinning it would fight the user's own slider every frame.
|
||||
pin(m_remesh_target_edge_mm, STD_REMESH_EDGE_MM);
|
||||
@@ -3752,6 +4105,7 @@ void GLGizmoTextureDisplacement::bake_standard()
|
||||
params.subdiv_detail_mm = STD_SUBDIV_DETAIL_MM;
|
||||
params.subdiv_min_edge_mm = STD_SUBDIV_MIN_EDGE_MM;
|
||||
params.subdiv_border_mm = STD_SUBDIV_BORDER_MM;
|
||||
params.subdiv_color_edge_mm = STD_SUBDIV_COLOR_MM;
|
||||
params.subdiv_feature = true;
|
||||
params.subdiv_added_triangles = m_subdivide_budget_k * 1000;
|
||||
queue_prepare(params, _u8L("Bake texture displacement"), /* then_bake */ true, {});
|
||||
@@ -3772,6 +4126,8 @@ void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareP
|
||||
input.layers = mv->texture_displacement_layers;
|
||||
input.params = params;
|
||||
input.snapshot_name = snapshot_name;
|
||||
if (params.subdiv_color_edge_mm > 0.f)
|
||||
input.color = color_settings_for(*mv);
|
||||
|
||||
m_prepare_in_progress = true;
|
||||
queue_texture_displacement_prepare(std::move(input), [this, then_bake, unchanged_msg](
|
||||
@@ -4216,6 +4572,84 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
|
||||
m_preview_params_dirty |= ImGui::Checkbox(_u8L("Invert").c_str(), &layer->invert);
|
||||
|
||||
// Colour. Only offered for a texture that actually has some - the shipped library is
|
||||
// grayscale, and a checkbox that silently does nothing on nine textures out of ten is
|
||||
// worse than no checkbox. Disabled rather than hidden so it is clear the feature exists
|
||||
// and what it wants.
|
||||
{
|
||||
const bool has_color = decode_height_texture(*layer).has_color();
|
||||
m_imgui->disabled_begin(!has_color);
|
||||
bool color_enabled = layer->color_enabled && has_color;
|
||||
if (ImGui::Checkbox(_u8L("Use image colours").c_str(), &color_enabled)) {
|
||||
layer->color_enabled = color_enabled;
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
m_imgui->disabled_end();
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(has_color ?
|
||||
_u8L("Colours the painted area from the texture's own colours, as well as "
|
||||
"displacing it. Each colour is matched to the closest printable "
|
||||
"colour, and the result is written as multi-material paint - so the "
|
||||
"area prints in those filaments. Everything you did not paint keeps "
|
||||
"the object's own filament.") :
|
||||
_u8L("This texture is a grayscale height map, so it has no colours to "
|
||||
"apply. Import a colour image to use this."),
|
||||
m_imgui->scaled(20.f));
|
||||
|
||||
// The rest of colour belongs to the whole stack, not to this layer, so it only appears
|
||||
// once - under whichever layer turned colour on.
|
||||
if (color_enabled && mv != nullptr) {
|
||||
TextureDisplacementOptions &opts = mv->texture_displacement_options;
|
||||
|
||||
if (ImGui::Checkbox(_u8L("Mix filaments").c_str(), &opts.color_mix_enabled))
|
||||
m_preview_params_dirty = true;
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(_u8L("Interleave pairs of filaments to reach colours between them, so a few "
|
||||
"filaments cover far more than a few colours. Off means every triangle "
|
||||
"prints in one of the filaments exactly."),
|
||||
m_imgui->scaled(20.f));
|
||||
|
||||
if (opts.color_mix_enabled) {
|
||||
m_imgui->text(_u8L("Mix by"));
|
||||
ImGui::SameLine();
|
||||
const std::string mix_z = _u8L("Layers");
|
||||
const std::string mix_xy = _u8L("Surface");
|
||||
const char *mix_items[] = { mix_z.c_str(), mix_xy.c_str() };
|
||||
int mix_mode = int(opts.color_mix_mode);
|
||||
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
||||
if (scoped_combo("##color_mix_mode", &mix_mode, mix_items, IM_ARRAYSIZE(mix_items))) {
|
||||
opts.color_mix_mode = ColorMixMode(mix_mode);
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
ImGui::PopItemWidth();
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(_u8L("Layers: the two filaments alternate between print layers, which "
|
||||
"blends smoothly on upright surfaces but disappears on flat-facing "
|
||||
"ones, where a whole layer is a single band.\n"
|
||||
"Surface: a fine checkerboard across the surface, which works at "
|
||||
"any angle but can read as texture rather than as a blend."),
|
||||
m_imgui->scaled(20.f));
|
||||
|
||||
const int count = int(cached_palette().size());
|
||||
ImGui::TextDisabled("%s", from_u8(Slic3r::format(
|
||||
_u8L("%1% printable colours from %2% filaments"), count,
|
||||
int(m_palette_filaments.size()))).ToUTF8().data());
|
||||
}
|
||||
|
||||
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
||||
if (ImGui::SliderInt(_u8L("Denoise").c_str(), &opts.color_despeckle, 0, 6))
|
||||
m_preview_params_dirty = true;
|
||||
ImGui::PopItemWidth();
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(_u8L("Removes stray single triangles of the wrong colour, which is what "
|
||||
"detail in the image finer than the mesh leaves behind. Each step "
|
||||
"replaces a triangle's colour with the one most of its neighbours "
|
||||
"have, so features wider than a triangle are kept. Raise it if the "
|
||||
"result looks speckled; lower it if fine detail is being eaten."),
|
||||
m_imgui->scaled(20.f));
|
||||
}
|
||||
}
|
||||
|
||||
// The lowest painted layer has nothing underneath it to combine with - it *is* the
|
||||
// base - so a blend mode would be meaningless (and Multiply/Divide against an implicit
|
||||
// zero would annihilate it). build_texture_displacement() forces the first layer to
|
||||
@@ -4784,6 +5218,21 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
"0 turns it off."),
|
||||
m_imgui->scaled(20.f));
|
||||
|
||||
// Only worth showing when a layer is actually colouring: with no colour there is no boundary
|
||||
// for it to refine and the control would do nothing whatever it is set to.
|
||||
if (mv != nullptr && any_layer_colors(*mv)) {
|
||||
if (m_imgui->slider_float(std::string(_u8L("Colour detail (mm)")) + "##subdivcolor",
|
||||
&m_subdivide_color_mm, 0.f, 5.f, "%.3f"))
|
||||
preview_live();
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(_u8L("Triangle size along a boundary between two colours. Colour is assigned per "
|
||||
"triangle, so an edge between two filaments can only be as clean as the "
|
||||
"triangles along it - and the relief criteria cannot see it at all, because "
|
||||
"the surface is perfectly smooth across a change of colour. Costs triangles "
|
||||
"along the boundaries only. 0 turns it off."),
|
||||
m_imgui->scaled(20.f));
|
||||
}
|
||||
|
||||
ImGui::PopItemWidth();
|
||||
budget_slider();
|
||||
|
||||
|
||||
@@ -45,11 +45,80 @@ public:
|
||||
const TextureDisplacementFacetsData &masks,
|
||||
const std::vector<TextureDisplacementLayer> &layers,
|
||||
const TextureDisplacementPrepareParams ¶ms,
|
||||
const std::vector<PrintableColor> &palette,
|
||||
const DisplacementProgressFn &progress);
|
||||
// The volume's eight texture-displacement masks, gathered into the array every pure function here
|
||||
// (and every job input) takes.
|
||||
static TextureDisplacementFacetsData facets_data_of(const ModelVolume &mv);
|
||||
|
||||
using PaletteEntry = PrintableColor;
|
||||
|
||||
// The printable palette: the loaded filaments (clamped to the sixteen mmu_segmentation_facets can
|
||||
// address), plus - when `mixing` - every pair of them at evenly spaced ratios.
|
||||
//
|
||||
// Mixes are averaged in **CIELAB**, not RGB and not subtractively: two filaments interleaved too
|
||||
// finely to resolve are averaged by the eye, which is what a perceptual space models. Yellow and
|
||||
// blue banded together read as a desaturated grey-green, and that is what the preview must promise
|
||||
// - blending them subtractively would show a green the printer cannot produce this way.
|
||||
//
|
||||
// How many ratios depends on how many filaments there are, so the palette stays bounded: the
|
||||
// quantizer's lookup cube costs one DeltaE00 per cell per entry to fill, and with sixteen
|
||||
// filaments there are already plenty of colours without mixing any of them.
|
||||
static std::vector<PaletteEntry> make_palette(const std::vector<ColorRGBA> &filaments, bool mixing);
|
||||
|
||||
// Maps an image colour to the closest entry of `palette`, perceptually (CIEDE2000 over CIELAB - a
|
||||
// plain RGB distance picks visibly wrong filaments, most obviously between a saturated colour and
|
||||
// a grey of similar brightness).
|
||||
//
|
||||
// Precomputed into a lookup cube rather than matched per call: the subdivision's colour criterion
|
||||
// samples up to seven points per triangle and re-samples both children of every split, so a live
|
||||
// match would dominate the refinement. The returned closure owns the cube, so it is safe to hand
|
||||
// to a worker thread and outlives the palette it was built from.
|
||||
static ColorQuantizeFn make_palette_quantizer(const std::vector<PaletteEntry> &palette);
|
||||
|
||||
// Turns a palette index plus a position into the filament to print there, interleaving the two
|
||||
// filaments of a mixed entry per `mode`. `layer_height` sizes the Z bands; `cell_mm` the dither
|
||||
// cells. See ColorResolveFn for why this is separate from the quantizer.
|
||||
static ColorResolveFn make_mix_resolver(const std::vector<PaletteEntry> &palette, ColorMixMode mode,
|
||||
float layer_height, float cell_mm);
|
||||
|
||||
// Everything the jobs need to colour with, for the current volume: palette, mix mode, layer
|
||||
// height, despeckle. Empty when no layer is actually colouring.
|
||||
TextureColorSettings color_settings_for(const ModelVolume &mv);
|
||||
|
||||
// The printable palette for the current filaments and mixing setting, rebuilt only when either
|
||||
// actually changes - see the definition for why that caching is not optional.
|
||||
const std::vector<PaletteEntry> &cached_palette();
|
||||
std::vector<PaletteEntry> m_palette_cache;
|
||||
std::vector<ColorRGBA> m_palette_filaments;
|
||||
bool m_palette_mixing = false;
|
||||
ColorQuantizeFn m_palette_quantizer;
|
||||
|
||||
// The loaded filaments, clamped to the sixteen mmu_segmentation_facets can address.
|
||||
static std::vector<ColorRGBA> filament_palette();
|
||||
// The print's layer height, which sizes ColorMixMode::ZBands. Falls back to 0.2 mm if it cannot be
|
||||
// read - a wrong band size is a cosmetic error, not a reason to refuse to colour anything.
|
||||
static float print_layer_height();
|
||||
|
||||
// The Normal preview's triangles, grouped by the filament they will print in. Colour is per facet
|
||||
// and there are at most sixteen filaments, so the mesh is uploaded once with its index buffer
|
||||
// sorted by colour and drawn as one GLModel::render(range) per group - which needs no per-vertex
|
||||
// colour attribute, and so no change to GLModel's vertex layouts.
|
||||
//
|
||||
// The *index buffer* is what gets reordered, never m_preview_its: the paint overlay and the
|
||||
// wireframe index into that by the volume's own triangle numbering (the bake is
|
||||
// topology-preserving), and permuting it would silently misplace both.
|
||||
struct PreviewColorRun
|
||||
{
|
||||
std::pair<size_t, size_t> range; // into the GLModel's index buffer, in elements
|
||||
ColorRGBA color;
|
||||
};
|
||||
std::vector<PreviewColorRun> m_preview_color_runs;
|
||||
// True if any of the volume's layers would actually colour something: colour turned on, and a
|
||||
// texture that has colour to give. What decides whether a palette is captured into a job at all,
|
||||
// and so whether the colour criterion and the mmu write ever run.
|
||||
static bool any_layer_colors(const ModelVolume &mv);
|
||||
|
||||
void render_painter_gizmo() override;
|
||||
|
||||
// Intercepts mouse input while "Adjust Texture" mode is on (dragging the on-canvas offset/
|
||||
@@ -214,6 +283,9 @@ private:
|
||||
// The same texture at full resolution, for the fast-preview shader. One slot, shared by whichever
|
||||
// layer is active, because that is the only one the bump shader ever shades.
|
||||
GLTexture *get_layer_height_texture(const TextureDisplacementLayer &layer);
|
||||
// The layer's colour texture for the fast preview's per-fragment quantization. Null when the
|
||||
// layer is not colouring or its texture is grayscale.
|
||||
GLTexture *get_layer_color_texture(const TextureDisplacementLayer &layer);
|
||||
|
||||
// A texture from the picker's library (see slic3r/GUI/TextureLibrary.hpp), read and uploaded
|
||||
// once and then kept for the gizmo's lifetime. The decoded bytes are held alongside the GPU
|
||||
@@ -423,6 +495,13 @@ private:
|
||||
// transition keeps the input's density and the rim of an unpainted island comes out as a ring of
|
||||
// large, steeply tilted triangles. See collect_paint_region() and subdivide_mesh_adaptive().
|
||||
float m_subdivide_border_mm = 0.4f;
|
||||
// Edge length triangles straddling a *colour* boundary are refined to (0 = ignore colour). Its own
|
||||
// control rather than a share of "Detail (mm)" because the two measure different things: Detail is
|
||||
// a chord error in mm of surface deviation, this is a triangle size in mm along a step the chord
|
||||
// test cannot see at all - the height field is perfectly smooth across a change of filament, so
|
||||
// without this a colour boundary lands on whatever triangles the relief happened to need, which on
|
||||
// a flat surface is none. Only ever costs anything where a boundary actually runs.
|
||||
float m_subdivide_color_mm = 0.3f;
|
||||
// How many thousand triangles refinement may *add* (the mesh's own count is added on before it is
|
||||
// passed as subdivide_mesh_adaptive()'s absolute cap, so the control still means something on a
|
||||
// dense model). Refinement is worst-error-first, so hitting the budget still yields the best mesh
|
||||
@@ -504,6 +583,12 @@ private:
|
||||
// Whether the current bump mesh carries a precomputed per-vertex uv (LSCM) that the shader
|
||||
// should sample at directly, rather than projecting in-shader. Set by rebuild_bump_preview_mesh().
|
||||
bool m_bump_preview_uses_vertex_uv = false;
|
||||
// The palette the fast preview's per-triangle filament indices were built against, captured when
|
||||
// the mesh was. Empty when the active layer is not colouring, which is what tells the shader to
|
||||
// fall back to the model's own colour. Held rather than re-read at draw time so the indices baked
|
||||
// into the mesh can never be resolved against a different set of filaments than they were computed
|
||||
// from - loading a filament mid-session would otherwise recolour a stale preview at random.
|
||||
std::vector<PaletteEntry> m_bump_preview_palette;
|
||||
|
||||
// GPU island drag: while an island is dragged in the UV editor, the bump mesh is baked once (with
|
||||
// the dragged island's vertices flagged, v_normal.y = 1) and then moved purely through the shader's
|
||||
@@ -621,6 +706,12 @@ private:
|
||||
const void *m_height_tex_source = nullptr;
|
||||
float m_height_tex_smoothing = -1.f;
|
||||
|
||||
// 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;
|
||||
|
||||
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
|
||||
std::map<std::string, LibraryTexture> m_library_textures;
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/TriangleSelector.hpp"
|
||||
|
||||
#include "slic3r/GUI/GLCanvas3D.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/GUI_ObjectList.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/Plater.hpp"
|
||||
#include "slic3r/Utils/UndoRedo.hpp"
|
||||
@@ -30,6 +32,21 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
|
||||
// grows a close button once it reaches 100%, so a job that reports 0 and nothing else leaves an
|
||||
// uncloseable notification pinned on screen. It also carries the Cancel button's effect into the
|
||||
// bake, which on a subdivided mesh can run for several seconds.
|
||||
// Colour, when any layer asks for it, is computed in the same pass as the displacement: both need
|
||||
// the same per-layer projection and UV work, and doing it twice would double the expensive part.
|
||||
TextureColorRequest color_request;
|
||||
TextureColorRequest *color = nullptr;
|
||||
if (!m_input.color.empty()) {
|
||||
color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette);
|
||||
color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver(
|
||||
m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height,
|
||||
m_input.color.dither_cell_mm);
|
||||
color_request.despeckle_passes = m_input.color.despeckle_passes;
|
||||
color_request.out_triangle = &m_triangle_color;
|
||||
if (color_request.quantize)
|
||||
color = &color_request;
|
||||
}
|
||||
|
||||
int last_reported = 1;
|
||||
m_result = TriangleMesh(build_texture_displacement(
|
||||
m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options,
|
||||
@@ -43,7 +60,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
|
||||
ctl.update_status(percent, status);
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
},
|
||||
color));
|
||||
|
||||
// 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.
|
||||
@@ -72,6 +90,23 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
|
||||
volume->set_new_unique_id();
|
||||
volume->calculate_convex_hull();
|
||||
|
||||
// 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
|
||||
// filament". That is what confines the effect to the painted area without having to invent a
|
||||
// colour for everything outside it. Safe to index straight onto the new mesh - the bake is
|
||||
// topology-preserving, so triangle i is still triangle i.
|
||||
if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) {
|
||||
TriangleSelector selector(volume->mesh());
|
||||
const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
|
||||
if (!existing.bitstream.empty())
|
||||
selector.deserialize(existing, false);
|
||||
for (size_t i = 0; i < m_triangle_color.size(); ++i)
|
||||
if (m_triangle_color[i] > 0)
|
||||
selector.set_facet(int(i), EnforcerBlockerType(m_triangle_color[i]));
|
||||
volume->mmu_segmentation_facets.set(selector);
|
||||
}
|
||||
|
||||
// Clear the paint mask of every layer that was actually baked so a repeat bake (or the paint
|
||||
// overlay) doesn't act on triangles that no longer represent the same unbaked surface. The
|
||||
// texture layer definitions themselves (and paint outside the baked area, if any) are left
|
||||
@@ -107,10 +142,11 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
|
||||
}
|
||||
}
|
||||
|
||||
void queue_texture_displacement_bake(const ModelVolume &volume, std::function<void()> on_finished,
|
||||
bool take_snapshot)
|
||||
void queue_texture_displacement_bake(const ModelVolume &volume, const TextureColorSettings &color,
|
||||
std::function<void()> on_finished, bool take_snapshot)
|
||||
{
|
||||
TextureDisplacementBakeInput input;
|
||||
input.color = color;
|
||||
input.take_snapshot = take_snapshot;
|
||||
input.volume_id = volume.id();
|
||||
input.base_mesh = volume.mesh().its;
|
||||
@@ -119,6 +155,7 @@ void queue_texture_displacement_bake(const ModelVolume &volume, std::function<vo
|
||||
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
|
||||
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
|
||||
|
||||
|
||||
auto &worker = wxGetApp().plater()->get_ui_job_worker();
|
||||
queue_job(worker, std::make_unique<TextureDisplacementBakeJob>(std::move(input), std::move(on_finished)));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "libslic3r/Color.hpp"
|
||||
#include "libslic3r/ObjectID.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
@@ -22,6 +23,10 @@ struct TextureDisplacementBakeInput
|
||||
std::vector<TextureDisplacementLayer> layers;
|
||||
TextureDisplacementFacetsData facets_data;
|
||||
TextureDisplacementOptions options;
|
||||
// Captured on the main thread. Empty unless some layer is colouring, in which case the bake also
|
||||
// writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU
|
||||
// paint gizmo writes - alongside the displaced geometry.
|
||||
TextureColorSettings color;
|
||||
// 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.
|
||||
@@ -42,6 +47,9 @@ public:
|
||||
private:
|
||||
TextureDisplacementBakeInput m_input;
|
||||
TriangleMesh m_result;
|
||||
// Per triangle of m_result: the filament to print it in, as an EnforcerBlockerType value
|
||||
// (0 = leave alone). Empty unless a layer asked for colour. See TextureColorRequest.
|
||||
std::vector<uint8_t> m_triangle_color;
|
||||
std::function<void()> m_on_finished;
|
||||
};
|
||||
|
||||
@@ -49,8 +57,8 @@ private:
|
||||
// the app's UI job worker. `on_finished` is always called once the job settles (success, failure,
|
||||
// or cancellation), so the caller can clear its own "bake in progress" UI state. Must be called
|
||||
// from the main thread.
|
||||
void queue_texture_displacement_bake(const ModelVolume &volume, std::function<void()> on_finished,
|
||||
bool take_snapshot = true);
|
||||
void queue_texture_displacement_bake(const ModelVolume &volume, const TextureColorSettings &color,
|
||||
std::function<void()> on_finished, bool take_snapshot = true);
|
||||
|
||||
} // namespace Slic3r::GUI
|
||||
|
||||
|
||||
@@ -35,7 +35,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.params,
|
||||
m_input.params, m_input.color.palette,
|
||||
[&ctl, &status, &last_reported](int percent) {
|
||||
if (ctl.was_canceled())
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "Job.hpp"
|
||||
|
||||
#include "libslic3r/Color.hpp"
|
||||
#include "libslic3r/ObjectID.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
@@ -43,6 +44,10 @@ struct TextureDisplacementPrepareInput
|
||||
TextureDisplacementFacetsData masks;
|
||||
std::vector<TextureDisplacementLayer> layers;
|
||||
TextureDisplacementPrepareParams params;
|
||||
// Captured on the main thread. Empty when no layer is colouring, in which case the refinement
|
||||
// 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 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;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#include "TextureDisplacementPreviewJob.hpp"
|
||||
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
|
||||
|
||||
namespace Slic3r::GUI {
|
||||
|
||||
TextureDisplacementPreviewJob::TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation,
|
||||
std::shared_ptr<const std::atomic<uint64_t>> current_generation,
|
||||
std::function<void(indexed_triangle_set, uint64_t)> on_finished)
|
||||
std::function<void(TextureDisplacementPreviewResult, uint64_t)> on_finished)
|
||||
: m_input(std::move(input)), m_generation(generation), m_current_generation(std::move(current_generation)),
|
||||
m_on_finished(std::move(on_finished))
|
||||
{
|
||||
@@ -19,7 +20,21 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
|
||||
|
||||
// Only ever touches m_input (captured by value before this job was queued) and local state -
|
||||
// never the live Model - so this is safe to run concurrently with the UI thread.
|
||||
m_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options,
|
||||
TextureColorRequest color_request;
|
||||
TextureColorRequest *color = nullptr;
|
||||
if (!m_input.color.empty()) {
|
||||
color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette);
|
||||
color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver(
|
||||
m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height,
|
||||
m_input.color.dither_cell_mm);
|
||||
color_request.despeckle_passes = m_input.color.despeckle_passes;
|
||||
color_request.out_triangle = &m_result.triangle_color;
|
||||
if (color_request.quantize)
|
||||
color = &color_request;
|
||||
}
|
||||
|
||||
m_result.mesh = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data,
|
||||
m_input.options,
|
||||
[this, &ctl](int) {
|
||||
// Bail the moment this preview stops being the current
|
||||
// one; build_texture_displacement() then returns an
|
||||
@@ -27,7 +42,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
|
||||
return !ctl.was_canceled() &&
|
||||
(!m_current_generation ||
|
||||
m_current_generation->load() == m_generation);
|
||||
});
|
||||
},
|
||||
color);
|
||||
}
|
||||
|
||||
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
|
||||
@@ -40,7 +56,7 @@ void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &
|
||||
// was ever queued again for the rest of the session. An empty result is the caller's signal that
|
||||
// nothing usable came back; it already handles that.
|
||||
if (canceled || eptr)
|
||||
m_on_finished(indexed_triangle_set{}, m_generation);
|
||||
m_on_finished(TextureDisplacementPreviewResult{}, m_generation);
|
||||
else
|
||||
m_on_finished(std::move(m_result), m_generation);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "libslic3r/Color.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
@@ -22,6 +23,17 @@ struct TextureDisplacementPreviewInput
|
||||
std::vector<TextureDisplacementLayer> layers;
|
||||
TextureDisplacementFacetsData facets_data;
|
||||
TextureDisplacementOptions options;
|
||||
// Empty unless a layer is colouring, in which case the preview reports the filament per triangle
|
||||
// alongside the mesh, so the Normal view shows what the bake will produce - interleaving included.
|
||||
TextureColorSettings color;
|
||||
};
|
||||
|
||||
// A preview result: the displaced mesh, and - when the input carried a palette - one filament index
|
||||
// per triangle (an EnforcerBlockerType value; 0 means "no colour from the texture").
|
||||
struct TextureDisplacementPreviewResult
|
||||
{
|
||||
indexed_triangle_set mesh;
|
||||
std::vector<uint8_t> triangle_color;
|
||||
};
|
||||
|
||||
// Computes the true (unbaked) displaced-mesh preview in the background. With several painted
|
||||
@@ -49,7 +61,7 @@ public:
|
||||
// order: without it, a Bake queued behind a handful of stale previews waits for every one of them.
|
||||
TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation,
|
||||
std::shared_ptr<const std::atomic<uint64_t>> current_generation,
|
||||
std::function<void(indexed_triangle_set, uint64_t)> on_finished);
|
||||
std::function<void(TextureDisplacementPreviewResult, uint64_t)> on_finished);
|
||||
|
||||
void process(Ctl &ctl) override;
|
||||
void finalize(bool canceled, std::exception_ptr &eptr) override;
|
||||
@@ -58,8 +70,8 @@ private:
|
||||
TextureDisplacementPreviewInput m_input;
|
||||
uint64_t m_generation;
|
||||
std::shared_ptr<const std::atomic<uint64_t>> m_current_generation;
|
||||
indexed_triangle_set m_result;
|
||||
std::function<void(indexed_triangle_set, uint64_t)> m_on_finished;
|
||||
TextureDisplacementPreviewResult m_result;
|
||||
std::function<void(TextureDisplacementPreviewResult, uint64_t)> m_on_finished;
|
||||
};
|
||||
|
||||
} // namespace Slic3r::GUI
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <wx/image.h>
|
||||
#include <wx/mstream.h>
|
||||
|
||||
#include "libslic3r/PNGReadWrite.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
@@ -47,6 +48,52 @@ void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<Text
|
||||
[](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return a.name < b.name; });
|
||||
}
|
||||
|
||||
// True if the image carries any colour at all, i.e. some pixel's channels are not all equal. A
|
||||
// photo saved as RGB but actually grey should still be stored as a grayscale PNG - it is a third of
|
||||
// the bytes, and the colour feature keys off has_color(), so storing a grey image as RGB would offer
|
||||
// the user a "colour" that is a row of identical greys.
|
||||
bool image_has_color(const wxImage &image)
|
||||
{
|
||||
const unsigned char *rgb = image.GetData();
|
||||
if (rgb == nullptr)
|
||||
return false;
|
||||
const size_t n = size_t(image.GetWidth()) * size_t(image.GetHeight());
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
if (rgb[i * 3] != rgb[i * 3 + 1] || rgb[i * 3 + 1] != rgb[i * 3 + 2])
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Colour images are stored as-is (as an RGB PNG) so the texture can colour the model as well as
|
||||
// displace it; decode_height_texture() takes the height from their luminance, with the same
|
||||
// coefficients ConvertToGreyscale() uses, so the relief is identical either way. wxImage writes the
|
||||
// PNG here rather than Slic3r::png, which only encodes grayscale.
|
||||
bool encode_color_png_bytes(const wxImage &image, std::vector<unsigned char> &out, std::string &error)
|
||||
{
|
||||
if (image.GetWidth() <= 0 || image.GetHeight() <= 0) {
|
||||
error = _u8L("The selected image is empty.");
|
||||
return false;
|
||||
}
|
||||
wxMemoryOutputStream stream;
|
||||
// Alpha would be lost on the way into DecodedHeightTexture anyway, and a partly transparent
|
||||
// texture reading as black relief is worse than reading as its own colour over the background.
|
||||
wxImage opaque = image;
|
||||
if (opaque.HasAlpha())
|
||||
opaque.ClearAlpha();
|
||||
if (!opaque.SaveFile(stream, wxBITMAP_TYPE_PNG)) {
|
||||
error = _u8L("Failed to prepare the texture for use.");
|
||||
return false;
|
||||
}
|
||||
const size_t size = size_t(stream.GetLength());
|
||||
out.resize(size);
|
||||
stream.CopyTo(out.data(), size);
|
||||
if (out.empty()) {
|
||||
error = _u8L("Failed to prepare the texture for use.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slic3r::png only writes PNGs to a file, so the encode round-trips through a temp file rather than
|
||||
// staying in memory. It happens once per import / per texture pick, not per frame, so the I/O is
|
||||
// not worth avoiding with a second PNG encoder.
|
||||
@@ -87,14 +134,22 @@ bool encode_gray_png_bytes(const wxImage &image, std::vector<unsigned char> &out
|
||||
return true;
|
||||
}
|
||||
|
||||
// Grayscale in, grayscale out; colour in, colour out.
|
||||
bool encode_png_bytes(const wxImage &image, std::vector<unsigned char> &out, std::string &error)
|
||||
{
|
||||
return image_has_color(image) ? encode_color_png_bytes(image, out, error)
|
||||
: encode_gray_png_bytes(image, out, error);
|
||||
}
|
||||
|
||||
std::vector<unsigned char> read_file_bytes(const std::string &path)
|
||||
{
|
||||
std::ifstream ifs(path, std::ios::binary);
|
||||
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
// True if these bytes are already the 8-bit grayscale PNG libslic3r can decode, i.e. can be stored
|
||||
// on a layer as-is. Mirrors exactly what decode_height_texture() accepts.
|
||||
// True if these bytes are a PNG libslic3r can decode, i.e. can be stored on a layer as-is. Mirrors
|
||||
// exactly what decode_height_texture() accepts: 8-bit grayscale, or colour (whose luminance is the
|
||||
// height and whose RGB is available to colour the model).
|
||||
bool is_supported_height_map(const std::vector<unsigned char> &bytes)
|
||||
{
|
||||
if (bytes.empty())
|
||||
@@ -102,8 +157,12 @@ bool is_supported_height_map(const std::vector<unsigned char> &bytes)
|
||||
const png::ReadBuf rbuf{ bytes.data(), bytes.size() };
|
||||
if (!png::is_png(rbuf))
|
||||
return false;
|
||||
png::ImageGreyscale img;
|
||||
return png::decode_png(rbuf, img) && img.cols > 0 && img.rows > 0;
|
||||
png::ImageGreyscale gray;
|
||||
if (png::decode_png(rbuf, gray) && gray.cols > 0 && gray.rows > 0)
|
||||
return true;
|
||||
png::ImageColorscale color;
|
||||
return png::decode_colored_png(rbuf, color) && color.cols > 0 && color.rows > 0 &&
|
||||
color.bytes_per_pixel >= 3;
|
||||
}
|
||||
|
||||
std::vector<TextureLibraryEntry> g_library;
|
||||
@@ -153,7 +212,7 @@ std::optional<TextureLibraryEntry> import_texture_to_library(const std::string &
|
||||
}
|
||||
|
||||
std::vector<unsigned char> bytes;
|
||||
if (!encode_gray_png_bytes(image, bytes, error))
|
||||
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
|
||||
@@ -192,15 +251,15 @@ std::shared_ptr<std::vector<unsigned char>> load_texture_image_data(const std::s
|
||||
if (is_supported_height_map(bytes))
|
||||
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
|
||||
|
||||
// Not an 8-bit grayscale PNG (a colour image somebody copied into the folder by hand, say):
|
||||
// convert it the same way an import would, but leave the file on disk alone.
|
||||
// Not a PNG at all (a jpg somebody copied into the folder by hand, say): convert it the same way
|
||||
// an import would - keeping its colour if it has any - but leave the file on disk alone.
|
||||
wxImage image;
|
||||
if (!image.LoadFile(from_u8(path)) || !image.IsOk()) {
|
||||
error = _u8L("Could not load the selected image.");
|
||||
return nullptr;
|
||||
}
|
||||
std::vector<unsigned char> converted;
|
||||
if (!encode_gray_png_bytes(image, converted, error))
|
||||
if (!encode_png_bytes(image, converted, error))
|
||||
return nullptr;
|
||||
return std::make_shared<std::vector<unsigned char>>(std::move(converted));
|
||||
}
|
||||
|
||||
@@ -634,3 +634,216 @@ TEST_CASE("TextureDisplacement: feature-adaptive subdivision follows curvature,
|
||||
CHECK(worst <= 0.03f);
|
||||
}
|
||||
}
|
||||
|
||||
// A 2x2 truecolour PNG: red, green / blue, white. Written out as bytes rather than encoded here
|
||||
// because libslic3r only *writes* grayscale PNGs (png::write_gray_to_file) - which is also exactly
|
||||
// why the colour path exists: the GUI importer stores colour images through wxImage instead.
|
||||
static std::shared_ptr<std::vector<unsigned char>> make_rgb_png_2x2()
|
||||
{
|
||||
static const unsigned char bytes[] = {
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00,
|
||||
0x14, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xc0,
|
||||
0x00, 0xc2, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x1f, 0xee, 0x05,
|
||||
0xfb, 0x60, 0x6c, 0x70, 0xf2, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
|
||||
0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
};
|
||||
return std::make_shared<std::vector<unsigned char>>(std::begin(bytes), std::end(bytes));
|
||||
}
|
||||
|
||||
TEST_CASE("TextureDisplacement: a colour texture decodes to both colour and height", "[TextureDisplacement]")
|
||||
{
|
||||
TextureDisplacementLayer layer;
|
||||
layer.slot = 0;
|
||||
layer.image_data = make_rgb_png_2x2();
|
||||
|
||||
const DecodedHeightTexture tex = decode_height_texture(layer);
|
||||
REQUIRE_FALSE(tex.empty());
|
||||
REQUIRE(tex.has_color());
|
||||
REQUIRE(tex.width == 2);
|
||||
REQUIRE(tex.height == 2);
|
||||
REQUIRE(tex.rgb.size() == 2 * 2 * 3);
|
||||
|
||||
// Row-major, top-to-bottom: red, green / blue, white.
|
||||
CHECK(tex.rgb[0] == 255); CHECK(tex.rgb[1] == 0); CHECK(tex.rgb[2] == 0);
|
||||
CHECK(tex.rgb[3] == 0); CHECK(tex.rgb[4] == 255); CHECK(tex.rgb[5] == 0);
|
||||
CHECK(tex.rgb[6] == 0); CHECK(tex.rgb[7] == 0); CHECK(tex.rgb[8] == 255);
|
||||
CHECK(tex.rgb[9] == 255); CHECK(tex.rgb[10] == 255); CHECK(tex.rgb[11] == 255);
|
||||
|
||||
// Height is the luminance, with wxImage::ConvertToGreyscale()'s coefficients - which is what
|
||||
// makes a texture displace identically whether it was imported before or after colour was kept.
|
||||
CHECK(int(tex.pixels[0]) == int(std::lround(0.299 * 255))); // red
|
||||
CHECK(int(tex.pixels[1]) == int(std::lround(0.587 * 255))); // green
|
||||
CHECK(int(tex.pixels[2]) == int(std::lround(0.114 * 255))); // blue
|
||||
CHECK(int(tex.pixels[3]) == 255); // white
|
||||
}
|
||||
|
||||
TEST_CASE("TextureDisplacement: a grayscale texture reports no colour", "[TextureDisplacement]")
|
||||
{
|
||||
// The shipped library is all grayscale, and has_color() is what the whole colour feature keys
|
||||
// off - a height map must never look like it has colours to apply.
|
||||
TextureDisplacementLayer layer;
|
||||
layer.slot = 0;
|
||||
layer.image_data = make_flat_gray_png(128);
|
||||
|
||||
const DecodedHeightTexture tex = decode_height_texture(layer);
|
||||
REQUIRE_FALSE(tex.empty());
|
||||
CHECK_FALSE(tex.has_color());
|
||||
CHECK(tex.rgb.empty());
|
||||
|
||||
Vec3f out(9.f, 9.f, 9.f);
|
||||
CHECK_FALSE(sample_layer_color(tex, layer, Vec3f::Zero(), Vec3f::UnitZ(), out));
|
||||
CHECK(out.x() == 9.f); // left untouched on a false return
|
||||
}
|
||||
|
||||
// Two triangles making a 10x10 quad in the z=0 plane.
|
||||
static indexed_triangle_set color_test_quad()
|
||||
{
|
||||
indexed_triangle_set quad;
|
||||
quad.vertices = { Vec3f(0, 0, 0), Vec3f(10, 0, 0), Vec3f(10, 10, 0), Vec3f(0, 10, 0) };
|
||||
quad.indices = { { 0, 1, 2 }, { 0, 2, 3 } };
|
||||
return quad;
|
||||
}
|
||||
|
||||
TEST_CASE("TextureDisplacement: colour is reported per triangle and only where painted", "[TextureDisplacement]")
|
||||
{
|
||||
const indexed_triangle_set quad = color_test_quad();
|
||||
|
||||
TextureDisplacementLayer layer;
|
||||
layer.slot = 0;
|
||||
layer.image_data = make_rgb_png_2x2();
|
||||
layer.color_enabled = true;
|
||||
layer.depth_mm = 0.f; // colour only, so this isolates the colour path from the geometry
|
||||
layer.tiling_scale = 100.f;
|
||||
layer.projection_method = TextureProjectionMethod::Triplanar;
|
||||
|
||||
TriangleMesh mesh(quad);
|
||||
TriangleSelector selector(mesh);
|
||||
selector.set_facet(0, EnforcerBlockerType::ENFORCER); // only the first triangle
|
||||
|
||||
TextureDisplacementFacetsData facets;
|
||||
facets[0] = selector.serialize();
|
||||
|
||||
// A three-entry palette matched in plain RGB: all this test needs is *an* index. The perceptual
|
||||
// matching is the GUI's (make_palette_quantizer), and is deliberately not under test here.
|
||||
const std::array<Vec3f, 3> palette = { Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(0, 0, 1) };
|
||||
TextureColorRequest request;
|
||||
std::vector<uint8_t> triangle_color;
|
||||
request.out_triangle = &triangle_color;
|
||||
request.quantize = [&palette](const Vec3f &rgb) {
|
||||
int best = 0;
|
||||
float bd = std::numeric_limits<float>::max();
|
||||
for (int i = 0; i < 3; ++i)
|
||||
if (const float d = (palette[size_t(i)] - rgb).squaredNorm(); d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const indexed_triangle_set out = build_texture_displacement(quad, { layer }, facets, {}, {}, &request);
|
||||
|
||||
REQUIRE_FALSE(out.indices.empty());
|
||||
REQUIRE(triangle_color.size() == quad.indices.size());
|
||||
// The painted triangle takes a filament; the unpainted one is left at 0, which is
|
||||
// EnforcerBlockerType::NONE - "use the volume's own filament". That is what confines the effect
|
||||
// to the painted area without having to invent a colour for everything outside it.
|
||||
CHECK(triangle_color[0] != 0);
|
||||
CHECK(triangle_color[1] == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("TextureDisplacement: a layer that is not colouring reports no colours", "[TextureDisplacement]")
|
||||
{
|
||||
const indexed_triangle_set quad = color_test_quad();
|
||||
|
||||
TextureDisplacementLayer layer;
|
||||
layer.slot = 0;
|
||||
layer.image_data = make_rgb_png_2x2();
|
||||
layer.color_enabled = false; // the checkbox is off: colour stays off even on a colour texture
|
||||
layer.depth_mm = 1.f;
|
||||
|
||||
TriangleMesh mesh(quad);
|
||||
TriangleSelector selector(mesh);
|
||||
selector.set_facet(0, EnforcerBlockerType::ENFORCER);
|
||||
selector.set_facet(1, EnforcerBlockerType::ENFORCER);
|
||||
|
||||
TextureDisplacementFacetsData facets;
|
||||
facets[0] = selector.serialize();
|
||||
|
||||
TextureColorRequest request;
|
||||
std::vector<uint8_t> triangle_color;
|
||||
request.out_triangle = &triangle_color;
|
||||
request.quantize = [](const Vec3f &) { return 0; };
|
||||
|
||||
build_texture_displacement(quad, { layer }, facets, {}, {}, &request);
|
||||
|
||||
REQUIRE(triangle_color.size() == quad.indices.size());
|
||||
CHECK(triangle_color[0] == 0);
|
||||
CHECK(triangle_color[1] == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("TextureDisplacement: subdivision refines a colour boundary a flat height field hides",
|
||||
"[TextureDisplacement]")
|
||||
{
|
||||
// A cube with a flat height field, so *nothing* in the height criteria has any reason to refine
|
||||
// it - which is exactly the case the colour criterion exists for. Closed, so every_edge_used_twice()
|
||||
// is an exact crack detector: the colour criterion goes through the same conformal bisection as
|
||||
// everything else and must not be able to open one.
|
||||
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
|
||||
const std::vector<uint8_t> region(cube.indices.size(), REFINE_PAINTED);
|
||||
|
||||
auto longest_edge = [](const indexed_triangle_set &its, const stl_triangle_vertex_indices &t) {
|
||||
float m = 0.f;
|
||||
for (int e = 0; e < 3; ++e)
|
||||
m = std::max(m, (its.vertices[t[e]] - its.vertices[t[(e + 1) % 3]]).norm());
|
||||
return m;
|
||||
};
|
||||
|
||||
// One filament on each side of x = 5: a step, with no gradient anywhere for a chord test to see.
|
||||
ColorFieldSampler split_at_five = [](const Vec3f &p, const Vec3f &) { return p.x() < 5.f ? 0 : 1; };
|
||||
ColorFieldSampler all_one = [](const Vec3f &, const Vec3f &) { return 0; };
|
||||
|
||||
SECTION("a colour boundary gets triangles")
|
||||
{
|
||||
const indexed_triangle_set out =
|
||||
subdivide_mesh_adaptive(cube, region, /*max edge*/ 0.f, 200000, nullptr, nullptr, 0.f,
|
||||
/*min_edge*/ 0.05f, /*border*/ 0.f, nullptr, split_at_five,
|
||||
/*colour edge*/ 0.5f);
|
||||
|
||||
CHECK(out.indices.size() > cube.indices.size());
|
||||
CHECK(every_edge_used_twice(out)); // still watertight
|
||||
|
||||
// Every triangle still straddling the boundary must be down at the target.
|
||||
for (const auto &t : out.indices) {
|
||||
bool straddles = false;
|
||||
for (int i = 1; i < 3; ++i)
|
||||
if ((out.vertices[t[i]].x() < 5.f) != (out.vertices[t[0]].x() < 5.f))
|
||||
straddles = true;
|
||||
if (straddles)
|
||||
CHECK(longest_edge(out, t) <= 0.5f + 1e-4f);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("a uniform colour adds nothing")
|
||||
{
|
||||
const indexed_triangle_set out =
|
||||
subdivide_mesh_adaptive(cube, region, /*max edge*/ 0.f, 200000, nullptr, nullptr, 0.f,
|
||||
/*min_edge*/ 0.05f, /*border*/ 0.f, nullptr, all_one,
|
||||
/*colour edge*/ 0.5f);
|
||||
|
||||
CHECK(out.indices.size() == cube.indices.size());
|
||||
}
|
||||
|
||||
SECTION("no colour sampler leaves the mesh alone")
|
||||
{
|
||||
// The regression this guards: the colour criterion must be inert when nothing is colouring,
|
||||
// or every bake would start refining geometry for no reason.
|
||||
const indexed_triangle_set out =
|
||||
subdivide_mesh_adaptive(cube, region, /*max edge*/ 0.f, 200000, nullptr, nullptr, 0.f,
|
||||
/*min_edge*/ 0.05f, /*border*/ 0.f, nullptr, nullptr,
|
||||
/*colour edge*/ 0.5f);
|
||||
|
||||
CHECK(out.indices.size() == cube.indices.size());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user