diff --git a/resources/images/texture_displacement_erase_all.svg b/resources/images/texture_displacement_erase_all.svg new file mode 100644 index 0000000000..d45e6c7d35 --- /dev/null +++ b/resources/images/texture_displacement_erase_all.svg @@ -0,0 +1 @@ + diff --git a/resources/images/texture_displacement_select_all.svg b/resources/images/texture_displacement_select_all.svg new file mode 100644 index 0000000000..bf420df6c6 --- /dev/null +++ b/resources/images/texture_displacement_select_all.svg @@ -0,0 +1 @@ + diff --git a/resources/shaders/110/texture_displacement_bump.fs b/resources/shaders/110/texture_displacement_bump.fs index 5dff96d479..fa000cc7ba 100644 --- a/resources/shaders/110/texture_displacement_bump.fs +++ b/resources/shaders/110/texture_displacement_bump.fs @@ -28,6 +28,20 @@ uniform vec4 uniform_color; uniform vec3 palette_lab[64]; uniform vec3 palette_rgb[64]; uniform int palette_count; +uniform bool pure_only; // match against single filaments only (flat-colour image) +// How each entry prints. A pure entry is one filament (a == b); a mix interleaves filaments a and b, +// num parts of a in every den, and the print shows that interleave rather than the entry's average +// colour. The fragment resolves it exactly as GLGizmoTextureDisplacement::make_mix_resolver() does +// per triangle on the CPU, so the preview shows the pattern the bake will print. +uniform int palette_a[64]; +uniform int palette_b[64]; +uniform int palette_num[64]; +uniform int palette_den[64]; +uniform vec3 filament_rgb[16]; +uniform int filament_count; +uniform int mix_mode; // ColorMixMode: 0 Z bands, 1 XY dither, 2 auto +uniform float layer_height; // mm; one Z band per print layer +uniform float dither_cell; // mm; one XY dither cell 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; @@ -53,6 +67,11 @@ uniform bool use_vertex_uv; // 140 variant. Identity when nothing is dragged. uniform vec4 island_delta_lin; uniform vec2 island_delta_tr; +// In-shader projection (0 Triplanar, 1 Cylindrical, 2 Spherical) and the painted patch's own frame the +// two wrapping ones wrap around, in the texture frame; see the 140 variant. +uniform int projection_mode; +uniform vec3 patch_center; +uniform vec3 patch_axis; varying vec3 clipping_planes_dots; varying vec4 model_pos; @@ -61,8 +80,71 @@ varying float weight; varying float island_active; varying vec2 vertex_uv; -void projection_axes(vec3 n, out vec3 t, out vec3 b) +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including +// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so +// the preview wraps the texture the same way round as the bake. +void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { + up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); + vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + right = normalize(cross(up, arbitrary)); + fwd = normalize(cross(right, up)); +} + +// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's +// tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), +// project_cylindrical() and project_spherical(). `n` is read by the planar mode only. +vec2 projection_raw(vec3 p, vec3 n) +{ + if (projection_mode == 1) { // Cylindrical: (arc length around, distance along) + vec3 up, right, fwd; + cylinder_frame(up, right, fwd); + vec3 rel = p - patch_center; + float x = dot(rel, right); + float y = dot(rel, fwd); + return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + } + if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius + vec3 rel = p - patch_center; + float radius = length(rel); + if (radius < 1e-8) + return vec2(0.0); + vec3 dir = rel / radius; + return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + } + vec3 an = abs(n); // Triplanar: drop the dominant normal axis + return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); +} + +// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one +// millimetre of travel along each of them covers - the factor that turns the uv-space height gradient +// into a real mm-per-mm slope. For the planar projection both axes are world axes and the factor is 1. +// Cylindrical and Spherical are arc-length parametrized, so it is 1 there too, except for the +// spherical longitude, whose circle shrinks by cos(latitude) toward the poles. Exact where the surface +// really is the cylinder/sphere the projection assumes - the same assumption libslic3r makes. +void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_mm) +{ + units_per_mm = vec2(1.0, 1.0); + if (projection_mode == 1) { + vec3 up, right, fwd; + cylinder_frame(up, right, fwd); + vec3 rel = p - patch_center; + vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); + float r = length(xy); + t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it + b = up; // v is the distance along the axis + return; + } + if (projection_mode == 2) { + vec3 rel = p - patch_center; + float r = length(rel); + vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); + float c = length(dir.xy); // cos(latitude) + t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); + b = cross(dir, t); // increasing latitude, unit length + units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); + return; + } vec3 an = abs(n); if (an.x >= an.y && an.x >= an.z) { // planar = p.yz t = vec3(0.0, 1.0, 0.0); @@ -78,8 +160,7 @@ void projection_axes(vec3 n, out vec3 t, out vec3 b) vec2 project_uv(vec3 p, vec3 n) { - vec3 an = abs(n); - vec2 planar = (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + vec2 planar = projection_raw(p, n); planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); @@ -112,22 +193,99 @@ vec3 srgb_to_lab(vec3 c) // // 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) +int nearest_palette_entry(vec3 rgb) { vec3 lab = srgb_to_lab(rgb); int best = 0; + int best_pure = -1; float bd = 1.0e20; + float bd_pure = 1.0e20; for (int i = 0; i < 64; ++i) { if (i >= palette_count) break; + if (pure_only && palette_a[i] != palette_b[i]) + continue; // a flat-colour image never takes a mix (see the bake) vec3 d = lab - palette_lab[i]; float d2 = dot(d, d); + if (palette_a[i] == palette_b[i] && d2 < bd_pure) { + bd_pure = d2; + best_pure = i; + } if (d2 < bd) { bd = d2; best = i; } } - return palette_rgb[best]; + // The same bias make_palette_quantizer() applies (PREFER_PURE_DE = 10): a mix is an interleave, so + // it is only worth taking when it beats the nearest single filament by a visible step. Without it + // this picked a mix for almost every fragment - with four filaments the palette is 4 pure entries + // against 30 mixes - while the bake picked a single filament for most of them, so the preview + // interleaved the whole wall where the bake interleaves only patches. Compared on the distances + // rather than their squares, so the threshold means the same thing as it does on the CPU (up to + // CIE76 against CIEDE2000, the approximation already noted above). + if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < 10.0) + best = best_pure; + return best; +} + +// One 2x2 Bayer cell, {0, 2; 3, 1}, for x and y in {0, 1}. +float bayer2(float x, float y) { return 2.0 * x + 3.0 * y - 4.0 * x * y; } + +// The colour the printer lays down at world point `pos` for palette entry `index`: its filament, or +// for a mix whichever of its two filaments this point falls on. Mirrors make_mix_resolver() on the +// CPU, floors on the band/cell size included. All the modular arithmetic is done in floats with +// mod(), which wraps negative coordinates the way the CPU's ((v % n) + n) % n does and needs no +// integer % (not available on every GLSL 1.10 target). +vec3 printed_color(int index, vec3 pos, vec3 normal, vec3 footprint) +{ + int a = palette_a[index]; + int b = palette_b[index]; + if (a < 0 || a >= filament_count || b < 0 || b >= filament_count) + return palette_rgb[index]; // no filament to resolve to: the entry's own colour + if (a == b) + return filament_rgb[a]; + float num = float(palette_num[index]); + float den = float(palette_den[index]); + // Auto: bands where the surface is steeper than ~45 degrees, the dominant filament elsewhere. + if (mix_mode == 2 && abs(normal.z) >= 0.7) + return filament_rgb[(num * 2.0 >= den) ? a : b]; + + // Pre-filter. The interleave is an ordered dither the eye is meant to blend away, and no dither + // blends when it is drawn at less than a few pixels per period - it aliases, which is what turned + // every upright wall into horizontal streaks: the Z band cycle is den * layer_height (around a + // millimetre), and every pixel of a row on a vertical wall shares one z, so each row came out as a + // 1-bit threshold of the image at that row's phase. `footprint` is mm of world position per pixel, + // so this is zoom- and resolution-correct rather than a tuned constant: where the print's own + // pattern is finer than this view can resolve, show what the print looks like from here, which is + // the entry's perceptual average. The Normal view remains where the per-facet truth lives. + float period = (mix_mode == 1) ? 2.0 * max(dither_cell, 0.01) : den * max(layer_height, 0.01); + float px = (mix_mode == 1) ? max(footprint.x, footprint.y) : footprint.z; + float sharp = clamp(period / max(4.0 * px, 1e-6) - 0.5, 0.0, 1.0); + if (sharp <= 0.0) + return palette_rgb[index]; + + vec3 picked; + if (mix_mode == 1) { + // Ordered 4x4 Bayer over floor(x / cell), floor(y / cell). The CPU's table + // 0 8 2 10 + // 12 4 14 6 + // 3 11 1 9 + // 15 7 13 5 + // is 4 * bayer2(x % 2, y % 2) + bayer2(x / 2, y / 2), which needs no array (GLSL 1.10 has + // no constant arrays). + float cell = max(dither_cell, 0.01); + float gx = mod(floor(pos.x / cell), 4.0); + float gy = mod(floor(pos.y / cell), 4.0); + float bayer = 4.0 * bayer2(mod(gx, 2.0), mod(gy, 2.0)) + bayer2(floor(gx / 2.0), floor(gy / 2.0)); + picked = filament_rgb[(num / den > (bayer + 0.5) / 16.0) ? a : b]; + } else { + // Z bands: one per band height, the band's phase in the a/b cycle picks the filament. Both + // operands are integer-valued, so the half keeps "phase < num" exact under float rounding. + float slot = floor(pos.z / max(layer_height, 0.01)); + float phase = mod(slot, den); + picked = filament_rgb[(phase < num - 0.5) ? a : b]; + } + return mix(palette_rgb[index], picked, sharp); } void main() @@ -138,6 +296,9 @@ void main() // World millimetres throughout, like the bake - see the 140 variant. vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz))); vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does + // World mm per pixel, for pre-filtering the interleave in printed_color(). Taken here because the + // albedo branch at the end of main() is non-uniform control flow, where derivatives are undefined. + vec3 pos_fwidth = fwidth(world_pos.xyz); if (volume_mirrored) triangle_normal = -triangle_normal; @@ -169,7 +330,8 @@ void main() triangle_normal = normalize(triangle_normal - (dHdx * R1 + dHdy * R2) / det); } else if (weight > 0.0) { vec3 t, b; - projection_axes(triangle_normal, t, b); + vec2 units_per_mm; + projection_axes(tex_pos, triangle_normal, t, b, units_per_mm); // Parallax occlusion mapping: march the view ray through the height shell and shade at the // first point where it drops below the displaced surface (see header). @@ -229,7 +391,8 @@ void main() // One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v // component of the gradient carries the extra factor before being rotated back into t/b. vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect); - vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs); + // ...and back out of raw-projection units into millimetres along t / b; see the 140 variant. + vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm; vec3 gradient = slope.x * t + slope.y * b; gradient -= triangle_normal * dot(triangle_normal, gradient); @@ -247,11 +410,16 @@ void main() NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; - // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour. + // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour - + // and, where that is a mix, the filament the interleave puts here, so the pattern that prints shows. // 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); + // tex_pos, not world_pos: the bake resolves the interleave in the bake frame (world + // orientation and scale about the volume's origin, see texture_displacement_bake_frame()), so + // measuring z from the bed instead shifted the band phase by the volume origin's height - a + // different filament in the same place than the bake produces. + albedo = printed_color(nearest_palette_entry(texture2D(color_tex, color_uv).rgb), tex_pos, triangle_normal, pos_fwidth); gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/resources/shaders/110/texture_displacement_uvcheck.fs b/resources/shaders/110/texture_displacement_uvcheck.fs index 2679bc57e7..201bf38639 100644 --- a/resources/shaders/110/texture_displacement_uvcheck.fs +++ b/resources/shaders/110/texture_displacement_uvcheck.fs @@ -20,6 +20,15 @@ uniform vec3 tex_anchor; // the volume's origin in world space uniform float rotation_rad; uniform vec2 uv_offset; uniform bool use_vertex_uv; +// The in-shader projection (0 Triplanar, 1 Cylindrical, 2 Spherical) and the painted patch's frame the +// wrapping ones wrap around, in the texture frame - the same uniforms, and the same formulas, as +// texture_displacement_bump.fs, so the checker reports the projection the bake will actually use. +uniform int projection_mode; +uniform vec3 patch_center; +uniform vec3 patch_axis; +// Height map width / height, as apply_uv_transform() applies it. Without it the checker diverged from +// the bake for any non-square texture, in every in-shader projection. +uniform float tex_aspect; varying vec3 clipping_planes_dots; varying vec4 model_pos; @@ -27,14 +36,46 @@ varying vec4 world_pos; varying float distortion; varying vec2 vertex_uv; +void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) +{ + up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); + vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + right = normalize(cross(up, arbitrary)); + fwd = normalize(cross(right, up)); +} + +// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. +vec2 projection_raw(vec3 p, vec3 n) +{ + if (projection_mode == 1) { + vec3 up, right, fwd; + cylinder_frame(up, right, fwd); + vec3 rel = p - patch_center; + float x = dot(rel, right); + float y = dot(rel, fwd); + return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + } + if (projection_mode == 2) { + vec3 rel = p - patch_center; + float radius = length(rel); + if (radius < 1e-8) + return vec2(0.0); + vec3 dir = rel / radius; + return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + } + vec3 an = abs(n); + return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); +} + vec2 project_uv(vec3 p, vec3 n) { - vec3 an = abs(n); - vec2 planar = (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + vec2 planar = projection_raw(p, n); planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); - return vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs) + uv_offset; + vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear + return r + uv_offset; } vec3 heatmap(float t) diff --git a/resources/shaders/140/texture_displacement_bump.fs b/resources/shaders/140/texture_displacement_bump.fs index 8127ac35a1..baf620933a 100644 --- a/resources/shaders/140/texture_displacement_bump.fs +++ b/resources/shaders/140/texture_displacement_bump.fs @@ -87,6 +87,20 @@ uniform vec4 uniform_color; uniform vec3 palette_lab[64]; uniform vec3 palette_rgb[64]; uniform int palette_count; +uniform bool pure_only; // match against single filaments only (flat-colour image) +// How each entry prints. A pure entry is one filament (a == b); a mix interleaves filaments a and b, +// num parts of a in every den, and the print shows that interleave rather than the entry's average +// colour. The fragment resolves it exactly as GLGizmoTextureDisplacement::make_mix_resolver() does +// per triangle on the CPU, so the preview shows the pattern the bake will print. +uniform int palette_a[64]; +uniform int palette_b[64]; +uniform int palette_num[64]; +uniform int palette_den[64]; +uniform vec3 filament_rgb[16]; +uniform int filament_count; +uniform int mix_mode; // ColorMixMode: 0 Z bands, 1 XY dither, 2 auto +uniform float layer_height; // mm; one Z band per print layer +uniform float dither_cell; // mm; one XY dither cell 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; @@ -114,6 +128,14 @@ uniform bool use_vertex_uv; // true: sample at vertex_uv with a derived ta // only a uniform update uniform vec4 island_delta_lin; uniform vec2 island_delta_tr; +// Which projection to reconstruct in-shader: 0 Triplanar, 1 Cylindrical, 2 Spherical (the low three +// TextureProjectionMethod values; LSCM and ViewProjected arrive through use_vertex_uv and leave this +// at 0). The two wrapping projections wrap around the *whole painted patch*, so its centroid - and, +// for Cylindrical, its axis - are properties no single fragment can derive. They come from the CPU, +// in this same texture frame, computed with the bake's own texture_displacement_patch_frame(). +uniform int projection_mode; +uniform vec3 patch_center; +uniform vec3 patch_axis; in vec3 clipping_planes_dots; in vec4 model_pos; @@ -124,11 +146,71 @@ in vec2 vertex_uv; out vec4 out_color; -// The two world-space axes the triplanar planar coordinate is read off, per dominant normal -// component - same choice libslic3r's project_planar() makes, so planar.x runs along t, planar.y -// along b. -void projection_axes(vec3 n, out vec3 t, out vec3 b) +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including +// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so +// the preview wraps the texture the same way round as the bake. +void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { + up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); + vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + right = normalize(cross(up, arbitrary)); + fwd = normalize(cross(right, up)); +} + +// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's +// tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), +// project_cylindrical() and project_spherical(). `n` is read by the planar mode only. +vec2 projection_raw(vec3 p, vec3 n) +{ + if (projection_mode == 1) { // Cylindrical: (arc length around, distance along) + vec3 up, right, fwd; + cylinder_frame(up, right, fwd); + vec3 rel = p - patch_center; + float x = dot(rel, right); + float y = dot(rel, fwd); + return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + } + if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius + vec3 rel = p - patch_center; + float radius = length(rel); + if (radius < 1e-8) + return vec2(0.0); + vec3 dir = rel / radius; + return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + } + vec3 an = abs(n); // Triplanar: drop the dominant normal axis + return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); +} + +// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one +// millimetre of travel along each of them covers - the factor that turns the uv-space height gradient +// into a real mm-per-mm slope. For the planar projection both axes are world axes and the factor is 1. +// Cylindrical and Spherical are arc-length parametrized, so it is 1 there too, except for the +// spherical longitude, whose circle shrinks by cos(latitude) toward the poles. Exact where the surface +// really is the cylinder/sphere the projection assumes - the same assumption libslic3r makes. +void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_mm) +{ + units_per_mm = vec2(1.0, 1.0); + if (projection_mode == 1) { + vec3 up, right, fwd; + cylinder_frame(up, right, fwd); + vec3 rel = p - patch_center; + vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); + float r = length(xy); + t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it + b = up; // v is the distance along the axis + return; + } + if (projection_mode == 2) { + vec3 rel = p - patch_center; + float r = length(rel); + vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); + float c = length(dir.xy); // cos(latitude) + t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); + b = cross(dir, t); // increasing latitude, unit length + units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); + return; + } vec3 an = abs(n); if (an.x >= an.y && an.x >= an.z) { // planar = p.yz t = vec3(0.0, 1.0, 0.0); @@ -144,8 +226,7 @@ void projection_axes(vec3 n, out vec3 t, out vec3 b) vec2 project_uv(vec3 p, vec3 n) { - vec3 an = abs(n); - vec2 planar = (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + vec2 planar = projection_raw(p, n); planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); @@ -178,22 +259,99 @@ vec3 srgb_to_lab(vec3 c) // // 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) +int nearest_palette_entry(vec3 rgb) { vec3 lab = srgb_to_lab(rgb); int best = 0; + int best_pure = -1; float bd = 1.0e20; + float bd_pure = 1.0e20; for (int i = 0; i < 64; ++i) { if (i >= palette_count) break; + if (pure_only && palette_a[i] != palette_b[i]) + continue; // a flat-colour image never takes a mix (see the bake) vec3 d = lab - palette_lab[i]; float d2 = dot(d, d); + if (palette_a[i] == palette_b[i] && d2 < bd_pure) { + bd_pure = d2; + best_pure = i; + } if (d2 < bd) { bd = d2; best = i; } } - return palette_rgb[best]; + // The same bias make_palette_quantizer() applies (PREFER_PURE_DE = 10): a mix is an interleave, so + // it is only worth taking when it beats the nearest single filament by a visible step. Without it + // this picked a mix for almost every fragment - with four filaments the palette is 4 pure entries + // against 30 mixes - while the bake picked a single filament for most of them, so the preview + // interleaved the whole wall where the bake interleaves only patches. Compared on the distances + // rather than their squares, so the threshold means the same thing as it does on the CPU (up to + // CIE76 against CIEDE2000, the approximation already noted above). + if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < 10.0) + best = best_pure; + return best; +} + +// One 2x2 Bayer cell, {0, 2; 3, 1}, for x and y in {0, 1}. +float bayer2(float x, float y) { return 2.0 * x + 3.0 * y - 4.0 * x * y; } + +// The colour the printer lays down at world point `pos` for palette entry `index`: its filament, or +// for a mix whichever of its two filaments this point falls on. Mirrors make_mix_resolver() on the +// CPU, floors on the band/cell size included. All the modular arithmetic is done in floats with +// mod(), which wraps negative coordinates the way the CPU's ((v % n) + n) % n does and needs no +// integer % (not available on every GLSL 1.10 target). +vec3 printed_color(int index, vec3 pos, vec3 normal, vec3 footprint) +{ + int a = palette_a[index]; + int b = palette_b[index]; + if (a < 0 || a >= filament_count || b < 0 || b >= filament_count) + return palette_rgb[index]; // no filament to resolve to: the entry's own colour + if (a == b) + return filament_rgb[a]; + float num = float(palette_num[index]); + float den = float(palette_den[index]); + // Auto: bands where the surface is steeper than ~45 degrees, the dominant filament elsewhere. + if (mix_mode == 2 && abs(normal.z) >= 0.7) + return filament_rgb[(num * 2.0 >= den) ? a : b]; + + // Pre-filter. The interleave is an ordered dither the eye is meant to blend away, and no dither + // blends when it is drawn at less than a few pixels per period - it aliases, which is what turned + // every upright wall into horizontal streaks: the Z band cycle is den * layer_height (around a + // millimetre), and every pixel of a row on a vertical wall shares one z, so each row came out as a + // 1-bit threshold of the image at that row's phase. `footprint` is mm of world position per pixel, + // so this is zoom- and resolution-correct rather than a tuned constant: where the print's own + // pattern is finer than this view can resolve, show what the print looks like from here, which is + // the entry's perceptual average. The Normal view remains where the per-facet truth lives. + float period = (mix_mode == 1) ? 2.0 * max(dither_cell, 0.01) : den * max(layer_height, 0.01); + float px = (mix_mode == 1) ? max(footprint.x, footprint.y) : footprint.z; + float sharp = clamp(period / max(4.0 * px, 1e-6) - 0.5, 0.0, 1.0); + if (sharp <= 0.0) + return palette_rgb[index]; + + vec3 picked; + if (mix_mode == 1) { + // Ordered 4x4 Bayer over floor(x / cell), floor(y / cell). The CPU's table + // 0 8 2 10 + // 12 4 14 6 + // 3 11 1 9 + // 15 7 13 5 + // is 4 * bayer2(x % 2, y % 2) + bayer2(x / 2, y / 2), which needs no array (GLSL 1.10 has + // no constant arrays). + float cell = max(dither_cell, 0.01); + float gx = mod(floor(pos.x / cell), 4.0); + float gy = mod(floor(pos.y / cell), 4.0); + float bayer = 4.0 * bayer2(mod(gx, 2.0), mod(gy, 2.0)) + bayer2(floor(gx / 2.0), floor(gy / 2.0)); + picked = filament_rgb[(num / den > (bayer + 0.5) / 16.0) ? a : b]; + } else { + // Z bands: one per band height, the band's phase in the a/b cycle picks the filament. Both + // operands are integer-valued, so the half keeps "phase < num" exact under float rounding. + float slot = floor(pos.z / max(layer_height, 0.01)); + float phase = mod(slot, den); + picked = filament_rgb[(phase < num - 0.5) ? a : b]; + } + return mix(palette_rgb[index], picked, sharp); } void main() @@ -206,6 +364,9 @@ void main() // world position and perturb the world normal. vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz))); vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does + // World mm per pixel, for pre-filtering the interleave in printed_color(). Taken here because the + // albedo branch at the end of main() is non-uniform control flow, where derivatives are undefined. + vec3 pos_fwidth = fwidth(world_pos.xyz); if (volume_mirrored) triangle_normal = -triangle_normal; @@ -250,7 +411,8 @@ void main() // normal component (see header). The gradient is expressed analytically because there is a // closed-form uv here, unlike the LSCM case. vec3 t, b; - projection_axes(triangle_normal, t, b); + vec2 units_per_mm; + projection_axes(tex_pos, triangle_normal, t, b, units_per_mm); // Parallax occlusion mapping: march the view ray through the height shell and shade at the // first point where it drops below the displaced surface (see header). @@ -315,7 +477,10 @@ void main() // One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v // component of the gradient carries the extra factor before being rotated back into t/b. vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect); - vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs); + // ...and back out of raw-projection units into millimetres of travel along t / b, which is a + // no-op except for the spherical longitude (see projection_axes()). After the inverse rotation, + // because units_per_mm is expressed in the t/b frame rather than in uv. + vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm; vec3 gradient = slope.x * t + slope.y * b; gradient -= triangle_normal * dot(triangle_normal, gradient); @@ -333,11 +498,16 @@ void main() NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; - // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour. + // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour - + // and, where that is a mix, the filament the interleave puts here, so the pattern that prints shows. // 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); + // tex_pos, not world_pos: the bake resolves the interleave in the bake frame (world + // orientation and scale about the volume's origin, see texture_displacement_bake_frame()), so + // measuring z from the bed instead shifted the band phase by the volume origin's height - a + // different filament in the same place than the bake produces. + albedo = printed_color(nearest_palette_entry(texture(color_tex, color_uv).rgb), tex_pos, triangle_normal, pos_fwidth); out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/resources/shaders/140/texture_displacement_uvcheck.fs b/resources/shaders/140/texture_displacement_uvcheck.fs index 038c915f3f..583346aec0 100644 --- a/resources/shaders/140/texture_displacement_uvcheck.fs +++ b/resources/shaders/140/texture_displacement_uvcheck.fs @@ -29,6 +29,15 @@ uniform vec3 tex_anchor; // the volume's origin in world space uniform float rotation_rad; uniform vec2 uv_offset; uniform bool use_vertex_uv; +// The in-shader projection (0 Triplanar, 1 Cylindrical, 2 Spherical) and the painted patch's frame the +// wrapping ones wrap around, in the texture frame - the same uniforms, and the same formulas, as +// texture_displacement_bump.fs, so the checker reports the projection the bake will actually use. +uniform int projection_mode; +uniform vec3 patch_center; +uniform vec3 patch_axis; +// Height map width / height, as apply_uv_transform() applies it. Without it the checker diverged from +// the bake for any non-square texture, in every in-shader projection. +uniform float tex_aspect; in vec3 clipping_planes_dots; in vec4 model_pos; @@ -38,14 +47,46 @@ in vec2 vertex_uv; out vec4 out_color; +void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) +{ + up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); + vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + right = normalize(cross(up, arbitrary)); + fwd = normalize(cross(right, up)); +} + +// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. +vec2 projection_raw(vec3 p, vec3 n) +{ + if (projection_mode == 1) { + vec3 up, right, fwd; + cylinder_frame(up, right, fwd); + vec3 rel = p - patch_center; + float x = dot(rel, right); + float y = dot(rel, fwd); + return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + } + if (projection_mode == 2) { + vec3 rel = p - patch_center; + float radius = length(rel); + if (radius < 1e-8) + return vec2(0.0); + vec3 dir = rel / radius; + return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + } + vec3 an = abs(n); + return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); +} + vec2 project_uv(vec3 p, vec3 n) { - vec3 an = abs(n); - vec2 planar = (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + vec2 planar = projection_raw(p, n); planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); - return vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs) + uv_offset; + vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear + return r + uv_offset; } // Blue -> cyan -> green -> yellow -> red over t in [0,1]. diff --git a/resources/textures/displacement/Bark.png b/resources/textures/displacement/Bark.png new file mode 100644 index 0000000000..a48233d01e Binary files /dev/null and b/resources/textures/displacement/Bark.png differ diff --git a/resources/textures/displacement/Basket Weave.png b/resources/textures/displacement/Basket Weave.png new file mode 100644 index 0000000000..4459d87e6b Binary files /dev/null and b/resources/textures/displacement/Basket Weave.png differ diff --git a/resources/textures/displacement/Bubbles.png b/resources/textures/displacement/Bubbles.png new file mode 100644 index 0000000000..d04c58bd7d Binary files /dev/null and b/resources/textures/displacement/Bubbles.png differ diff --git a/resources/textures/displacement/Camouflage.png b/resources/textures/displacement/Camouflage.png new file mode 100644 index 0000000000..c0cde6accf Binary files /dev/null and b/resources/textures/displacement/Camouflage.png differ diff --git a/resources/textures/displacement/Carbon Fibre.png b/resources/textures/displacement/Carbon Fibre.png new file mode 100644 index 0000000000..53212f39a0 Binary files /dev/null and b/resources/textures/displacement/Carbon Fibre.png differ diff --git a/resources/textures/displacement/Chainmail.png b/resources/textures/displacement/Chainmail.png new file mode 100644 index 0000000000..7939d5b4a0 Binary files /dev/null and b/resources/textures/displacement/Chainmail.png differ diff --git a/resources/textures/displacement/Chevron.png b/resources/textures/displacement/Chevron.png new file mode 100644 index 0000000000..0a387439e2 Binary files /dev/null and b/resources/textures/displacement/Chevron.png differ diff --git a/resources/textures/displacement/Cobblestone.png b/resources/textures/displacement/Cobblestone.png new file mode 100644 index 0000000000..f9f7ed0035 Binary files /dev/null and b/resources/textures/displacement/Cobblestone.png differ diff --git a/resources/textures/displacement/Colour Bricks.png b/resources/textures/displacement/Colour Bricks.png new file mode 100644 index 0000000000..a8b574f14b Binary files /dev/null and b/resources/textures/displacement/Colour Bricks.png differ diff --git a/resources/textures/displacement/Cracked Earth.png b/resources/textures/displacement/Cracked Earth.png new file mode 100644 index 0000000000..9876500318 Binary files /dev/null and b/resources/textures/displacement/Cracked Earth.png differ diff --git a/resources/textures/displacement/Diamond Plate.png b/resources/textures/displacement/Diamond Plate.png new file mode 100644 index 0000000000..0b474a6921 Binary files /dev/null and b/resources/textures/displacement/Diamond Plate.png differ diff --git a/resources/textures/displacement/Fine Knurl.png b/resources/textures/displacement/Fine Knurl.png new file mode 100644 index 0000000000..dddd2480a0 Binary files /dev/null and b/resources/textures/displacement/Fine Knurl.png differ diff --git a/resources/textures/displacement/Hammered.png b/resources/textures/displacement/Hammered.png new file mode 100644 index 0000000000..45fac6009b Binary files /dev/null and b/resources/textures/displacement/Hammered.png differ diff --git a/resources/textures/displacement/Herringbone.png b/resources/textures/displacement/Herringbone.png new file mode 100644 index 0000000000..d773b9f615 Binary files /dev/null and b/resources/textures/displacement/Herringbone.png differ diff --git a/resources/textures/displacement/Hex Tiles.png b/resources/textures/displacement/Hex Tiles.png new file mode 100644 index 0000000000..2c924d18ec Binary files /dev/null and b/resources/textures/displacement/Hex Tiles.png differ diff --git a/resources/textures/displacement/Honeycomb.png b/resources/textures/displacement/Honeycomb.png new file mode 100644 index 0000000000..d428a5d2b3 Binary files /dev/null and b/resources/textures/displacement/Honeycomb.png differ diff --git a/resources/textures/displacement/Leather.png b/resources/textures/displacement/Leather.png new file mode 100644 index 0000000000..0c16daac63 Binary files /dev/null and b/resources/textures/displacement/Leather.png differ diff --git a/resources/textures/displacement/Mosaic Tiles.png b/resources/textures/displacement/Mosaic Tiles.png new file mode 100644 index 0000000000..e5e30e0bc2 Binary files /dev/null and b/resources/textures/displacement/Mosaic Tiles.png differ diff --git a/resources/textures/displacement/Perforated.png b/resources/textures/displacement/Perforated.png new file mode 100644 index 0000000000..5ea3435f14 Binary files /dev/null and b/resources/textures/displacement/Perforated.png differ diff --git a/resources/textures/displacement/Pyramids.png b/resources/textures/displacement/Pyramids.png new file mode 100644 index 0000000000..bb83b97b84 Binary files /dev/null and b/resources/textures/displacement/Pyramids.png differ diff --git a/resources/textures/displacement/Ripples.png b/resources/textures/displacement/Ripples.png new file mode 100644 index 0000000000..d06c6a58da Binary files /dev/null and b/resources/textures/displacement/Ripples.png differ diff --git a/resources/textures/displacement/Roof Tiles.png b/resources/textures/displacement/Roof Tiles.png new file mode 100644 index 0000000000..ed28294cb6 Binary files /dev/null and b/resources/textures/displacement/Roof Tiles.png differ diff --git a/resources/textures/displacement/Rope.png b/resources/textures/displacement/Rope.png new file mode 100644 index 0000000000..da6c6c1ffa Binary files /dev/null and b/resources/textures/displacement/Rope.png differ diff --git a/resources/textures/displacement/Sand Ripples.png b/resources/textures/displacement/Sand Ripples.png new file mode 100644 index 0000000000..7b4cd77312 Binary files /dev/null and b/resources/textures/displacement/Sand Ripples.png differ diff --git a/resources/textures/displacement/Scales.png b/resources/textures/displacement/Scales.png new file mode 100644 index 0000000000..6976dfad9d Binary files /dev/null and b/resources/textures/displacement/Scales.png differ diff --git a/resources/textures/displacement/Slate.png b/resources/textures/displacement/Slate.png new file mode 100644 index 0000000000..b63853a2d7 Binary files /dev/null and b/resources/textures/displacement/Slate.png differ diff --git a/resources/textures/displacement/Star Tiles.png b/resources/textures/displacement/Star Tiles.png new file mode 100644 index 0000000000..e1406e6f26 Binary files /dev/null and b/resources/textures/displacement/Star Tiles.png differ diff --git a/resources/textures/displacement/Stone Wall.png b/resources/textures/displacement/Stone Wall.png new file mode 100644 index 0000000000..f5239f92f9 Binary files /dev/null and b/resources/textures/displacement/Stone Wall.png differ diff --git a/resources/textures/displacement/Tartan.png b/resources/textures/displacement/Tartan.png new file mode 100644 index 0000000000..a5c321daa3 Binary files /dev/null and b/resources/textures/displacement/Tartan.png differ diff --git a/resources/textures/displacement/Terrazzo.png b/resources/textures/displacement/Terrazzo.png new file mode 100644 index 0000000000..0b817546bb Binary files /dev/null and b/resources/textures/displacement/Terrazzo.png differ diff --git a/resources/textures/displacement/Triangles.png b/resources/textures/displacement/Triangles.png new file mode 100644 index 0000000000..f9f21d88e5 Binary files /dev/null and b/resources/textures/displacement/Triangles.png differ diff --git a/resources/textures/displacement/Waffle.png b/resources/textures/displacement/Waffle.png new file mode 100644 index 0000000000..06b5de6377 Binary files /dev/null and b/resources/textures/displacement/Waffle.png differ diff --git a/resources/textures/displacement/Wood Planks.png b/resources/textures/displacement/Wood Planks.png new file mode 100644 index 0000000000..b0e02f0b02 Binary files /dev/null and b/resources/textures/displacement/Wood Planks.png differ diff --git a/scripts/generate_displacement_textures.py b/scripts/generate_displacement_textures.py new file mode 100644 index 0000000000..abcb7948ce --- /dev/null +++ b/scripts/generate_displacement_textures.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""Generates the displacement textures shipped in resources/textures/displacement/. + +Every texture is procedural - periodic functions and wrap-around noise on a unit tile - so the images +tile seamlessly in both directions and carry no third-party material. Grey PNGs are height maps +(white = raised). The colour PNGs are RGB where the luminance is the height, which is what +decode_height_texture() reads, so they displace and colour at the same time. + + python3 scripts/generate_displacement_textures.py [--size 1024] [--out resources/textures/displacement] + +Needs numpy and Pillow. Regenerating overwrites the files this script owns and nothing else. +""" +import argparse, os +import numpy as np +from PIL import Image + +SS = 2 # supersampling factor; the final image is the box-filtered average + +# --------------------------------------------------------------------------------------------- +# helpers: everything works on u, v in [0, 1) with period 1 on the tile +# --------------------------------------------------------------------------------------------- +def grid(n): + s = n * SS + v, u = np.meshgrid((np.arange(s) + 0.5) / s, (np.arange(s) + 0.5) / s, indexing='ij') + return u, v + +def down(img): + s = img.shape[0] // SS + return img.reshape(s, SS, s, SS).mean(axis=(1, 3)) + +def norm(h): + h = h - h.min(); m = h.max() + return h / m if m > 0 else h + +def smoothstep(e0, e1, x): + t = np.clip((x - e0) / (e1 - e0), 0.0, 1.0) + return t * t * (3 - 2 * t) + +def value_noise(u, v, freq, seed): + """Tileable value noise: a random lattice of `freq` cells with wrap-around, smooth interpolation.""" + rng = np.random.default_rng(seed) + lat = rng.random((freq, freq)) + x = u * freq; y = v * freq + x0 = np.floor(x).astype(int); y0 = np.floor(y).astype(int) + fx = x - x0; fy = y - y0 + fx = fx * fx * (3 - 2 * fx); fy = fy * fy * (3 - 2 * fy) + x1 = (x0 + 1) % freq; y1 = (y0 + 1) % freq; x0 %= freq; y0 %= freq + a = lat[y0, x0]; b = lat[y0, x1]; c = lat[y1, x0]; d = lat[y1, x1] + return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy + +def fbm(u, v, freq, seed, octaves=4, gain=0.5): + out = np.zeros_like(u); amp = 1.0; total = 0.0 + for o in range(octaves): + out += amp * value_noise(u, v, freq * 2 ** o, seed + o); total += amp; amp *= gain + return out / total + +def wrapped_points(n, seed, jitter=0.35): + """n x n jittered lattice points on the unit tile (periodic).""" + rng = np.random.default_rng(seed) + gx, gy = np.meshgrid((np.arange(n) + 0.5) / n, (np.arange(n) + 0.5) / n) + pts = np.stack([gx.ravel(), gy.ravel()], 1) + (rng.random((n * n, 2)) - 0.5) * (jitter / n) + return pts % 1.0 + +def voronoi(u, v, pts): + """Distance to the nearest and second-nearest point on the torus, and the nearest point's index.""" + d1 = np.full(u.shape, 9.0); d2 = np.full(u.shape, 9.0); idx = np.zeros(u.shape, int) + for i, (px, py) in enumerate(pts): + dx = u - px; dx -= np.round(dx); dy = v - py; dy -= np.round(dy) + d = np.sqrt(dx * dx + dy * dy) + closer = d < d1 + d2 = np.where(closer, d1, np.minimum(d2, d)); d1 = np.where(closer, d, d1); idx = np.where(closer, i, idx) + return d1, d2, idx + +def tri_wave(x): + return np.abs(2 * (x - np.floor(x + 0.5))) # 0..1 triangle wave, period 1 + +# --------------------------------------------------------------------------------------------- +# the textures +# --------------------------------------------------------------------------------------------- +def diamond_plate(u, v): + # Raised diamonds in two offset rows, bevelled; the classic tread plate. + n = 4 + h = np.zeros_like(u) + for ox, oy in ((0.0, 0.0), (0.5, 0.5)): + x = (u * n + ox) % 1 - 0.5; y = (v * n + oy) % 1 - 0.5 + d = np.abs(x) / 0.36 + np.abs(y) / 0.18 # a long diamond, 2:1 + h = np.maximum(h, smoothstep(1.0, 0.72, d)) + h += 0.06 * fbm(u, v, 32, 11) + return norm(h) + +def hex_cells(u, v, nx): + """Distance to the nearest hexagon centre, normalised so the hexagon's edge is at 1, plus a cell id. + nx hexagons across; the rows are stretched by at most ~2 % so a whole number fit the tile.""" + W = 1.0 / nx # hexagon width (flat-to-flat, pointy-top layout) + H = 2 * W / np.sqrt(3) # ideal corner-to-corner height + ny = 2 * max(1, int(round(1.0 / (1.5 * H)))) # rows per tile, even: the two staggered lattices + H = 1.0 / (0.75 * ny) # stretched so the rows tile exactly + best = np.full(u.shape, 9.0); cid = np.zeros(u.shape, int) + for k, (ox, oy) in enumerate(((0.0, 0.0), (0.5, 0.5))): + cx = (np.round(u / W - ox) + ox) * W; cy = (np.round(v / (1.5 * H) - oy) + oy) * 1.5 * H + dx = u - cx + dy = (v - cy) * (2 * W / np.sqrt(3)) / H # undo the row stretch: regular-hex units + ax = np.abs(dx) / (W / 2); ay = np.abs(dy) / (W / 2) + d = np.maximum(ax, (ax + ay * np.sqrt(3)) / 2) # hexagon SDF, edge at 1 + ix = np.round(u / W - ox).astype(int) % nx; iy = np.round(v / (1.5 * H) - oy).astype(int) % (ny // 2) + this = (ix * 7 + iy * 13 + k * 3) % 1009 # periodic, so a cell's colour matches across the seam + cid = np.where(d < best, this, cid); best = np.minimum(best, d) + return best, cid + +def honeycomb(u, v): + d, _ = hex_cells(u, v, 7) + return norm(smoothstep(1.0, 0.86, d)) + +def knurl(u, v): + n = 24 + a = tri_wave((u + v) * n); b = tri_wave((u - v) * n) + return norm(1 - np.maximum(a, b)) # pyramids + +def scales(u, v): + n = 8 + h = np.zeros_like(u) + # rows of circles, each row offset by half a scale and drawn over the row below + for row in range(-1, 2 * n + 1): + cy = row / (2 * n); ox = 0.5 if row % 2 else 0.0 + x = (u * n + ox) % 1 - 0.5; y = v - cy + y -= np.round(y) + r = np.sqrt((x / 1.0) ** 2 + (y * n) ** 2) + inside = r < 0.5 + dome = np.sqrt(np.clip(0.25 - r * r, 0, None)) * 2 # spherical cap + ramp = 0.4 + 0.6 * np.clip((0.5 * n * -y) / 0.5 + 0.5, 0, 1) # thicker at the exposed edge + cand = np.where(inside, 0.35 + 0.65 * dome * ramp, 0) + h = np.where(inside & (y * n <= 0.02), cand, h) + return norm(h) + +def herringbone(u, v): + # 2:1 bricks in the domino herringbone, turned 45 degrees. In cell coordinates (x, y) the brick a + # cell belongs to follows from (i - j) mod 4: 0/1 pair horizontally, 2/3 pair vertically. An even + # cell count per tile edge keeps that rule periodic. + n = 4 + x = (u + v) * n; y = (u - v) * n + i = np.floor(x); j = np.floor(y); fx = x - i; fy = y - j + k = ((i - j) % 4 + 4) % 4 + bx = np.where(k == 0, fx / 2, np.where(k == 1, 0.5 + fx / 2, fx)) + by = np.where(k == 2, 0.5 + fy / 2, np.where(k == 3, fy / 2, fy)) + # local coordinates on a 2x1 brick: the long axis is x for k in {0,1}, y for k in {2,3} + long_ = np.where(k < 2, bx, by); short = np.where(k < 2, by, bx) + gap_l = 0.03; gap_s = 0.06 + m = smoothstep(0, gap_l, long_) * smoothstep(0, gap_l, 1 - long_) * smoothstep(0, gap_s, short) * smoothstep(0, gap_s, 1 - short) + return norm(0.85 * m + 0.15 * m * fbm(u, v, 16, 5)) + +def cobblestone(u, v): + pts = wrapped_points(6, 21, 0.55) + d1, d2, idx = voronoi(u, v, pts) + edge = d2 - d1 + rng = np.random.default_rng(22); tops = rng.random(len(pts)) * 0.25 + 0.75 + h = smoothstep(0.0, 0.05, edge) * (0.55 + 0.45 * np.sqrt(np.clip(1 - (d1 / 0.11) ** 2, 0, None))) + h *= tops[idx] + h += 0.08 * fbm(u, v, 48, 23) + return norm(h) + +def leather(u, v): + pts = wrapped_points(22, 31, 0.9) + d1, d2, idx = voronoi(u, v, pts) + grooves = 1 - smoothstep(0.0, 0.012, d2 - d1) + h = 1 - 0.55 * grooves - 0.25 * fbm(u, v, 12, 33) - 0.1 * fbm(u, v, 96, 34) + return norm(h) + +def carbon_fibre(u, v): + n = 8 + x = u * n; y = v * n + cx = np.floor(x); cy = np.floor(y) + over = ((cx + cy) % 4) < 2 # 2x2 twill + fx = x % 1; fy = y % 1 + warp = 1 - 0.5 * (2 * np.abs(fx - 0.5)) ** 2 + 0.06 * np.sin(fy * 2 * np.pi * 14) + weft = 1 - 0.5 * (2 * np.abs(fy - 0.5)) ** 2 + 0.06 * np.sin(fx * 2 * np.pi * 14) + h = np.where(over, warp, weft * 0.92) + return norm(h) + +def chevron(u, v): + n = 6 + h = tri_wave(v * n * 2 + tri_wave(u * n) * 1.0) + return norm(1 - smoothstep(0.35, 0.65, h)) + +def ripples(u, v): + # Rings spreading from a few points on the torus, fading with distance - rain on water. + pts = wrapped_points(3, 81, 1.0) + h = np.zeros_like(u) + rng = np.random.default_rng(82) + for (px, py), f, ph in zip(pts, rng.random(len(pts)) * 6 + 10, rng.random(len(pts)) * 6.28): + dx = u - px; dx -= np.round(dx); dy = v - py; dy -= np.round(dy) + r = np.sqrt(dx * dx + dy * dy) + h += np.exp(-r / 0.22) * np.cos(2 * np.pi * r * f + ph) + return norm(h) + +def hammered(u, v): + pts = wrapped_points(9, 41, 0.8) + d1, d2, idx = voronoi(u, v, pts) + rng = np.random.default_rng(42); rad = rng.random(len(pts)) * 0.04 + 0.07 + dent = np.clip(1 - (d1 / rad[idx]) ** 2, 0, None) + return norm(1 - 0.8 * dent + 0.05 * fbm(u, v, 64, 43)) + +def perforated(u, v): + n = 8 + x = (u * n) % 1 - 0.5; y = (v * n) % 1 - 0.5 + r = np.sqrt(x * x + y * y) + return norm(smoothstep(0.30, 0.34, r)) + +def rope(u, v): + n = 6 # ropes per tile, running along v + x = (u * n) % 1 - 0.5 + body = np.sqrt(np.clip(0.25 - x * x, 0, None)) * 2 + twist = 0.5 + 0.5 * np.sin(2 * np.pi * (v * 12 + x * 1.6)) + return norm(body * (0.65 + 0.35 * twist)) + +def stone_wall(u, v): + rows = 5 + y = v * rows; row = np.floor(y); fy = y % 1 + rng = np.random.default_rng(51) + h = np.zeros_like(u) + for r in range(rows): + # stones of varying width along the row, periodic in u + widths = rng.random(6) * 0.6 + 0.7; widths *= 1.0 / widths.sum() + edges = np.concatenate([[0], np.cumsum(widths)]) + rng.random() * 0.3 + xu = (u + 0.0) % 1 + in_row = row == r + for i in range(len(widths)): + a = edges[i] % 1; w = widths[i] + dx = (xu - a) % 1 + inside = dx < w + gap = 0.035 + m = smoothstep(0, gap, dx) * smoothstep(0, gap, w - dx) * smoothstep(0, 0.12, fy) * smoothstep(0, 0.12, 1 - fy) + top = 0.7 + 0.3 * rng.random() + h = np.where(in_row & inside, m * top, h) + h = h * (0.85 + 0.15 * fbm(u, v, 24, 52)) + 0.04 * fbm(u, v, 96, 53) + return norm(h) + +# ---- colour textures: (rgb in 0..1, height = luminance by construction) +def lum(rgb): + return 0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2] + +def colour_bricks(u, v): + n = 6 + y = v * n; row = np.floor(y); x = u * n * 2 + 0.5 * (row % 2) + bx = x % 1; by = y % 1 + gap = 0.07 + m = smoothstep(0, gap, bx) * smoothstep(0, gap, 1 - bx) * smoothstep(0, gap * 2, by) * smoothstep(0, gap * 2, 1 - by) + rng = np.random.default_rng(61) + cell = (np.floor(x).astype(int) * 7 + row.astype(int) * 13) % 97 + tone = rng.random(97)[cell] + # brick reds of varying warmth over a dark grey mortar + brick = np.stack([0.70 + 0.2 * tone, 0.30 + 0.12 * tone, 0.22 + 0.06 * tone], -1) + brick *= (0.85 + 0.15 * fbm(u, v, 48, 62))[..., None] + mortar = np.array([0.30, 0.29, 0.27]) + rgb = brick * m[..., None] + mortar * (1 - m[..., None]) + return rgb + +def mosaic(u, v): + n = 8 + x = (u * n) % 1; y = (v * n) % 1 + gap = 0.08 + m = smoothstep(0, gap, x) * smoothstep(0, gap, 1 - x) * smoothstep(0, gap, y) * smoothstep(0, gap, 1 - y) + rng = np.random.default_rng(71) + cell = (np.floor(u * n).astype(int) * 31 + np.floor(v * n).astype(int) * 17) % 64 + pal = np.array([[0.90, 0.85, 0.70], [0.20, 0.45, 0.75], [0.85, 0.35, 0.25], [0.35, 0.65, 0.40]]) + tile = pal[rng.integers(0, 4, 64)[cell]] + grout = np.array([0.18, 0.18, 0.18]) + return tile * m[..., None] + grout * (1 - m[..., None]) + +def hex_tiles(u, v): + d, cid = hex_cells(u, v, 7) + m = smoothstep(1.0, 0.9, d) + pal = np.array([[0.95, 0.93, 0.88], [0.25, 0.55, 0.60], [0.80, 0.55, 0.20]]) + rng = np.random.default_rng(91) + tile = pal[rng.integers(0, 3, 1009)[cid]] + grout = np.array([0.15, 0.15, 0.16]) + return tile * m[..., None] + grout * (1 - m[..., None]) + + +def wood_planks(u, v): + n = 4 # planks across, running along v; staggered ends + x = u * n; col = np.floor(x); fx = x - col + rng = np.random.default_rng(101) + y = v * 2 + rng.random(n)[col.astype(int) % n] # each plank column has its own end offset + fy = y % 1 + gap = 0.04 + m = smoothstep(0, gap, fx) * smoothstep(0, gap, 1 - fx) * smoothstep(0, gap * 1.5, fy) * smoothstep(0, gap * 1.5, 1 - fy) + # grain: stretched noise along the plank, per plank phase + grain = fbm((u * 1.0 + rng.random(n)[col.astype(int) % n]) % 1, v, 6, 102, octaves=5) + rings = 0.5 + 0.5 * np.sin(2 * np.pi * (fx * 3 + grain * 2.5)) + return norm(m * (0.75 + 0.25 * rings)) + +def basket_weave(u, v): + n = 4 + x = u * n; y = v * n + cx = np.floor(x); cy = np.floor(y); fx = x % 1; fy = y % 1 + horiz = (cx + cy) % 2 == 0 + strips = 3 # strips per cell + along = np.where(horiz, fx, fy); across = np.where(horiz, fy, fx) + strip = (across * strips) % 1 + body = np.sqrt(np.clip(1 - (2 * strip - 1) ** 2, 0, None)) # rounded strip + ends = smoothstep(0, 0.06, along) * smoothstep(0, 0.06, 1 - along) + return norm(0.35 + 0.65 * body * ends) + +def chainmail(u, v): + n = 6 + h = np.zeros_like(u) + for ox, oy in ((0.0, 0.0), (0.5, 0.5)): + x = (u * n + ox) % 1 - 0.5; y = (v * n + oy) % 1 - 0.5 + r = np.sqrt(x * x + y * y) + ring = np.exp(-((r - 0.36) / 0.09) ** 2) + h = np.maximum(h, ring) + return norm(h) + +def pyramids(u, v): + n = 8 + x = np.abs((u * n) % 1 - 0.5); y = np.abs((v * n) % 1 - 0.5) + return norm(0.5 - np.maximum(x, y)) + +def waffle(u, v): + n = 6 + x = (u * n) % 1; y = (v * n) % 1 + gap = 0.12 + m = smoothstep(0, gap, x) * smoothstep(0, gap, 1 - x) * smoothstep(0, gap, y) * smoothstep(0, gap, 1 - y) + return norm(m) + +def bubbles(u, v): + rng = np.random.default_rng(111) + h = np.zeros_like(u) + for (px, py), r in zip(wrapped_points(7, 112, 0.9), rng.random(49) * 0.05 + 0.03): + dx = u - px; dx -= np.round(dx); dy = v - py; dy -= np.round(dy) + d2 = dx * dx + dy * dy + h = np.maximum(h, np.sqrt(np.clip(r * r - d2, 0, None)) / 0.08) + return norm(h) + +def cracked_earth(u, v): + pts = wrapped_points(7, 121, 0.7) + d1, d2, idx = voronoi(u, v, pts) + crack = 1 - smoothstep(0.0, 0.03, d2 - d1) + plates = 0.8 + 0.2 * fbm(u, v, 24, 122) + curl = 1 - 0.35 * np.clip(1 - d1 / 0.12, 0, 1) # plates curl up at the edges + return norm(plates * (2 - curl) * (1 - 0.9 * crack)) + +def sand_ripples(u, v): + n = 8 + wob = 0.06 * np.sin(2 * np.pi * u * 2) + 0.03 * fbm(u, v, 4, 131) + h = 0.5 + 0.5 * np.sin(2 * np.pi * (v * n + wob)) + h = h ** 1.6 # sharp crests, soft troughs + return norm(h + 0.05 * fbm(u, v, 64, 132)) + +def bark(u, v): + grooves = fbm((u * 3) % 1, v, 4, 141, octaves=4) # the ridges below stretch it along v + ridges = np.abs(np.sin(2 * np.pi * (u * 9 + grooves * 1.5))) + return norm(ridges ** 0.7 * (0.7 + 0.3 * fbm(u, v, 12, 142)) + 0.1 * fbm(u, v, 48, 143)) + +def slate(u, v): + h = fbm(u, v, 3, 151, octaves=6, gain=0.55) + steps = np.floor(h * 6) / 6 + 0.4 * (h * 6 - np.floor(h * 6)) / 6 # cleaved layers + return norm(steps + 0.05 * fbm(u, v, 48, 152)) + +def triangles(u, v): + n = 6 + x = u * n; y = v * n * np.sqrt(3) / 1.5 # rows of equilateral triangles + row = np.floor(y); fy = y - row + xs = x + 0.5 * (row % 2) + fx = xs % 1 + up = fx < 1 - fy # which triangle of the rhombus + # distance to the nearest edge of the triangle, in either orientation + d_up = np.minimum(np.minimum(fy, fx - 0 * fy), (1 - fy - fx)) + d_dn = np.minimum(np.minimum(1 - fy, 1 - fx), (fx + fy - 1)) + d = np.where(up, d_up, d_dn) + return norm(smoothstep(0.0, 0.08, d)) + +def roof_tiles(u, v): + n = 6 + h = np.zeros_like(u) + for row in range(-1, 2 * n + 1): + cy = row / (2 * n); ox = 0.5 if row % 2 else 0.0 + x = (u * n + ox) % 1 - 0.5; y = v - cy; y -= np.round(y) + yy = y * n * 2 # 0 at the row's exposed edge, rising toward the covered end + inside = (yy >= -0.05) & (yy < 1.0) + arch = np.cos(x * np.pi) * 0.6 + 0.4 + cand = np.where(inside, 0.3 + 0.7 * arch * (1 - 0.35 * yy), 0) + h = np.where(inside & (cand > 0), cand, h) + return norm(h) + +def star_tiles(u, v): + # 8-point stars and crosses (the classic Islamic star-and-cross tiling) + n = 4 + x = (u * n) % 1 - 0.5; y = (v * n) % 1 - 0.5 + a = np.abs(x); b = np.abs(y) + star = np.maximum(np.maximum(a, b), (a + b) / np.sqrt(2) * 1.15) + m = smoothstep(0.42, 0.36, star) + # the crosses between the stars sit on the half-offset lattice + x2 = (u * n + 0.5) % 1 - 0.5; y2 = (v * n + 0.5) % 1 - 0.5 + a2 = np.abs(x2); b2 = np.abs(y2) + cross = np.minimum(np.maximum(a2 / 0.12, b2 / 0.30), np.maximum(a2 / 0.30, b2 / 0.12)) + m2 = smoothstep(1.0, 0.85, cross) * 0.8 + return norm(np.maximum(m, m2)) + +def terrazzo(u, v): + base = np.array([0.82, 0.80, 0.76]) + rgb = np.broadcast_to(base, u.shape + (3,)).copy() * (0.95 + 0.05 * fbm(u, v, 32, 161))[..., None] + rng = np.random.default_rng(162) + pal = np.array([[0.85, 0.30, 0.25], [0.20, 0.35, 0.55], [0.25, 0.25, 0.25], [0.95, 0.90, 0.80], [0.80, 0.60, 0.20]]) + for (px, py), r, c, ang in zip(wrapped_points(12, 163, 1.0), rng.random(144) * 0.02 + 0.012, rng.integers(0, 5, 144), rng.random(144) * 3.14): + dx = u - px; dx -= np.round(dx); dy = v - py; dy -= np.round(dy) + ca, sa = np.cos(ang), np.sin(ang) + ex = (dx * ca - dy * sa) / (r * 1.4); ey = (dx * sa + dy * ca) / r + inside = (np.abs(ex) + np.abs(ey) * 0.7 + np.maximum(np.abs(ex), np.abs(ey)) * 0.5) < 1.0 + rgb[inside] = pal[c] * 0.92 # chips sit a touch below the matrix: darker = lower + return rgb + +def camouflage(u, v): + pal = np.array([[0.36, 0.42, 0.24], [0.55, 0.50, 0.32], [0.22, 0.26, 0.17], [0.60, 0.58, 0.45]]) + a = fbm(u, v, 3, 171, octaves=4); b = fbm(u, v, 3, 172, octaves=4); c = fbm(u, v, 5, 173, octaves=3) + idx = (a > 0.55).astype(int) + 2 * (b > 0.5).astype(int) + idx = np.where(c > 0.72, 3, idx) + rgb = pal[idx].astype(float) + return rgb * (0.94 + 0.06 * fbm(u, v, 48, 174))[..., None] + +def tartan(u, v): + n = 2 + def stripes(t): + t = (t * n) % 1 + band = np.zeros_like(t) + for a, w, val in ((0.0, 0.32, 1), (0.32, 0.06, 2), (0.38, 0.24, 0), (0.62, 0.06, 2), (0.68, 0.32, 1)): + band = np.where((t >= a) & (t < a + w), val, band) + return band + pal = np.array([[0.12, 0.25, 0.20], [0.55, 0.12, 0.14], [0.90, 0.80, 0.30]]) + su = stripes(u).astype(int); sv = stripes(v).astype(int) + weave = ((np.floor(u * 400) + np.floor(v * 400)) % 2) == 0 # the two thread directions alternate + rgb = np.where(weave[..., None], pal[su], pal[sv]).astype(float) + return rgb * (0.9 + 0.1 * (0.5 + 0.5 * np.sin(2 * np.pi * (u + v) * 200)))[..., None] + +GREY = { + 'Diamond Plate': diamond_plate, 'Honeycomb': honeycomb, 'Fine Knurl': knurl, 'Scales': scales, + 'Herringbone': herringbone, 'Cobblestone': cobblestone, 'Leather': leather, 'Carbon Fibre': carbon_fibre, + 'Chevron': chevron, 'Ripples': ripples, 'Hammered': hammered, 'Perforated': perforated, 'Rope': rope, + 'Stone Wall': stone_wall, 'Wood Planks': wood_planks, 'Basket Weave': basket_weave, 'Chainmail': chainmail, + 'Pyramids': pyramids, 'Waffle': waffle, 'Bubbles': bubbles, 'Cracked Earth': cracked_earth, + 'Sand Ripples': sand_ripples, 'Bark': bark, 'Slate': slate, 'Triangles': triangles, 'Roof Tiles': roof_tiles, + 'Star Tiles': star_tiles, +} +COLOUR = {'Colour Bricks': colour_bricks, 'Mosaic Tiles': mosaic, 'Hex Tiles': hex_tiles, 'Terrazzo': terrazzo, + 'Camouflage': camouflage, 'Tartan': tartan} + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--size', type=int, default=1024) + ap.add_argument('--out', default='resources/textures/displacement') + ap.add_argument('--sheet', default='') + args = ap.parse_args() + os.makedirs(args.out, exist_ok=True) + u, v = grid(args.size) + thumbs = [] + for name, fn in GREY.items(): + img = (np.clip(down(fn(u, v)), 0, 1) * 255 + 0.5).astype(np.uint8) + Image.fromarray(img, 'L').save(os.path.join(args.out, name + '.png'), optimize=True) + thumbs.append((name, np.stack([img] * 3, -1))) + print('wrote', name) + for name, fn in COLOUR.items(): + rgb = fn(u, v) + rgb = np.stack([down(rgb[..., c]) for c in range(3)], -1) + img = (np.clip(rgb, 0, 1) * 255 + 0.5).astype(np.uint8) + Image.fromarray(img, 'RGB').save(os.path.join(args.out, name + '.png'), optimize=True) + thumbs.append((name, img)) + print('wrote', name, '(colour)') + if args.sheet: + t = 256; cols = 7; rows = (len(thumbs) + cols - 1) // cols + sheet = Image.new('RGB', (cols * t, rows * t), (40, 40, 40)) + for i, (name, img) in enumerate(thumbs): + im = Image.fromarray(img).resize((t, t), Image.LANCZOS) + sheet.paste(im, ((i % cols) * t, (i // cols) * t)) + sheet.save(args.sheet); print('sheet', args.sheet) + +if __name__ == '__main__': + main() diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index cc59351d72..2f56b2d56e 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -2943,6 +2943,11 @@ void ModelVolume::assign_new_unique_ids_recursive() seam_facets.set_new_unique_id(); mmu_segmentation_facets.set_new_unique_id(); fuzzy_skin_facets.set_new_unique_id(); + // As set_new_unique_id() already does: the undo/redo stack stores FacetsAnnotation contents keyed + // by ObjectID, so a clone left sharing these ids with its source can be handed the source's mask + // on an undo - after which a paint mask and the mesh it was recorded against no longer match. + for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) + texture_displacement_facet(i).set_new_unique_id(); } void ModelVolume::rotate(double angle, Axis axis) diff --git a/src/libslic3r/TextureBake/TextureBakeDecimate.cpp b/src/libslic3r/TextureBake/TextureBakeDecimate.cpp index f08331c8f0..13d18c5314 100644 --- a/src/libslic3r/TextureBake/TextureBakeDecimate.cpp +++ b/src/libslic3r/TextureBake/TextureBakeDecimate.cpp @@ -98,7 +98,7 @@ struct HeapEntry DecimateResult decimate(const TriSoup &geometry, size_t target_triangles, bool harvest_flat, double harvest_tol, const std::vector &locked_faces, - const DecimateProgressFn &on_progress) + const DecimateProgressFn &on_progress, const std::vector &face_color) { DecimateResult result; const size_t n = geometry.pos.size(); @@ -211,8 +211,10 @@ DecimateResult decimate(const TriSoup &geometry, size_t target_triangles, bool h faces[size_t(er.f0) * 3 + 2]); const Vec3d n1 = face_normal_unit(pos, faces[size_t(er.f1) * 3], faces[size_t(er.f1) * 3 + 1], faces[size_t(er.f1) * 3 + 2]); - if (n0.dot(n1) >= DECIMATE_CREASE_COS) - continue; // smooth enough to be no crease + const bool color_edge = face_color.size() > std::max(size_t(er.f0), size_t(er.f1)) && + face_color[size_t(er.f0)] != face_color[size_t(er.f1)]; + if (!color_edge && n0.dot(n1) >= DECIMATE_CREASE_COS) + continue; // smooth enough to be no crease, and no colour changes across it const Vec3d e = pos[size_t(er.vb)] - pos[size_t(er.va)]; const double elen = e.norm(); diff --git a/src/libslic3r/TextureBake/TextureBakeDecimate.hpp b/src/libslic3r/TextureBake/TextureBakeDecimate.hpp index 8876ab00df..c40d9de266 100644 --- a/src/libslic3r/TextureBake/TextureBakeDecimate.hpp +++ b/src/libslic3r/TextureBake/TextureBakeDecimate.hpp @@ -47,10 +47,14 @@ struct DecimateResult // `locked_faces`: one entry per input triangle; a vertex touching one may neither move nor be // removed, which also pins the ring between the two regions. +// `face_color`: optional, one entry per input triangle. An edge between two faces of different +// colour is treated as a crease, so the simplified triangles never span a colour boundary and the +// boundary keeps its place - a per-triangle colour read off the result then has nothing to smear. DecimateResult decimate(const TriSoup &geometry, size_t target_triangles, bool harvest_flat = true, double harvest_tol = DECIMATE_DEFAULT_HARVEST_TOL, const std::vector &locked_faces = {}, - const DecimateProgressFn &on_progress = {}); + const DecimateProgressFn &on_progress = {}, + const std::vector &face_color = {}); } // namespace TextureBake } // namespace Slic3r diff --git a/src/libslic3r/TextureBake/TextureBakePipeline.cpp b/src/libslic3r/TextureBake/TextureBakePipeline.cpp index 37b58a6f3b..f59ed51163 100644 --- a/src/libslic3r/TextureBake/TextureBakePipeline.cpp +++ b/src/libslic3r/TextureBake/TextureBakePipeline.cpp @@ -1,5 +1,8 @@ #include "TextureBakePipeline.hpp" +#include +#include + #include "TextureBakeDebug.hpp" #include @@ -111,7 +114,8 @@ size_t snap_bottom_to_flat(TriSoup &geometry, float bottom_z, double tol) PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, const PipelineSettings &settings, const DisplaceBounds &bounds, PipelineMode mode, const std::vector &face_excluded, - const PipelineProgressFn &on_progress, BakeStageRecorder *debug) + const PipelineProgressFn &on_progress, BakeStageRecorder *debug, + const ColorSampleFn &color_sample) { PipelineResult result; const auto report = [&](const char *stage, double f) { @@ -193,6 +197,46 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, } } + // 2b. Paint finer than the input triangles. The caller includes a source triangle when any part of + // it is painted; now that the faces are small, ask once more per face and switch the unpainted + // ones off. They are pinned like the excluded region from here on (their own corners at weight + // 1, and the displacement's boundary sealing pins the stroke's rim on the painted side), but they + // are refined pieces of painted triangles, not original geometry, so `soft_excluded` keeps them + // out of the decimation lock below. Every stage between here and the decimation rewrites faces in + // place, so the per-face flag stays valid by index. + std::vector soft_excluded; + if (settings.painted) { + const size_t nf = sub.geometry.triangle_count(); + const bool have_w = !sub.geometry.exclude_weight.empty(); + std::vector unpainted(nf, 0); + tbb::parallel_for(tbb::blocked_range(0, nf), [&](const tbb::blocked_range &r) { + for (size_t t = r.begin(); t < r.end(); ++t) { + if (have_w && sub.geometry.exclude_weight[t * 3] > 0.99f) + continue; // excluded from the start, never asked + const Vec3f &a = sub.geometry.pos[t * 3], &b = sub.geometry.pos[t * 3 + 1], &c = sub.geometry.pos[t * 3 + 2]; + if (!settings.painted((a + b + c) / 3.f)) + unpainted[t] = 1; + } + }); + size_t switched = 0; + for (size_t t = 0; t < nf; ++t) + switched += unpainted[t]; + if (switched > 0) { + if (sub.geometry.exclude_weight.empty()) + sub.geometry.exclude_weight.assign(sub.geometry.pos.size(), 0.f); + for (size_t t = 0; t < nf; ++t) + if (unpainted[t]) + sub.geometry.exclude_weight[t * 3] = sub.geometry.exclude_weight[t * 3 + 1] = + sub.geometry.exclude_weight[t * 3 + 2] = 1.f; + soft_excluded = std::move(unpainted); + } + lap("paint", sub.geometry, std::to_string(switched) + " faces switched off"); + if (!report("paint", 1.0)) { + result.canceled = true; + return result; + } + } + // 3. Align the mesh to the height field's edges, then displace. if (settings.relocate) { std::vector locked; @@ -247,12 +291,40 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, std::vector locked; if (settings.preserve_untextured && !displaced.exclude_weight.empty()) { locked.assign(displaced.triangle_count(), 0); + // The corner average, as the displacement stage judges it: after the flip stage's + // per-vertex merge an included face touching the excluded region carries one corner + // at weight 1, and must stay free to collapse and to take colour. for (size_t t = 0; t < locked.size(); ++t) - locked[t] = displaced.exclude_weight[t * 3] > 0.99f ? 1 : 0; + locked[t] = (displaced.exclude_weight[t * 3] + displaced.exclude_weight[t * 3 + 1] + + displaced.exclude_weight[t * 3 + 2]) / 3.f > 0.99f ? 1 : 0; + // Faces the paint test switched off carry weight 1 too, but are refined pieces of + // painted triangles rather than original geometry: locking them would keep a partly + // painted source triangle at full refinement. Face indices survived relocate, flip + // and displace unchanged, so the flag still lines up. + for (size_t t = 0; t < locked.size() && t < soft_excluded.size(); ++t) + if (soft_excluded[t]) + locked[t] = 0; + } + // Colour per face on the fine mesh, so colour boundaries become creases the collapse + // respects. Excluded (unpainted) faces take no colour. + std::vector face_color; + if (color_sample) { + const size_t nf = displaced.triangle_count(); + face_color.assign(nf, -1); + const bool have_w = !displaced.exclude_weight.empty(); + tbb::parallel_for(tbb::blocked_range(0, nf), [&](const tbb::blocked_range &r) { + for (size_t t = r.begin(); t < r.end(); ++t) { + if (have_w && (displaced.exclude_weight[t * 3] + displaced.exclude_weight[t * 3 + 1] + + displaced.exclude_weight[t * 3 + 2]) / 3.f > 0.99f) + continue; + const Vec3f &a = displaced.pos[t * 3], &b = displaced.pos[t * 3 + 1], &c = displaced.pos[t * 3 + 2]; + face_color[t] = color_sample((a + b + c) / 3.f, displaced.nrm[t * 3]); + } + }); } DecimateResult dec = decimate(displaced, settings.max_triangles, settings.harvest_flat, settings.harvest_tol, locked, - [&](double f) { return report("decimate", f); }); + [&](double f) { return report("decimate", f); }, face_color); result.locked_over_budget = dec.locked_over_budget; displaced = std::move(dec.geometry); lap("decimate", displaced, "over budget, simplified"); diff --git a/src/libslic3r/TextureBake/TextureBakePipeline.hpp b/src/libslic3r/TextureBake/TextureBakePipeline.hpp index aac6710cf4..d56a0d09dc 100644 --- a/src/libslic3r/TextureBake/TextureBakePipeline.hpp +++ b/src/libslic3r/TextureBake/TextureBakePipeline.hpp @@ -2,8 +2,8 @@ // The bake pipeline: // -// subdivide -> [regularize -> re-subdivide] -> [relocate] -> displace -> [decimate] -// -> bottom clamp -> bottom snap -> [resolve T-junctions] +// subdivide -> [regularize -> re-subdivide] -> [paint test] -> [relocate] -> [flip edges] +// -> displace -> [decimate] -> bottom clamp -> bottom snap -> [resolve T-junctions] // // Regularization sits between two subdivisions on purpose: it dissolves the slivers refinement // inherited, which lengthens some edges past the target, and the second pass brings those back. @@ -66,6 +66,13 @@ struct PipelineSettings DisplaceSettings displace; + // Optional. Asked once per refined face (its centroid, in the soup's coordinates) after the + // refinement stages and before displacement, for faces whose source triangle was included: + // false marks the face as unpainted (no displacement), so paint finer than the input triangles + // is honoured. Faces excluded from the start are never asked. Called from several threads at + // once, so it must be safe to call concurrently. + std::function painted; + // Export mode only. size_t max_triangles = 750'000; // Keep removing zero-cost flat faces past the target. Only applies when decimation runs, i.e. when @@ -87,6 +94,9 @@ struct PipelineSettings // Stage name and a fraction within it. Returning false cancels the run. using PipelineProgressFn = std::function; +// Colour class of a point of the surface (a palette index, -1 for none), for the decimation's +// colour-boundary creases. Only consulted when the mesh is over budget. +using ColorSampleFn = std::function; struct PipelineResult { @@ -105,7 +115,7 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, const PipelineSettings &settings, const DisplaceBounds &bounds, PipelineMode mode, const std::vector &face_excluded = {}, const PipelineProgressFn &on_progress = {}, - BakeStageRecorder *debug = nullptr); + BakeStageRecorder *debug = nullptr, const ColorSampleFn &color_sample = {}); // Snap anything that ended below the model's original bottom back up to it. void clamp_below_bottom(TriSoup &geometry, float bottom_z); diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index cfc75c286f..a81d8b3c21 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -277,18 +278,26 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer col.bytes_per_pixel < 3) return result; - 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); + const size_t cols = size_t(col.cols), rows = size_t(col.rows), n = cols * rows; + const size_t bpp = size_t(col.bytes_per_pixel); + const size_t stride = col.buf.size() / rows; + result.width = int(cols); + result.height = int(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)); + // decode_colored_png() fills its buffer bottom-up (its other callers hand the rows to + // OpenGL, which wants them that way); a height map is top-down, like decode_png()'s grey + // output, so a colour image has to read the same way up as a grey copy of itself. + for (size_t y = 0; y < rows; ++y) { + const uint8_t *src = col.buf.data() + (rows - 1 - y) * stride; + for (size_t x = 0; x < cols; ++x) { + const size_t i = y * cols + x; + const uint8_t r = src[x * bpp], g = src[x * bpp + 1], b = src[x * 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)); + } } } @@ -386,6 +395,24 @@ TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer) else if (out.sharp_fraction > 0.05f || out.mean_gradient > 20.f) out.pixels_per_edge = 1.5f; else if (out.mean_gradient > 8.f) out.pixels_per_edge = 2.5f; else out.pixels_per_edge = 4.f; + + // Colour spread: a coarse histogram (8 levels per channel, 64 levels for a grey image) and + // the share of the eight fullest bins. Tiles, logos and camouflage put nearly everything in a + // handful of bins even with some texture noise; a photograph spreads across hundreds. + std::vector bins(size_t(8 * 8 * 8), 0); + const size_t npx = size_t(w) * size_t(h); + if (tex.has_color()) + for (size_t i = 0; i < npx; ++i) + ++bins[size_t(tex.rgb[i * 3] >> 5) * 64 + size_t(tex.rgb[i * 3 + 1] >> 5) * 8 + size_t(tex.rgb[i * 3 + 2] >> 5)]; + else + for (size_t i = 0; i < npx; ++i) + ++bins[size_t(tex.pixels[i] >> 2) * 8]; // 64 grey levels, spread over distinct bins + std::partial_sort(bins.begin(), bins.begin() + 8, bins.end(), std::greater()); + uint64_t top = 0; + for (int i = 0; i < 8; ++i) + top += bins[size_t(i)]; + out.flat_share = float(double(top) / double(npx)); + out.flat_colors = out.flat_share >= 0.85f; } std::lock_guard lock(g_texture_detail_cache.mutex); auto &entries = g_texture_detail_cache.entries; @@ -399,60 +426,40 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set const std::vector &layers, const Transform3d &volume_to_world) { - // BumpMesh's smart resolution, the numbers included: equilateral-cover triangle density, a 16 M - // triangle refinement cap taken at 75 %, a 0.5 mm reference relief for the budget. - constexpr double TRIS_PER_AREA = 2.309, CAP_TRIANGLES = 16e6 * 0.75; - constexpr double EDGE_MIN = 0.05, EDGE_MAX = 5.0; - constexpr double BUDGET_MIN = 10e3, BUDGET_MAX = 2000e3, REF_DEPTH = 0.5, MIN_DEPTH = 0.1; + // bumpmesh.com's defaults on model load: edge = diagonal / 250 in [0.05, 5] mm, budget 750 k. A + // texture-driven variant (BumpMesh's smart resolution) was measured to give better walls on step + // textures at 2-10x the bake time and up to 2 M output triangles; the user preferred the site's + // defaults. The texel size and sharpness are still reported for the panel. + constexpr double EDGE_MIN = 0.05, EDGE_MAX = 5.0, DIAG_DIVISOR = 250.0; + constexpr int BUDGET_K = 750; V2Resolution out; - // The finest layer decides: the smallest detail edge (texel x pixels per edge) across the layers. - double detail_edge = std::numeric_limits::max(), depth = 0.0; + if (mesh.vertices.empty()) + return out; for (const TextureDisplacementLayer &layer : layers) { if (layer.empty() || layer.tiling_scale <= 0.f) continue; const DecodedHeightTexture &tex = decode_height_texture(layer); if (tex.width <= 0) continue; - const TextureDetail detail = analyze_texture_detail(layer); - const double texel = double(layer.tiling_scale) / double(tex.width); - const double edge = texel * double(detail.pixels_per_edge); - if (edge < detail_edge) { - detail_edge = edge; - out.texel_mm = float(texel); - out.pixels_per_edge = detail.pixels_per_edge; - depth = std::abs(double(layer.depth_mm)); + const float texel = layer.tiling_scale / float(tex.width); + if (out.texel_mm <= 0.f || texel < out.texel_mm) { + out.texel_mm = texel; + out.pixels_per_edge = analyze_texture_detail(layer).pixels_per_edge; } } - if (out.texel_mm <= 0.f || mesh.vertices.empty()) - return out; - - // Surface area and diagonal in world mm: the tile is in world mm and the pipeline refines there. - double area = 0.0; - Vec3d bmin = Vec3d::Constant(std::numeric_limits::max()), bmax = -bmin; - std::vector world(mesh.vertices.size()); - for (size_t i = 0; i < world.size(); ++i) { - world[i] = volume_to_world * mesh.vertices[i].cast(); - bmin = bmin.cwiseMin(world[i]); - bmax = bmax.cwiseMax(world[i]); + Vec3d bmin = Vec3d::Constant(std::numeric_limits::max()), bmax = -bmin; + for (const Vec3f &v : mesh.vertices) { + const Vec3d w = volume_to_world * v.cast(); + bmin = bmin.cwiseMin(w); + bmax = bmax.cwiseMax(w); } - for (const stl_triangle_vertex_indices &t : mesh.indices) - area += 0.5 * (world[size_t(t[1])] - world[size_t(t[0])]).cross(world[size_t(t[2])] - world[size_t(t[0])]).norm(); const double diag = (bmax - bmin).norm(); - - const double budget_edge = std::sqrt(TRIS_PER_AREA * area / CAP_TRIANGLES); - double edge = std::max(detail_edge, budget_edge); - out.budget_bound = budget_edge > detail_edge; - const double hi = std::max(EDGE_MIN, std::min(EDGE_MAX, diag / 50.0)); - edge = std::clamp(edge, EDGE_MIN, hi); - edge = std::max(EDGE_MIN, std::ceil(edge * 100.0) / 100.0); // up, so the cap holds - out.edge_mm = float(edge); - - const double depth_scale = std::sqrt(REF_DEPTH / std::max(depth, MIN_DEPTH)); - const double target_edge = double(out.pixels_per_edge) * double(out.texel_mm) * depth_scale; - const double raw = TRIS_PER_AREA * area / (target_edge * target_edge); - const double stepped = std::round(raw / 10e3) * 10e3; - out.budget_k = int(std::clamp(stepped, BUDGET_MIN, BUDGET_MAX) / 1000.0); + double edge = std::clamp(diag / DIAG_DIVISOR, EDGE_MIN, EDGE_MAX); + edge = std::max(EDGE_MIN, std::ceil(edge * 100.0) / 100.0); + out.edge_mm = float(edge); + out.budget_k = BUDGET_K; + out.budget_bound = false; return out; } @@ -917,6 +924,9 @@ PatchUnwrap compute_patch_unwrap(const indexed_triangle_set &patch, float seam_a std::vector uvs; std::vector to_patch; // chart vertex -> patch vertex std::vector indices; // chart-local + // Parallel to `indices`: the patch triangle each one came from. compact_patch_with_map() keeps + // the patch's triangle count *and* order, so a compact face index is already a patch face index. + std::vector faces; Vec2f min = Vec2f::Zero(); Vec2f size = Vec2f::Zero(); }; @@ -950,6 +960,7 @@ PatchUnwrap compute_patch_unwrap(const indexed_triangle_set &patch, float seam_a local_tri[i] = compact_to_local[size_t(cv)]; } chart_mesh.indices.push_back(local_tri); + chart.faces.push_back(int(f)); } if (chart_mesh.indices.empty()) continue; @@ -1043,6 +1054,7 @@ PatchUnwrap compute_patch_unwrap(const indexed_triangle_set &patch, float seam_a result.vertex_chart.insert(result.vertex_chart.end(), chart.uvs.size(), c); for (const stl_triangle_vertex_indices &tri : chart.indices) result.indices.emplace_back(tri[0] + base, tri[1] + base, tri[2] + base); + result.source_face.insert(result.source_face.end(), chart.faces.begin(), chart.faces.end()); if (!chart.uvs.empty()) { Vec2f sum = Vec2f::Zero(); @@ -1509,6 +1521,41 @@ std::vector compute_lscm_uvs(const indexed_triangle_set &patch, const Tex return per_vertex; } +std::vector compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer) +{ + // Padding 0 and the layer's own seam angle/edges, exactly as compute_lscm_uvs() does - the two must + // unwrap identically or a hand placement would land in one place on screen and another in the bake. + const PatchUnwrap unwrap = compute_patch_unwrap(patch, layer.lscm_seam_angle_deg, 0.f, layer.lscm_seam_edges); + if (unwrap.empty() || unwrap.source_face.size() != unwrap.indices.size()) + return {}; + + PatchUnwrap edited_unwrap = unwrap; + apply_lscm_uv_overrides(edited_unwrap, layer.lscm_uv_overrides); + + // No first-copy-wins collapse here: the unwrap's triangles are already per chart, so each corner + // simply takes its own chart's copy. A triangle the unwrap dropped (a sliver a chart rejected) keeps + // the zero it was initialised with; the callers treat that as "no placement" the same way they treat + // an empty result. + std::vector corner(patch.indices.size() * 3, Vec2f::Zero()); + for (size_t t = 0; t < edited_unwrap.indices.size(); ++t) { + const int f = edited_unwrap.source_face[t]; + if (f < 0 || size_t(f) >= patch.indices.size()) + continue; + const stl_triangle_vertex_indices &tri = edited_unwrap.indices[t]; + for (int k = 0; k < 3; ++k) { + const int uvi = tri[k]; + if (uvi < 0 || size_t(uvi) >= edited_unwrap.uvs.size()) + continue; + // The island transform is taken against the *unedited* unwrap, whose chart_centroid is the + // pivot the UV editor rotates about - same as compute_lscm_uvs(). + corner[size_t(f) * 3 + size_t(k)] = apply_island_transform(edited_unwrap.uvs[size_t(uvi)], + edited_unwrap.vertex_chart[size_t(uvi)], + unwrap, layer.islands); + } + } + return corner; +} + namespace { // apply_uv_transform()'s per-layer constants, worked out once. Triplanar sampling runs the transform // three times per point, and recomputing the rotation's cos/sin and the tiling reciprocal on every one @@ -1820,7 +1867,7 @@ bool compute_layer_paint_anchor(const indexed_triangle_set &b // these once, up front, and every layer both projects and displaces along them - so a vertex // covered by several layers is pushed along one single, well-defined direction rather than along // whatever direction the surface happened to be pointing partway through the stack. -static std::vector texture_displacement_vertex_normals(const indexed_triangle_set &its) +std::vector texture_displacement_vertex_normals(const indexed_triangle_set &its) { std::vector normals(its.vertices.size(), Vec3f::Zero()); for (const stl_triangle_vertex_indices &tri : its.indices) { @@ -1943,6 +1990,98 @@ void despeckle_triangle_colors(const indexed_triangle_set &mesh, std::vector &color, float min_area_mm2) +{ + const size_t n = mesh.indices.size(); + if (min_area_mm2 <= 0.f || color.size() != n) + return; + const std::vector neighbors = its_face_neighbors(mesh); + if (neighbors.size() != n) + return; + + const auto edge_length = [&mesh](size_t f, int e) { + const stl_triangle_vertex_indices &t = mesh.indices[f]; + return (mesh.vertices[size_t(t[(e + 1) % 3])] - mesh.vertices[size_t(t[e])]).norm(); + }; + + // Connected components of equal colour: `faces` lists every coloured face, component by + // component, `start` delimits them. Uncoloured faces (-1) belong to no component and block the + // flood, so a region never grows across the paint's border. + std::vector component(n, -1); + std::vector faces; + std::vector start; + std::vector area; + std::vector stack; + faces.reserve(n); + for (size_t seed = 0; seed < n; ++seed) { + if (color[seed] < 0 || component[seed] >= 0) + continue; + const int c = color[seed]; + const int id = int(area.size()); + start.push_back(faces.size()); + area.push_back(0.f); + component[seed] = id; + stack.push_back(int(seed)); + while (!stack.empty()) { + const size_t f = size_t(stack.back()); + stack.pop_back(); + faces.push_back(int(f)); + const stl_triangle_vertex_indices &t = mesh.indices[f]; + const Vec3f &a = mesh.vertices[size_t(t[0])], &b = mesh.vertices[size_t(t[1])], &cv = mesh.vertices[size_t(t[2])]; + area[size_t(id)] += 0.5f * (b - a).cross(cv - a).norm(); + for (int e = 0; e < 3; ++e) { + const int nb = neighbors[f][e]; + if (nb < 0 || size_t(nb) >= n || component[size_t(nb)] >= 0 || color[size_t(nb)] != c) + continue; + component[size_t(nb)] = id; + stack.push_back(nb); + } + } + } + start.push_back(faces.size()); + + // Smallest first, so that when a small island borders a slightly larger one the larger one has + // not yet moved and the small one joins whatever the two of them sit in; the larger one then + // reads that colour in turn. + std::vector order; + for (int id = 0; id < int(area.size()); ++id) + if (area[size_t(id)] < min_area_mm2) + order.push_back(id); + std::sort(order.begin(), order.end(), [&area](int l, int r) { return area[size_t(l)] < area[size_t(r)]; }); + + std::vector> weights; // neighbouring colour -> shared edge length + for (const int id : order) { + const size_t begin = start[size_t(id)], end = start[size_t(id) + 1]; + const int own = color[size_t(faces[begin])]; + weights.clear(); + for (size_t k = begin; k < end; ++k) { + const size_t f = size_t(faces[k]); + for (int e = 0; e < 3; ++e) { + const int nb = neighbors[f][e]; + if (nb < 0 || size_t(nb) >= n) + continue; + const int c = color[size_t(nb)]; // read now: an earlier merge may have recoloured it + if (c < 0 || c == own) + continue; + const float len = edge_length(f, e); + auto it = std::find_if(weights.begin(), weights.end(), [c](const std::pair &w) { return w.first == c; }); + if (it == weights.end()) + weights.emplace_back(c, len); + else + it->second += len; + } + } + if (weights.empty()) + continue; // bordered only by uncoloured faces (or nothing): stays + const int target = std::max_element(weights.begin(), weights.end(), + [](const std::pair &l, const std::pair &r) { + return l.second < r.second; + })->first; + for (size_t k = begin; k < end; ++k) + color[size_t(faces[k])] = target; + } +} + namespace { // Wired to the same layer stack via make_combined_displacement_sampler(), so layers, blend modes and @@ -1960,8 +2099,13 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set if (!combined) return mesh; // nothing decodable to displace with - // Unpainted triangles are excluded, keeping them out of refinement and pinned thereafter. + // Unpainted triangles are excluded, keeping them out of refinement and pinned thereafter. The + // paint is finer than that, though: a brush stroke splits a source triangle into pieces, and only + // some of them are painted. `painted_pieces` keeps every layer's painted pieces (they lie in the + // source surface) so the refined faces can be tested against the paint itself, not against the + // source triangle they came from. std::vector excluded(mesh.indices.size(), 1); + indexed_triangle_set painted_pieces; { const TriangleMesh selector_mesh(mesh); TriangleSelector selector(selector_mesh); @@ -1977,11 +2121,49 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set for (const int src : piece_src) if (src >= 0 && size_t(src) < excluded.size()) excluded[size_t(src)] = 0; + // `patch` carries the whole mesh's vertex array (see compact_patch_with_map()); append + // only what its pieces reference. + std::vector unused; + const indexed_triangle_set compact = compact_patch_with_map(patch, unused); + const int offset = int(painted_pieces.vertices.size()); + painted_pieces.vertices.insert(painted_pieces.vertices.end(), compact.vertices.begin(), compact.vertices.end()); + for (const stl_triangle_vertex_indices &t : compact.indices) + painted_pieces.indices.emplace_back(t[0] + offset, t[1] + offset, t[2] + offset); } } - if (std::all_of(excluded.begin(), excluded.end(), [](uint8_t e) { return e != 0; })) + if (std::all_of(excluded.begin(), excluded.end(), [](uint8_t e) { return e != 0; }) || painted_pieces.indices.empty()) return mesh; // nothing painted + // Distance to the nearest painted piece. Built once here; the tree is read-only afterwards, so + // the parallel stages below share it freely. + const AABBTreeIndirect::Tree3f painted_tree = + AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(painted_pieces.vertices, painted_pieces.indices); + // `foot`/`normal`, when asked for, are the closest point on the painted pieces and that piece's + // normal. The pieces lie in the *undisplaced* surface, so for a displaced point those two are the + // base position and normal underneath it - the frame colour has to be projected in (see below). + const auto painted_closest = [&painted_pieces, &painted_tree](const Vec3f &p, Vec3f *foot, Vec3f *normal) { + size_t hit = 0; + Vec3f hit_point; + const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + painted_pieces.vertices, painted_pieces.indices, painted_tree, p, hit, hit_point); + if (foot != nullptr) + *foot = hit_point; + if (normal != nullptr && hit < painted_pieces.indices.size()) { + const stl_triangle_vertex_indices &t = painted_pieces.indices[hit]; + const Vec3f &a = painted_pieces.vertices[size_t(t[0])], &b = painted_pieces.vertices[size_t(t[1])], + &c = painted_pieces.vertices[size_t(t[2])]; + Vec3f n = (b - a).cross(c - a); + const float l = n.norm(); + *normal = (l > 0.f) ? Vec3f(n / l) : Vec3f::UnitZ(); + } + return d2; + }; + const auto painted_dist2 = [&painted_closest](const Vec3f &p) { return painted_closest(p, nullptr, nullptr); }; + // Before displacement the queried centroids lie in the same surface as the pieces, so anything + // beyond a hair is genuinely outside the paint. + constexpr float paint_tol = 0.05f; + const auto painted_at = [&painted_dist2](const Vec3f &p) { return painted_dist2(p) < paint_tol * paint_tol; }; + // "Auto" resolution and budget (0 and -1) resolve here, from the texture and the model - the mesh // is already in world mm at this point, so no transform is needed. const bool auto_edge = options.v2_refine_mm <= 0.f, auto_budget = options.v2_max_triangles_k < 0; @@ -2012,6 +2194,30 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set // of vertices that samples both faces' patterns half and half otherwise comes out as a row of // notches, since it matches neither face. settings.displace.blend_normal_smoothing = 32; + // Refined faces are asked against the paint itself, so a stroke narrower than a source triangle + // moves only what it covers. + // Only when some included source triangle is painted in part: the pieces then cover less area + // than the triangles they came from. Whole-triangle paint (the usual case, and every bench) has + // nothing to gain from a query per refined face. + { + const auto area_of = [](const indexed_triangle_set &its) { + double a = 0.0; + for (const stl_triangle_vertex_indices &t : its.indices) + a += 0.5 * double((its.vertices[size_t(t[1])] - its.vertices[size_t(t[0])]) + .cross(its.vertices[size_t(t[2])] - its.vertices[size_t(t[0])]).norm()); + return a; + }; + double included_area = 0.0; + for (size_t t = 0; t < mesh.indices.size(); ++t) + if (excluded[t] == 0) { + const stl_triangle_vertex_indices &f = mesh.indices[t]; + included_area += 0.5 * double((mesh.vertices[size_t(f[1])] - mesh.vertices[size_t(f[0])]) + .cross(mesh.vertices[size_t(f[2])] - mesh.vertices[size_t(f[0])]).norm()); + } + const double pieces_area = area_of(painted_pieces); + if (pieces_area < included_area * (1.0 - 1e-4)) + settings.painted = painted_at; + } TextureBake::DisplaceBounds bounds; bounds.min = bounds.max = mesh.vertices.empty() ? Vec3f::Zero() : mesh.vertices.front(); @@ -2039,6 +2245,26 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set // 0 means no simplification, i.e. Bake mode. const TextureBake::PipelineMode mode = settings.max_triangles > 0 ? TextureBake::PipelineMode::Export : TextureBake::PipelineMode::Bake; + // Colour, when asked for. The sampler is built now so the simplification can see the colour + // boundaries: a simplified triangle must not span two colours, or its one colour is wrong over + // part of it (half a tile in the neighbour's colour, a tile edge that wanders). + const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize); + const ColorFieldSampler color_sampler = + want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure) : ColorFieldSampler{}; + // + // The *palette* index, not the printed filament. The decimation treats any edge whose two faces + // differ as a crease (TextureBakeDecimate.cpp), so it must only ever see where the **perceived** + // colour changes - which is exactly what ColorResolveFn's own contract says the interleaving may + // never be fed into. Handing it the resolved filament made every Z band boundary a crease: on an + // upright wall that is one crease per band, so the collapse ran along those lines and left a stack + // of horizontal slivers, each printing in a single filament. Those were the horizontal colour + // lines in the baked result, and they also spent the triangle budget drawing a pattern the eye is + // meant to blend away. Faces the paint excludes are skipped by the pipeline itself. + const TextureBake::ColorSampleFn color_sample = + color_sampler ? TextureBake::ColorSampleFn([&color_sampler](const Vec3f &p, const Vec3f &n) { + return color_sampler(p, n); + }) + : TextureBake::ColorSampleFn{}; // The pipeline works on `oriented`, whose winding was reversed above for a mirrored placement, so // the stages it records are wound the same way. Note where they start and turn the whole range // back afterwards, exactly as the result itself is turned back below. @@ -2048,7 +2274,7 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set [&progress](const char *, double f) { return !progress || progress(std::clamp(int(f * 100.0), 0, 99)); }, - debug); + debug, color_sample); if (debug != nullptr && flip_normals) debug->rebase(debug_mark, nullptr, /* flip_winding */ true); if (result.canceled || result.geometry.empty()) @@ -2063,44 +2289,56 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set // Colour, per output triangle. The topology is new, so unlike the classic path there is no base // triangle to inherit a colour from: each output triangle samples the colour stack at its own - // centroid, and takes colour only where the base surface under it is painted - found by the nearest - // base triangle, which is never more than the relief depth away. Then the same despeckle and + // centroid, and takes colour only where the paint is - measured against the painted pieces, which + // an output centroid is never further from than the relief depth. Then the same despeckle and // filament resolution as the classic path. - if (color != nullptr && color->out_triangle != nullptr && bool(color->quantize)) { - std::vector out_color(out.indices.size(), 0); - const ColorFieldSampler sampler = make_combined_color_sampler(mesh, layers, facets_data, color->quantize); + if (want_color) { + std::vector out_color(out.indices.size(), 0); + const ColorFieldSampler &sampler = color_sampler; if (sampler) { const bool all_painted = std::none_of(excluded.begin(), excluded.end(), [](uint8_t e) { return e != 0; }); - AABBTreeIndirect::Tree3f tree; - if (!all_painted) - tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(mesh.vertices, mesh.indices); + float max_depth = 0.f; + for (const TextureDisplacementLayer &layer : layers) + max_depth = std::max(max_depth, std::abs(layer.depth_mm)); + const float relief_tol = max_depth + paint_tol; std::vector palette(out.indices.size(), -1); tbb::parallel_for(tbb::blocked_range(0, out.indices.size()), [&](const tbb::blocked_range &r) { for (size_t i = r.begin(); i < r.end(); ++i) { const stl_triangle_vertex_indices &t = out.indices[i]; const Vec3f &a = out.vertices[size_t(t[0])], &b = out.vertices[size_t(t[1])], &c = out.vertices[size_t(t[2])]; const Vec3f centroid = (a + b + c) / 3.f; - if (!all_painted) { - size_t hit = 0; - Vec3f hit_point; - AABBTreeIndirect::squared_distance_to_indexed_triangle_set(mesh.vertices, mesh.indices, tree, - centroid, hit, hit_point); - if (hit >= excluded.size() || excluded[hit] != 0) - continue; - } - Vec3f n = (b - a).cross(c - a); - const float l = n.norm(); - n = (l > 0.f) ? Vec3f(n / l) : Vec3f::UnitZ(); - palette[i] = sampler(centroid, n); + // Sample on the *base* surface under this face, not on the relief. The projection + // is a function of position and normal, and the displacement has moved both: the + // triplanar blend weights three axis planes by |n|^4, so a face tilted ~45 degrees + // away from its base normal reads the image half through an unrelated plane. The + // patch border is a ring of exactly such faces - the relief ramps to zero there - + // which is the coloured fringe around the border, and the steep interior slopes + // streak for the same reason. The classic path samples the base patch for this very + // reason; this path was the inconsistent one. + Vec3f foot = centroid, base_n = Vec3f::UnitZ(); + const float d2 = painted_closest(centroid, &foot, &base_n); + if (!all_painted && d2 >= relief_tol * relief_tol) + continue; + palette[i] = sampler(foot, base_n); } }); - despeckle_triangle_colors(out, palette, color->despeckle_passes); + // The despeckle filter is for a fine, uniform mesh, where one facet flipping colour is + // noise. A simplified mesh is neither: its triangles are as large as the colour regions + // themselves and already end on the colour boundaries, so a majority vote among three + // neighbours would repaint whole features. Bake mode (no simplification) keeps it. + const bool simplified = result.face_parent_id.empty(); + despeckle_triangle_colors(out, palette, simplified ? 0 : color->despeckle_passes); + merge_small_color_regions(out, palette, color->min_color_region_mm2); for (size_t i = 0; i < out.indices.size(); ++i) { if (palette[i] < 0) continue; const stl_triangle_vertex_indices &t = out.indices[i]; - const Vec3f centroid = (out.vertices[size_t(t[0])] + out.vertices[size_t(t[1])] + out.vertices[size_t(t[2])]) / 3.f; - const int filament = color->resolve ? color->resolve(palette[i], centroid) : palette[i]; + const Vec3f &a = out.vertices[size_t(t[0])], &b = out.vertices[size_t(t[1])], &c = out.vertices[size_t(t[2])]; + const Vec3f centroid = (a + b + c) / 3.f; + Vec3f normal = (b - a).cross(c - a); + const float nl = normal.norm(); + normal = (nl > 0.f) ? Vec3f(normal / nl) : Vec3f::UnitZ(); + const int filament = color->resolve ? color->resolve(palette[i], centroid, normal) : palette[i]; if (filament >= 0) out_color[i] = uint8_t(std::min(filament + 1, 255)); } @@ -2278,6 +2516,10 @@ static indexed_triangle_set build_texture_displacement_in_place( selector_dirty = true; const bool color_this_layer = want_color && layer->color_enabled; + // A flat-colour image is matched against the filaments alone (see TextureColorRequest). + const ColorQuantizeFn &layer_quantize = + (color_this_layer && color->quantize_pure && analyze_texture_detail(*layer).flat_colors) ? color->quantize_pure + : color->quantize; std::vector 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); @@ -2302,34 +2544,11 @@ static indexed_triangle_set build_texture_displacement_in_place( } const bool pin_boundary = !options.displace_border; - // Only the Cylindrical/Spherical methods need these; Triplanar blends each vertex's own - // normal and LSCM solves the patch globally. - Vec3f average_normal = Vec3f::Zero(); - Vec3f patch_centroid = Vec3f::Zero(); - int patch_vertex_count = 0; - for (const stl_triangle_vertex_indices &tri : patch.indices) - for (int i = 0; i < 3; ++i) { - const int vi = tri[i]; - patch_centroid += patch.vertices[size_t(vi)]; - ++patch_vertex_count; - // A brush stroke that split a triangle appends new vertices past the base mesh's own - // (see the get_facets_strict() note above); vertex_normals is sized to the base mesh, - // so those split indices must be skipped here or this reads out of bounds. The main - // displacement loop below guards the same way. - if (vi < int(vertex_normals.size())) - average_normal += vertex_normals[size_t(vi)]; - } - average_normal = (average_normal.norm() > 1e-8f) ? Vec3f(average_normal.normalized()) : Vec3f::UnitZ(); - patch_centroid = (patch_vertex_count > 0) ? Vec3f(patch_centroid / float(patch_vertex_count)) : Vec3f::Zero(); - - // Cylinder axis auto-picked as the world axis *least* aligned with the average normal - // (perpendicular to the outward radial normal, as a cylinder's own axis would be). - Vec3f patch_axis = Vec3f::UnitZ(); - const Vec3f an = average_normal.cwiseAbs(); - if (an.x() <= an.y() && an.x() <= an.z()) - patch_axis = Vec3f::UnitX(); - else if (an.y() <= an.x() && an.y() <= an.z()) - patch_axis = Vec3f::UnitY(); + // Only the Cylindrical/Spherical methods need the centroid and axis; Triplanar blends each + // vertex's own normal and LSCM solves the patch globally. average_normal is also the fallback + // normal the colour pass below uses for a degenerate triangle. + Vec3f average_normal, patch_centroid, patch_axis; + texture_displacement_patch_frame(patch, vertex_normals, patch_centroid, patch_axis, average_normal); // A real unwrap of the whole patch, computed once here rather than per vertex - it is a // per-chart solve over the whole patch, not a per-point formula. Cached, so repeating this @@ -2337,6 +2556,15 @@ static indexed_triangle_set build_texture_displacement_in_place( const std::vector lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? compute_lscm_uvs(patch, *layer) : std::vector{}; + // The colour pass below samples per *triangle*, so it takes the per-corner unwrap instead: the + // per-vertex collapse above would hand a triangle at a seam the island layout did not join its + // neighbour's placement, painting one triangle per face from the wrong part of the texture. + // (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.) + const std::vector lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? + compute_lscm_corner_uvs(patch, *layer) : + std::vector{}; + const bool corner_uv_ok = !lscm_corner_uvs.empty() && + lscm_corner_uvs.size() == patch.indices.size() * 3; // Colour, if this layer carries any. Area-weighted over each base triangle's *painted* part, // so a triangle the brush only clipped a corner off takes the colour of that corner rather @@ -2371,7 +2599,11 @@ static indexed_triangle_set build_texture_displacement_in_place( 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()) + if (!have_uv) + continue; + if (corner_uv_ok) + uv += lscm_corner_uvs[j * 3 + size_t(k)]; + else if (size_t(vi) < lscm_uvs.size()) uv += lscm_uvs[size_t(vi)]; else have_uv = false; @@ -2388,7 +2620,7 @@ static indexed_triangle_set build_texture_displacement_in_place( } 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]); + const int idx = layer_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) @@ -2548,15 +2780,19 @@ static indexed_triangle_set build_texture_displacement_in_place( // 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); + merge_small_color_regions(mesh, triangle_palette, color->min_color_region_mm2); std::vector 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) + const Vec3f &a = mesh.vertices[size_t(t[0])], &b = mesh.vertices[size_t(t[1])], &c = mesh.vertices[size_t(t[2])]; + const Vec3f centroid = (a + b + c) / 3.f; + Vec3f normal = (b - a).cross(c - a); + const float nl = normal.norm(); + normal = (nl > 0.f) ? Vec3f(normal / nl) : Vec3f::UnitZ(); + const int filament = color->resolve ? color->resolve(triangle_palette[i], centroid, normal) : triangle_palette[i]; if (filament >= 0) out_color[i] = uint8_t(std::min(filament + 1, 255)); @@ -2615,6 +2851,32 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set return out; } +void texture_displacement_patch_frame(const indexed_triangle_set &patch, const std::vector &vertex_normals, + Vec3f ¢er, Vec3f &axis, Vec3f &average_normal) +{ + Vec3f normal_sum = Vec3f::Zero(); + Vec3f centroid_sum = Vec3f::Zero(); + int count = 0; + for (const stl_triangle_vertex_indices &tri : patch.indices) + for (int i = 0; i < 3; ++i) { + const int vi = tri[i]; + centroid_sum += patch.vertices[size_t(vi)]; + ++count; + // A brush stroke that split a triangle appends new vertices past the base mesh's own, and + // vertex_normals is sized to the base mesh, so those indices must be skipped here. + if (vi < int(vertex_normals.size())) + normal_sum += vertex_normals[size_t(vi)]; + } + average_normal = (normal_sum.norm() > 1e-8f) ? Vec3f(normal_sum.normalized()) : Vec3f::UnitZ(); + center = (count > 0) ? Vec3f(centroid_sum / float(count)) : Vec3f::Zero(); + + // The world axis least aligned with the average normal - perpendicular to the outward radial + // normal, as a cylinder's own axis would be. + const Vec3f an = average_normal.cwiseAbs(); + axis = (an.x() <= an.y() && an.x() <= an.z()) ? Vec3f::UnitX() : + (an.y() <= an.x() && an.y() <= an.z()) ? Vec3f::UnitY() : Vec3f::UnitZ(); +} + Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world) { // World orientation and scale, but the origin moved to where the volume's own origin sits: the @@ -2704,11 +2966,89 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector 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. +// An unwrap turned into something a *point* sampler can use. LSCM has no formula from position to +// uv - it is a per-triangle map - so a point is placed on the painted patch (the nearest patch +// triangle, and its barycentric coordinates there) and the uv is interpolated from that triangle's own +// per-corner uvs. Exact for a point on the base surface, which is where both samplers are queried: the +// displacement samples refined positions before moving them, and the colour pass samples the foot +// point on the painted pieces. +struct LscmLookup { + indexed_triangle_set patch; // the layer's painted patch, as the unwrap was solved on + AABBTreeIndirect::Tree3f tree; + std::vector corner; // compute_lscm_corner_uvs(patch, layer) + + // False when `pos` is not on this layer's patch (farther than `tol`): there is no uv there, so the + // layer contributes nothing - the same as a non-tiled texture outside its placement. + bool uv_at(const Vec3f &pos, float tol, Vec2f &uv) const + { + size_t hit = 0; + Vec3f foot; + const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(patch.vertices, patch.indices, tree, + pos, hit, foot); + if (d2 < 0.f || d2 > tol * tol || hit >= patch.indices.size()) + return false; + const stl_triangle_vertex_indices &t = patch.indices[hit]; + const Vec3f &a = patch.vertices[size_t(t[0])], &b = patch.vertices[size_t(t[1])], &c = patch.vertices[size_t(t[2])]; + const Vec3f e0 = b - a, e1 = c - a, ep = foot - a; + const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1); + const float den = d00 * d11 - d01 * d01; + float w1 = 1.f / 3.f, w2 = 1.f / 3.f; // a degenerate triangle takes its centroid's uv + if (std::abs(den) > 1e-20f) { + w1 = (d11 * dp0 - d01 * dp1) / den; + w2 = (d00 * dp1 - d01 * dp0) / den; + } + const Vec2f *c3 = &corner[hit * 3]; + uv = (1.f - w1 - w2) * c3[0] + w1 * c3[1] + w2 * c3[2]; + return true; + } +}; + +// A layer's painted patch as a point-in-region test. Every layer is sampled on its own paint only - the +// analytic projections included: unlike an unwrap they are defined everywhere, so without this every layer's +// relief was stacked over every other layer's painted area, and the top layer's texture showed on all of them. +struct PatchRegion { + indexed_triangle_set patch; + AABBTreeIndirect::Tree3f tree; + + bool contains(const Vec3f &pos, float tol) const + { + size_t hit = 0; + Vec3f foot; + const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(patch.vertices, patch.indices, tree, + pos, hit, foot); + return d2 >= 0.f && d2 <= tol * tol; + } +}; + 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 + // Unwrap layers only; null when the unwrap failed, in which case sampling falls through to the + // layer's analytic fallback exactly as build_texture_displacement()'s classic path does. + std::shared_ptr lscm; + // The painted patch of a layer without an unwrap lookup (the unwrap's own lookup already stops at its patch). + // Null when the paint covers the whole mesh, where every point is on it. + std::shared_ptr region; + + // The uv to hand sample_layer_height()/sample_layer_color(): nullptr for every analytic projection + // (they project `pos` themselves). False means `pos` is off this layer's paint and the layer must be + // skipped. + bool lscm_uv(const Vec3f &pos, Vec2f &uv, const Vec2f *&out) const + { + out = nullptr; + // Queries lie on the base surface, so anything beyond a hair is off this layer's patch. + constexpr float ON_PATCH_TOL = 0.05f; + if (region && !region->contains(pos, ON_PATCH_TOL)) + return false; + if (!lscm) + return true; + if (!lscm->uv_at(pos, ON_PATCH_TOL, uv)) + return false; + out = &uv; + return true; + } }; // Shared by both point samplers, so the height field and the colour field can never disagree about @@ -2731,10 +3071,12 @@ std::shared_ptr> prepare_sampleable_layers( const std::vector vertex_normals = texture_displacement_vertex_normals(base_mesh); const TriangleMesh selector_mesh(base_mesh); + const float mesh_area = area_3d(base_mesh); 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) + // Unwrap layers used to be skipped here ("no per-point UV"). That made the default pipeline + // bake an unwrap layer as nothing at all - and since the job then clears the baked layers' + // paint, the painted region simply vanished. They get an LscmLookup below instead. if (need_color && !layer->color_enabled) continue; const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)]; @@ -2750,30 +3092,34 @@ std::shared_ptr> prepare_sampleable_layers( if (patch.indices.empty()) continue; - // Patch centroid + cylinder axis, computed exactly as build_texture_displacement() does, so a + // Patch centroid + cylinder axis, shared with build_texture_displacement() so a // Cylindrical/Spherical layer's detach criterion matches the geometry the bake will produce. - Vec3f average_normal = Vec3f::Zero(); - Vec3f centroid = Vec3f::Zero(); - int count = 0; - for (const stl_triangle_vertex_indices &tri : patch.indices) - for (int i = 0; i < 3; ++i) { - const int vi = tri[i]; - centroid += patch.vertices[size_t(vi)]; - ++count; - if (vi < int(vertex_normals.size())) - average_normal += vertex_normals[size_t(vi)]; + Vec3f centroid, axis, average_normal; + texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal); + + std::shared_ptr lscm; + if (layer->projection_method == TextureProjectionMethod::LSCM) { + // Solved on the very patch the classic path and the GUI solve it on (same geometry, seam + // angle and edges), so it hits the unwrap cache and lands exactly where the UV editor + // shows it, hand-placed islands and UV edits included. + auto l = std::make_shared(); + l->corner = compute_lscm_corner_uvs(patch, *layer); + if (l->corner.size() == patch.indices.size() * 3) { + l->patch = patch; + l->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(l->patch.vertices, l->patch.indices); + lscm = std::move(l); } - average_normal = (average_normal.norm() > 1e-8f) ? Vec3f(average_normal.normalized()) : Vec3f::UnitZ(); - centroid = (count > 0) ? Vec3f(centroid / float(count)) : Vec3f::Zero(); + } - Vec3f axis = Vec3f::UnitZ(); - const Vec3f an = average_normal.cwiseAbs(); - if (an.x() <= an.y() && an.x() <= an.z()) - axis = Vec3f::UnitX(); - else if (an.y() <= an.x() && an.y() <= an.z()) - axis = Vec3f::UnitY(); + std::shared_ptr region; + if (!lscm && area_3d(patch) < 0.9999f * mesh_area) { + auto r = std::make_shared(); + r->patch = patch; + r->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(r->patch.vertices, r->patch.indices); + region = std::move(r); + } - prepared->push_back({ tex, *layer, centroid, axis }); + prepared->push_back({ tex, *layer, centroid, axis, std::move(lscm), std::move(region) }); } return prepared; } @@ -2782,22 +3128,34 @@ std::shared_ptr> prepare_sampleable_layers( ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, - ColorQuantizeFn quantize) + ColorQuantizeFn quantize, + ColorQuantizeFn quantize_pure) { if (!quantize) return nullptr; auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true); if (prepared->empty()) return nullptr; + // Per layer: a flat-colour image is matched against the filaments alone, when that quantizer + // was supplied; anything else may use the mixes. Decided once here, not per sample. + auto pure = std::make_shared>(prepared->size(), 0); + if (quantize_pure) + for (size_t i = 0; i < prepared->size(); ++i) + (*pure)[i] = analyze_texture_detail((*prepared)[i].layer).flat_colors ? 1 : 0; - return [prepared, quantize = std::move(quantize)](const Vec3f &pos, const Vec3f &normal) -> int { + return [prepared, pure, quantize = std::move(quantize), quantize_pure = std::move(quantize_pure)](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) { + for (size_t i = 0; i < prepared->size(); ++i) { + const PreparedLayer &p = (*prepared)[i]; + Vec2f uv; + const Vec2f *lscm_uv = nullptr; + if (!p.lscm_uv(pos, uv, lscm_uv)) + continue; 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) + if (sample_layer_color(p.tex, p.layer, pos, normal, rgb, p.center, p.axis, lscm_uv)) + if (const int idx = ((*pure)[i] ? quantize_pure : quantize)(rgb); idx >= 0) result = idx; } return result; @@ -2816,7 +3174,11 @@ HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set float total = 0.f; bool any = false; for (const PreparedLayer &p : *prepared) { - const float h = sample_layer_height(p.tex, p.layer, pos, normal, p.center, p.axis, nullptr); + Vec2f uv; + const Vec2f *lscm_uv = nullptr; + if (!p.lscm_uv(pos, uv, lscm_uv)) + continue; // off this unwrap layer's patch: no uv, so no contribution + const float h = sample_layer_height(p.tex, p.layer, pos, normal, p.center, p.axis, lscm_uv); const float sign = p.layer.invert ? -1.f : 1.f; const float signed_h = (h - p.layer.midlevel) * p.layer.depth_mm * sign; // The first (lowest) sampleable layer folds additively; the rest use their own blend mode - @@ -3264,10 +3626,10 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, 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 - // per-point paint test), so the chord test there would chase texture detail on a surface the - // bake is going to leave flat. Length alone is what this band needs - the error it is fixing - // is the size of the triangles spanning the displacement step, not the curvature of anything. + // through detail_error(): the sampler reports no relief off the paint, so across its edge the + // chord test sees a step and would chase it down to the length floor. Length alone is what this + // band needs - the error it is fixing is the size of the triangles spanning the displacement + // step, not the curvature of anything. if ((flags & REFINE_BORDER) && border_sq > 0.f) p = std::max(p, ll / border_sq); return p; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index ea208fbcb3..238422b5fe 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -333,6 +333,10 @@ enum class ColorMixMode : int // 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, + // Per triangle, by its orientation: bands where the surface is upright enough for consecutive + // layers to alternate, the checkerboard where it faces up or down and a layer would be one band. + // The default - a flat-topped part with a mix on top gets no blend at all from bands alone. + Auto = 2, }; // Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next @@ -396,9 +400,11 @@ struct TextureDisplacementOptions // 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. + // more than four colours. Whether a given layer's colours actually use mixes is decided from its + // image (TextureDetail::flat_colors): a texture of flat colours prints in single filaments, a + // photograph or gradient in mixes. Off forces single filaments everywhere. bool color_mix_enabled = true; - ColorMixMode color_mix_mode = ColorMixMode::ZBands; + ColorMixMode color_mix_mode = ColorMixMode::Auto; // 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. @@ -423,17 +429,18 @@ struct TextureDetail float mean_gradient = 0.f; float sharp_fraction = 0.f; float pixels_per_edge = 4.f; + // How much of the image its eight most common colours cover (8 levels per channel), and the + // verdict: a "flat-colour" image (tiles, logos, camouflage) whose colours should each print in a + // single filament, versus a photograph or gradient where interleaved filament mixes pay off. + float flat_share = 0.f; + bool flat_colors = false; }; TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer); -// The default pipeline's automatic resolution: the refinement edge and the simplification budget the -// texture and the model call for, when the options leave them at "auto". -// - edge = texel size (tile / image width, in world mm, over the finest layer) x pixels per edge, -// but no finer than keeps the refinement under a 12 M triangle cap for this surface area, clamped -// to [0.05 mm, min(5 mm, diagonal / 50)] and rounded up to 0.01 mm; -// - budget = the triangle count an edge of that texel size needs over the surface, scaled by the -// relief depth (a gentle relief needs fewer), stepped to 10 k and clamped to [10 k, 2000 k]. -// `edge_mm` is 0 when no layer has a usable texture. +// The default pipeline's automatic resolution and budget, when the options leave them at "auto": +// bumpmesh.com's defaults - edge = the model's world-space diagonal / 250, clamped to [0.05, 5] mm and +// rounded up to 0.01; budget 750 k. The texel size of the finest layer and its sharpness class are +// reported alongside for the panel. `edge_mm` is 0 for an empty mesh. struct V2Resolution { float edge_mm = 0.f; @@ -510,7 +517,7 @@ using ColorQuantizeFn = std::function; // 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; +using ColorResolveFn = std::function; // 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. @@ -529,6 +536,7 @@ struct PrintableColor struct TextureColorSettings { std::vector palette; + std::vector palette_pure; // the filaments alone, for flat-colour images 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 @@ -623,7 +631,12 @@ struct PatchUnwrap std::vector uvs; // one per unwrapped vertex, in mm std::vector source_vertex; // unwrapped vertex -> index into patch.vertices std::vector vertex_chart; // unwrapped vertex -> chart (island) id - std::vector indices; // patch triangles, re-indexed into `uvs` + // The patch's triangles re-indexed into `uvs` - but *grouped by chart*, not left in the patch's + // own order: the charts are flattened one at a time and then concatenated. `source_face` is the + // map back, so anything that needs UVs per triangle corner (as opposed to per vertex) can place + // them against its own triangle list. See compute_lscm_corner_uvs(). + std::vector indices; + std::vector source_face; // unwrapped triangle -> index into patch.indices // Per chart, the centroid of its uvs - the point a TextureIsland's rotation turns about. std::vector chart_centroid; // Edges belonging to exactly one triangle: the outline of each island. Indices into `uvs`. This @@ -675,17 +688,30 @@ bool join_chart_placement(const PatchUnwrap &unwrap, const std::vector> &seam_edges = {}); -// One UV per patch vertex, for displacement. Displacement is inherently per-vertex - a vertex has -// exactly one position, so it can only be pushed out by one height - which means a seam vertex has -// to settle on a single one of its charts' UVs (the first, arbitrarily). That is not a compromise -// in the result: the surface stays watertight either way, since neighbouring vertices each move -// along their own normals and nothing depends on the UVs agreeing across the seam. It is only the -// *display* in the UV editor that needs the duplicated-vertex form above. +// One UV per patch vertex, **for displacement only**. Displacement is inherently per-vertex - a +// vertex has exactly one position, so it can only be pushed out by one height - which means a seam +// vertex has to settle on a single one of its charts' UVs (the first, arbitrarily). That is not a +// compromise in the result: the surface stays watertight either way, since neighbouring vertices +// each move along their own normals and nothing depends on the UVs agreeing across the seam. +// +// Anything that samples or draws per *triangle* must use compute_lscm_corner_uvs() instead. This +// collapse is wrong for those: a triangle at a seam that the island layout did not join gets handed +// a neighbouring island's placement, which showed up as a single skewed triangle per face and as +// every island's texture following the lowest-numbered island when it was dragged. // // Returns an empty vector if the patch has no triangles. Takes the whole layer because it applies // both the layer's seam angle and its hand-placed islands. std::vector compute_lscm_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer); +// Three UVs per patch triangle (corner 0, 1, 2 of triangle i at index 3i..3i+2), in the patch's own +// triangle order. Unlike compute_lscm_uvs() this keeps a seam vertex's separate per-chart copies: a +// triangle belongs to exactly one chart and is given that chart's UVs, which is what every consumer +// that works per triangle rather than per vertex needs - the fast preview's flat mesh, the checker +// overlay and the bake's per-facet colour. +// +// Returns an empty vector if the patch has no triangles or the unwrap carries no source_face map. +std::vector compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer); + // The TextureDisplacementLayer::lscm_uv_overrides key for one unwrapped vertex (an index into PatchUnwrap::uvs). inline int lscm_uv_override_key(int unwrapped_vertex) { return -(unwrapped_vertex + 1); } @@ -749,6 +775,10 @@ 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; + // The same over the loaded filaments alone, no mixes. Optional; when given, a layer whose image is + // made of flat colours (TextureDetail::flat_colors) is matched with this one, so a tile or a logo + // prints in single filaments while a photograph on another layer may still use mixes. + ColorQuantizeFn quantize_pure; // 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; @@ -760,6 +790,11 @@ struct TextureColorRequest // its edge neighbours removes exactly that, and leaves any feature wider than a facet alone. 0 // turns it off. int despeckle_passes = 0; + // After the despeckle: connected patches of one colour smaller than this (mm^2) are recoloured + // to whatever borders them most - see merge_small_color_regions(). The despeckle only reaches + // single facets; an image detail a few facets wide still leaves thousands of pinhead islands + // that the slicer's multi-material segmentation cannot digest. 0 turns it off. + float min_color_region_mm2 = 0.5f; // 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 @@ -767,6 +802,15 @@ struct TextureColorRequest // straight to a TriangleSelector without a second mapping table. std::vector *out_triangle = nullptr; }; + +// Recolours connected patches of one colour whose area is under `min_area_mm2` to the colour that +// borders them most (by shared edge length). Colour is per triangle, -1 = none (never merged into, +// never merged away). Removes the confetti a detailed image leaves on a fine mesh - thousands of +// one-facet zones, which the slicer's multi-material segmentation cannot digest. Patches are +// processed smallest-first, reading their neighbours' current colour, so a chain of tiny islands +// collapses into its surroundings rather than into each other. +void merge_small_color_regions(const indexed_triangle_set &mesh, std::vector &color, float min_area_mm2); + // Where the volume sits on the plate: its instance transform times its own volume transform, i.e. // mesh coordinates -> world millimetres. // @@ -796,6 +840,22 @@ Transform3d texture_displacement_volume_to_world(const ModelVolume &volume); // removed, i.e. world orientation and scale about the volume's own origin. See build_texture_displacement(). Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world); +// Area-weighted vertex normals of `its` - the directions the bake both projects and displaces along. +std::vector texture_displacement_vertex_normals(const indexed_triangle_set &its); + +// The frame the Cylindrical and Spherical projections wrap around: `patch`'s triangle-corner centroid, +// the world axis *least* aligned with the average of `vertex_normals` over those corners (a cylinder's +// own axis is perpendicular to its outward radial normal), and that average normal itself. +// +// Results come out in whatever frame `patch` is given in. The bake calls this with the patch already in +// the bake frame (see texture_displacement_bake_frame()), so a preview that wants to reproduce the +// bake's projection must too, or it wraps the texture around a different centre. Corners past the end +// of `vertex_normals` - the ones a brush stroke appended - contribute to the centroid but carry no +// normal, exactly as the bake's own loops skip them. +void texture_displacement_patch_frame(const indexed_triangle_set &patch, + const std::vector &vertex_normals, + Vec3f ¢er, Vec3f &axis, Vec3f &average_normal); + // Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from // `volume` and forwards to the overload above. indexed_triangle_set build_texture_displacement(const ModelVolume &volume); @@ -844,7 +904,8 @@ using ColorFieldSampler = std::function &layers, const TextureDisplacementFacetsData &facets_data, - ColorQuantizeFn quantize); + ColorQuantizeFn quantize, + ColorQuantizeFn quantize_pure = nullptr); HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index bd396d9484..34b47c1825 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -224,6 +224,9 @@ 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; +// Ceiling on the filaments the palette's entries can refer to (the bump shader's filament_rgb[]); +// mmu segmentation stops at Extruder16 anyway. +constexpr int PALETTE_MAX_FILAMENTS = 16; // sRGB (0..1) <-> CIELAB, D65. Exactly what the bump shader's srgb_to_lab() computes, so the CPU // quantizer, the mixed-palette entries and the per-fragment preview all match in the same space. @@ -408,7 +411,7 @@ void GLGizmoTextureDisplacement::on_shutdown() m_subdivide_preview_tris = -1; m_subdivide_preview_glmodel.reset(); m_bump_active_chart = -1; - m_bump_active_vertex.clear(); + m_bump_active_face.clear(); m_bump_island_delta = Eigen::Matrix::Identity(); m_island_drag_active = false; m_island_move_set.clear(); @@ -481,6 +484,7 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() rebuild_paint_overlay(); m_paint_overlay_dirty = false; } + rebuild_other_paint_overlay(); // a no-op unless another layer's paint, the active layer or the preview changed // is_initialized() alone is not enough: render_bump_preview_mesh() also needs an active layer // with a decoded texture and a compiled shader, and bails silently without them. Hiding the real // volume for a bump pass that then draws nothing is what made the model vanish - most obviously @@ -518,12 +522,33 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() render_triangles(selection); } + // Every other layer's paint, in muted grey, so all layers stay visible while one of them is edited. Drawn + // before the active layer's tint so that one reads on top where the two overlap. + if (show_paint_overlay) + render_paint_overlay(m_other_paint_glmodel); + // The translucent paint tint. Needed in the bump view because the opaque highlight above is // skipped there, and in the true-displacement view because the displaced surface rises *above* // the undisplaced overlay geometry and hides it exactly where the relief is strongest - in both // cases leaving an erase stroke with no visible effect until the next full preview rebuild. if (show_paint_overlay && (use_bump || use_true_preview)) - render_paint_overlay(); + render_paint_overlay(m_paint_overlay_glmodel); + + // The UV editor's island selection, shown on the model. Polled here rather than pushed: the pane + // changes its selection in its own mouse handling, and a compare of a few ints per frame is free. + { + const TextureDisplacementLayer *al = active_layer(); + const UVEditorCanvas *uv_canvas = wxGetApp().plater()->get_uv_editor_canvas(); + if (m_show_uv_editor && al != nullptr && al->projection_method == TextureProjectionMethod::LSCM && + uv_canvas != nullptr && !m_uv_editor_unwrap.empty()) { + if (uv_canvas->selected_islands() != m_island_overlay_selection) + rebuild_island_overlay(uv_canvas->selected_islands()); + render_island_overlay(); + } else if (m_island_overlay_glmodel.is_initialized()) { + m_island_overlay_glmodel.reset(); + m_island_overlay_selection.clear(); + } + } // Diagnostic overlays, drawn on top of whatever preview is active (both pull toward the camera // with a polygon offset so they win the depth test against the coincident surface). @@ -1055,6 +1080,49 @@ std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in return {}; // Triplanar / Cylindrical / Spherical: the shader projects on its own } +int GLGizmoTextureDisplacement::layer_projection_frame(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + Vec3f ¢er, Vec3f &axis) const +{ + center = Vec3f::Zero(); + axis = Vec3f::UnitZ(); + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || (layer.projection_method != TextureProjectionMethod::Cylindrical && + layer.projection_method != TextureProjectionMethod::Spherical)) + return 0; + // The bake averages the *whole mesh's* vertex normals over the patch's corners, so this has to as + // well: a patch-only average would sometimes quantize to a different world axis and wrap the + // texture the other way round. Both meshes go through patch_in_world() first, which is the frame + // the shaders' tex_pos lives in. + Vec3f average_normal; + texture_displacement_patch_frame(patch_in_world(local_patch), + texture_displacement_vertex_normals(patch_in_world(mv->mesh().its)), + center, axis, average_normal); + return layer.projection_method == TextureProjectionMethod::Cylindrical ? 1 : 2; +} + +std::vector GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer) const +{ + if (layer.projection_method == TextureProjectionMethod::LSCM) { + const indexed_triangle_set patch = patch_in_world(local_patch); + const float aspect = layer_texture_aspect(layer); + std::vector uv = compute_lscm_corner_uvs(patch, layer); + for (Vec2f &p : uv) + p = apply_uv_transform(p, layer, aspect); + return uv; + } + // Single-valued per point: fan the per-vertex result out over the corners. + const std::vector per_vertex = compute_layer_vertex_uvs(local_patch, layer); + if (per_vertex.size() != local_patch.vertices.size()) + return {}; + std::vector corner(local_patch.indices.size() * 3); + for (size_t f = 0; f < local_patch.indices.size(); ++f) + for (int k = 0; k < 3; ++k) + corner[f * 3 + size_t(k)] = per_vertex[size_t(local_patch.indices[f][k])]; + return corner; +} + void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() { m_bump_preview_glmodel.reset(); @@ -1087,11 +1155,20 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() // reconstructed in the fragment shader the way a triplanar projection can. This is also what // makes the fast preview follow the UV editor: the uvs move when an island is dragged, so this // mesh rebuilds (on drag end) with them. The other projections keep projecting in-shader. + // Per *corner*, not per vertex: the mesh below is flat (unshared) anyway, so each triangle can + // carry its own chart's UVs - see compute_layer_corner_uvs(). const TextureDisplacementLayer *active = active_layer(); - std::vector vertex_uv = active != nullptr ? compute_layer_vertex_uvs(patch, *active) : std::vector{}; - m_bump_preview_uses_vertex_uv = vertex_uv.size() == patch.vertices.size(); + std::vector corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector{}; + m_bump_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3; if (!m_bump_preview_uses_vertex_uv) - vertex_uv.clear(); + corner_uv.clear(); + m_bump_projection_mode = (active != nullptr && !m_bump_preview_uses_vertex_uv) ? + layer_projection_frame(patch, *active, m_bump_patch_center, m_bump_patch_axis) : 0; + + // Which triangles the in-flight UV drag moves. Computed here, against the very patch this mesh is + // built from, so the flags can never be indexed by a different triangle count than they were sized + // for (the drag starts from the flushed facet data, a brush stroke changes the live selector). + compute_bump_active_faces(m_bump_active_chart >= 0 ? m_island_move_set : std::vector{}, patch.indices.size()); // 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 @@ -1112,29 +1189,30 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() // quality here because the bump shader takes its surface normal from screen-space derivatives of // position (dFdx/dFdy), not from a per-vertex normal. normal.y flags the UV-editor island being // dragged so the shader can move just that island via the island_delta uniform. - const bool have_active = m_bump_active_chart >= 0 && !m_bump_active_vertex.empty(); - const size_t tri_total = patch.indices.size() + rest.indices.size(); + const size_t tri_total = patch.indices.size() + rest.indices.size(); init_data.reserve_vertices(tri_total * 3); init_data.reserve_indices(tri_total * 3); unsigned vcount = 0; - const auto emit_triangles = [&](const indexed_triangle_set &its, float weight) { - for (const stl_triangle_vertex_indices &tri : its.indices) { + const auto emit_triangles = [&](const indexed_triangle_set &its, float weight, bool painted) { + for (size_t f = 0; f < its.indices.size(); ++f) { + const stl_triangle_vertex_indices &tri = its.indices[f]; + // One value for the whole triangle: island_active is an interpolated varying, so the three + // corners have to agree or the shader moves part of a triangle and not the rest. + const float act = (painted && f < m_bump_active_face.size() && m_bump_active_face[f]) ? 1.f : 0.f; for (int i = 0; i < 3; ++i) { const int idx = tri[i]; - const float act = (have_active && idx >= 0 && size_t(idx) < m_bump_active_vertex.size() && - m_bump_active_vertex[size_t(idx)]) ? 1.f : 0.f; - const Vec2f uv = (weight > 0.5f && m_bump_preview_uses_vertex_uv && size_t(idx) < vertex_uv.size()) ? - vertex_uv[size_t(idx)] : Vec2f::Zero(); + const Vec2f uv = (painted && m_bump_preview_uses_vertex_uv) ? corner_uv[f * 3 + size_t(i)] + : Vec2f::Zero(); init_data.add_vertex(its.vertices[size_t(idx)], Vec3f(weight, act, 0.f), uv); } init_data.add_triangle(vcount, vcount + 1, vcount + 2); vcount += 3; } }; - emit_triangles(patch, 1.f); // painted -> bumped, and coloured by the shader + emit_triangles(patch, 1.f, true); // 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); + emit_triangles(rest, 0.f, false); m_bump_preview_glmodel.init_from(std::move(init_data)); // GLModel::render() unconditionally re-sets the shader's "uniform_color" from this internal @@ -1156,24 +1234,25 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() } } -void GLGizmoTextureDisplacement::compute_bump_active_vertices(const std::vector &charts) +void GLGizmoTextureDisplacement::compute_bump_active_faces(const std::vector &charts, size_t patch_face_count) { - m_bump_active_vertex.clear(); - const ModelVolume *mv = texture_volume(); - if (mv == nullptr || charts.empty()) + m_bump_active_face.clear(); + if (charts.empty() || patch_face_count == 0) return; const PatchUnwrap &u = m_uv_editor_unwrap; - m_bump_active_vertex.assign(mv->mesh().its.vertices.size(), 0); - // Flag the base vertices of every chart being moved. For a group/multi move that is more than one + if (u.source_face.size() != u.indices.size()) + return; + m_bump_active_face.assign(patch_face_count, 0); + // Flag every triangle of every chart being moved. For a group/multi move that is more than one // chart, but since such a move is a pure translation the shader applies the same delta to them all // (see on_island_edited) - exactly the "joined islands move together" behaviour. - for (size_t i = 0; i < u.uvs.size(); ++i) { - if (i >= u.vertex_chart.size() || - std::find(charts.begin(), charts.end(), u.vertex_chart[i]) == charts.end()) + for (size_t t = 0; t < u.indices.size(); ++t) { + const int f = u.source_face[t]; + const int v0 = u.indices[t][0]; // a triangle lies in one chart, so any corner names it + if (f < 0 || size_t(f) >= m_bump_active_face.size() || v0 < 0 || size_t(v0) >= u.vertex_chart.size()) continue; - const int sv = (i < u.source_vertex.size()) ? u.source_vertex[i] : -1; - if (sv >= 0 && size_t(sv) < m_bump_active_vertex.size()) - m_bump_active_vertex[size_t(sv)] = 1; + if (std::find(charts.begin(), charts.end(), u.vertex_chart[size_t(v0)]) != charts.end()) + m_bump_active_face[size_t(f)] = 1; } } @@ -1324,6 +1403,12 @@ 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); + // Cylindrical/Spherical wrap around the painted patch's own centre, which no fragment can derive: + // captured with the mesh (see rebuild_bump_preview_mesh()) and handed over here. 0 is the planar + // projection every other in-shader path uses. + shader->set_uniform("projection_mode", m_bump_projection_mode); + shader->set_uniform("patch_center", m_bump_patch_center); + shader->set_uniform("patch_axis", m_bump_patch_axis); // 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. @@ -1334,11 +1419,32 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh() (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); + // A flat-colour image is matched against single filaments only, as the bake does. + shader->set_uniform("pure_only", color_tex != nullptr && analyze_texture_detail(*layer).flat_colors); for (int i = 0; i < palette_count; ++i) { - const Vec3f &rgb = m_bump_preview_palette[size_t(i)].rgb; - shader->set_uniform(("palette_rgb[" + std::to_string(i) + "]").c_str(), rgb); - shader->set_uniform(("palette_lab[" + std::to_string(i) + "]").c_str(), srgb_to_lab(rgb)); + const PaletteEntry &e = m_bump_preview_palette[size_t(i)]; + const std::string idx = "[" + std::to_string(i) + "]"; + shader->set_uniform(("palette_rgb" + idx).c_str(), e.rgb); + shader->set_uniform(("palette_lab" + idx).c_str(), srgb_to_lab(e.rgb)); + // How the entry prints: its filament, or for a mix the two it interleaves and in what ratio. + shader->set_uniform(("palette_a" + idx).c_str(), e.a); + shader->set_uniform(("palette_b" + idx).c_str(), e.b); + shader->set_uniform(("palette_num" + idx).c_str(), e.num); + shader->set_uniform(("palette_den" + idx).c_str(), e.den); } + // The filaments those indices refer to, and the interleave the shader resolves a mix with - the + // same inputs make_mix_resolver() gets, so the preview shows the pattern that prints rather than + // the mix's smooth average colour. m_palette_filaments is what m_bump_preview_palette was built from. + const int filament_count = + (palette_count > 0) ? int(std::min(m_palette_filaments.size(), size_t(PALETTE_MAX_FILAMENTS))) : 0; + shader->set_uniform("filament_count", filament_count); + for (int i = 0; i < filament_count; ++i) { + const ColorRGBA &c = m_palette_filaments[size_t(i)]; + shader->set_uniform(("filament_rgb[" + std::to_string(i) + "]").c_str(), Vec3f(c.r(), c.g(), c.b())); + } + shader->set_uniform("mix_mode", int(mv->texture_displacement_options.color_mix_mode)); + shader->set_uniform("layer_height", color_band_mm(*mv)); // as color_settings_for() + shader->set_uniform("dither_cell", std::max(m_subdivide_color_mm, 0.05f) * 2.f); // as color_settings_for() if (color_tex != nullptr) { shader->set_uniform("color_tex", 1); glsafe(::glActiveTexture(GL_TEXTURE1)); @@ -1374,6 +1480,64 @@ bool GLGizmoTextureDisplacement::bump_preview_ready() const return wxGetApp().get_shader("texture_displacement_bump") != nullptr; } +// Appends a painted patch to an overlay, lifted onto the displaced surface where that has the base mesh's +// topology (see rebuild_paint_overlay()). +static void append_paint_patch(GLModel::Geometry &out, const indexed_triangle_set &patch, const std::vector *displaced) +{ + unsigned n = unsigned(out.vertices_count()); + for (const stl_triangle_vertex_indices &tri : patch.indices) { + for (int i = 0; i < 3; ++i) { + const size_t idx = size_t(tri[i]); + out.add_vertex((displaced != nullptr && idx < displaced->size()) ? (*displaced)[idx] : patch.vertices[idx]); + } + out.add_triangle(n, n + 1, n + 2); + n += 3; + } +} + +void GLGizmoTextureDisplacement::rebuild_other_paint_overlay() +{ + const ModelVolume *mv = texture_volume(); + // What it depends on: the volume, which layer is active, every other layer's paint (by its timestamp) and the + // displaced positions it is lifted onto. Compared every frame, rebuilt only when it differs. + std::string key; + if (mv != nullptr) { + key = std::to_string(mv->id().id) + ":" + std::to_string(m_active_layer_slot) + (m_use_bump_preview ? ":b:" : ":t:") + + std::to_string(reinterpret_cast(m_preview_its.vertices.data())) + ":" + + std::to_string(m_preview_its.vertices.size()); + for (const TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.slot != m_active_layer_slot && l.slot >= 0 && l.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) + key += "|" + std::to_string(l.slot) + "@" + std::to_string(mv->texture_displacement_facet(l.slot).timestamp()); + } + if (key == m_other_paint_key) + return; + m_other_paint_key = std::move(key); + m_other_paint_glmodel.reset(); + if (mv == nullptr) + return; + + const std::vector *displaced = nullptr; + if (!m_use_bump_preview && m_preview_its.vertices.size() == mv->mesh().its.vertices.size() && + !m_preview_its.vertices.empty()) + displaced = &m_preview_its.vertices; + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + for (const TextureDisplacementLayer &l : mv->texture_displacement_layers) { + if (l.slot == m_active_layer_slot || l.slot < 0 || l.slot >= int(TEXTURE_DISPLACEMENT_MAX_LAYERS) || + mv->texture_displacement_facet(l.slot).empty()) + continue; + TriangleSelector selector(mv->mesh()); + selector.deserialize(mv->texture_displacement_facet(l.slot).get_data(), false); + append_paint_patch(init_data, selector.get_facets_strict(EnforcerBlockerType::ENFORCER), displaced); + } + if (init_data.is_empty()) + return; + m_other_paint_glmodel.init_from(std::move(init_data)); + // Neutral grey: painted, but not the layer the brush is working on. + m_other_paint_glmodel.set_color(ColorRGBA(0.55f, 0.58f, 0.60f, 0.35f)); +} + void GLGizmoTextureDisplacement::rebuild_paint_overlay() { m_paint_overlay_glmodel.reset(); @@ -1403,27 +1567,18 @@ void GLGizmoTextureDisplacement::rebuild_paint_overlay() init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; init_data.reserve_vertices(patch.indices.size() * 3); init_data.reserve_indices(patch.indices.size() * 3); - unsigned n = 0; - for (const stl_triangle_vertex_indices &tri : patch.indices) { - for (int i = 0; i < 3; ++i) { - const size_t idx = size_t(tri[i]); - init_data.add_vertex((displaced != nullptr && idx < displaced->size()) ? (*displaced)[idx] - : patch.vertices[idx]); - } - init_data.add_triangle(n, n + 1, n + 2); - n += 3; - } + append_paint_patch(init_data, patch, displaced); m_paint_overlay_glmodel.init_from(std::move(init_data)); // GLModel::render() re-sets "uniform_color" from this field just before drawing, so the colour // has to be set here rather than as a uniform at draw time. m_paint_overlay_glmodel.set_color(ColorRGBA(0.16f, 0.79f, 0.35f, 0.38f)); } -void GLGizmoTextureDisplacement::render_paint_overlay() +void GLGizmoTextureDisplacement::render_paint_overlay(GLModel &overlay) { const ModelObject *mo = m_c->selection_info()->model_object(); const ModelVolume *mv = texture_volume(); - if (mo == nullptr || mv == nullptr || !m_paint_overlay_glmodel.is_initialized()) + if (mo == nullptr || mv == nullptr || !overlay.is_initialized()) return; GLShaderProgram *shader = wxGetApp().get_shader("flat"); if (shader == nullptr) @@ -1443,7 +1598,78 @@ void GLGizmoTextureDisplacement::render_paint_overlay() glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); glsafe(::glPolygonOffset(-1.5f, -1.5f)); glsafe(::glDepthMask(GL_FALSE)); - m_paint_overlay_glmodel.render(); + overlay.render(); + glsafe(::glDepthMask(GL_TRUE)); + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); + shader->stop_using(); +} + +void GLGizmoTextureDisplacement::rebuild_island_overlay(const std::vector &selection) +{ + m_island_overlay_glmodel.reset(); + m_island_overlay_selection = selection; + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || selection.empty() || m_uv_editor_unwrap.empty()) + return; + // The unwrap was made from the painted patch in the bake frame; the same extraction on the + // volume's own mesh gives the same triangles and vertex order in local coordinates, which is the + // frame the overlay is drawn in (with the volume's transform, like the paint tint). + const indexed_triangle_set patch = extract_painted_patch(mv->mesh().its, m_uv_editor_state.facets); + const PatchUnwrap &uw = m_uv_editor_unwrap; + std::vector chosen(size_t(std::max(uw.chart_count, 0)), 0); + for (const int c : selection) + if (c >= 0 && size_t(c) < chosen.size()) + chosen[size_t(c)] = 1; + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + unsigned n = 0; + for (const stl_triangle_vertex_indices &tri : uw.indices) { + const int v0 = tri[0]; + if (v0 < 0 || size_t(v0) >= uw.vertex_chart.size()) + continue; + const int c = uw.vertex_chart[size_t(v0)]; + if (c < 0 || size_t(c) >= chosen.size() || !chosen[size_t(c)]) + continue; + bool ok = true; + for (int k = 0; k < 3 && ok; ++k) { + const int u = tri[k]; + ok = u >= 0 && size_t(u) < uw.source_vertex.size() && uw.source_vertex[size_t(u)] >= 0 && + size_t(uw.source_vertex[size_t(u)]) < patch.vertices.size(); + } + if (!ok) + continue; + for (int k = 0; k < 3; ++k) + init_data.add_vertex(patch.vertices[size_t(uw.source_vertex[size_t(tri[k])])]); + init_data.add_triangle(n, n + 1, n + 2); + n += 3; + } + if (n == 0) + return; + m_island_overlay_glmodel.init_from(std::move(init_data)); + m_island_overlay_glmodel.set_color(ColorRGBA(0.10f, 0.55f, 0.95f, 0.45f)); // the pane's selection blue +} + +void GLGizmoTextureDisplacement::render_island_overlay() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr || !m_island_overlay_glmodel.is_initialized()) + return; + GLShaderProgram *shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + shader->start_using(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + // Above the paint tint (a larger offset), translucent, no depth writes - a marker, not geometry. + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(-2.0f, -2.0f)); + glsafe(::glDepthMask(GL_FALSE)); + m_island_overlay_glmodel.render(); glsafe(::glDepthMask(GL_TRUE)); glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); shader->stop_using(); @@ -1475,6 +1701,14 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() std::vector uv = compute_layer_vertex_uvs(patch, *layer); const bool have_uvs = uv.size() == patch.vertices.size(); m_uvcheck_uses_vertex_uv = have_uvs; + m_uvcheck_projection_mode = have_uvs ? 0 : + layer_projection_frame(patch, *layer, m_uvcheck_patch_center, m_uvcheck_patch_axis); + // Per corner as well, for the same reason the bump mesh takes them: under LSCM a seam vertex has a + // different uv in each island it borders, so the shared-vertex form drew one triangle per face from + // a neighbouring island's placement. Only the *drawing* needs this; the distortion metric below is + // a per-vertex average by construction and keeps using `uv`. + const std::vector corner_uv = compute_layer_corner_uvs(patch, *layer); + const bool have_corner_uvs = have_uvs && corner_uv.size() == patch.indices.size() * 3; // Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible. std::vector distortion(patch.vertices.size(), 0.5f); @@ -1513,13 +1747,23 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() GLModel::Geometry init_data; init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 }; - init_data.reserve_vertices(patch.vertices.size()); + // Flat (one vertex per triangle corner), so each triangle can carry its own island's uv - see + // have_corner_uvs above. Costs nothing in shading quality: the overlay shades from uv and the + // interpolated distortion value alone, never from a per-vertex normal. + init_data.reserve_vertices(patch.indices.size() * 3); init_data.reserve_indices(patch.indices.size() * 3); - for (size_t vi = 0; vi < patch.vertices.size(); ++vi) - init_data.add_vertex(patch.vertices[vi], Vec3f(distortion[vi], 0.f, 0.f), - have_uvs ? uv[vi] : Vec2f::Zero()); - for (const stl_triangle_vertex_indices &tri : patch.indices) - init_data.add_triangle(unsigned(tri[0]), unsigned(tri[1]), unsigned(tri[2])); + unsigned vcount = 0; + for (size_t f = 0; f < patch.indices.size(); ++f) { + const stl_triangle_vertex_indices &tri = patch.indices[f]; + for (int k = 0; k < 3; ++k) { + const size_t vi = size_t(tri[k]); + init_data.add_vertex(patch.vertices[vi], Vec3f(distortion[vi], 0.f, 0.f), + have_corner_uvs ? corner_uv[f * 3 + size_t(k)] : + (have_uvs ? uv[vi] : Vec2f::Zero())); + } + init_data.add_triangle(vcount, vcount + 1, vcount + 2); + vcount += 3; + } m_uvcheck_glmodel.init_from(std::move(init_data)); } @@ -1560,6 +1804,11 @@ void GLGizmoTextureDisplacement::render_uvcheck_mesh() shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f); shader->set_uniform("uv_offset", layer->offset); shader->set_uniform("use_vertex_uv", m_uvcheck_uses_vertex_uv); + shader->set_uniform("projection_mode", m_uvcheck_projection_mode); + shader->set_uniform("patch_center", m_uvcheck_patch_center); + shader->set_uniform("patch_axis", m_uvcheck_patch_axis); + // Was never uploaded, so the checker disagreed with the bake for any non-square height map. + shader->set_uniform("tex_aspect", layer_texture_aspect(*layer)); // Coincident with the base surface, so pull it toward the camera to win the depth test. glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); @@ -1870,6 +2119,8 @@ void GLGizmoTextureDisplacement::update_uv_editor() } // Padding disabled (0): the user asked to pack islands with no gap between them. m_uv_editor_unwrap = compute_patch_unwrap(patch, layer->lscm_seam_angle_deg, 0.f, layer->lscm_seam_edges); + m_island_overlay_glmodel.reset(); // the islands were renumbered: rebuilt from the pane's selection next frame + m_island_overlay_selection.clear(); // Re-apply any stored UV edits onto the fresh unwrap, so the pane shows exactly what // compute_lscm_uvs() will bake (which applies the same overrides). apply_lscm_uv_overrides(m_uv_editor_unwrap, layer->lscm_uv_overrides); @@ -2159,6 +2410,17 @@ void GLGizmoTextureDisplacement::run_uv_command(int cmd, float value) // Closed with the pane's own X: keep it closed until asked again, and upload the background afresh then. m_show_uv_editor = false; m_uv_editor_bg = UVBackground::None; + // The seam tool belongs to the pane: left on with the pane gone, every stroke on the model would + // be swallowed as a seam click and nothing would paint. + if (m_seam_edit_mode) { + m_seam_edit_mode = false; + m_seam_hover_edge = { -1, -1 }; + m_seam_hover_vertex = -1; + m_seam_hover_glmodel.reset(); + m_seam_path_anchor = -1; + m_seam_anchor_glmodel.reset(); + push_uv_pane_state(); + } return; } if (cmd == int(Command::SetBackground)) { @@ -2574,10 +2836,10 @@ void GLGizmoTextureDisplacement::on_island_edited(int island, const Vec2f &offse // Decide the moved set once, at drag start: the whole selection + join groups for a move, or // just the primary for a rotate/scale. m_island_move_set = is_move ? build_island_move_set(*layer, island) : std::vector{ island }; - // Set up the GPU drag: flag the moved islands' vertices and bake the mesh once (via the dirty - // flag). From then on the drag is a uniform update, no rebuild - see render_bump_preview_mesh(). + // Set up the GPU drag: bake the mesh once (via the dirty flag), which is also what flags the + // moved islands' triangles. From then on the drag is a uniform update, no rebuild - see + // render_bump_preview_mesh(). m_bump_active_chart = island; - compute_bump_active_vertices(m_island_move_set); m_bump_island_delta = Eigen::Matrix::Identity(); m_bump_preview_dirty = true; } @@ -2604,7 +2866,7 @@ void GLGizmoTextureDisplacement::on_island_edited(int island, const Vec2f &offse if (finished) { m_island_drag_active = false; m_bump_active_chart = -1; - m_bump_active_vertex.clear(); + m_bump_active_face.clear(); m_island_move_set.clear(); m_bump_island_delta = Eigen::Matrix::Identity(); rebuild_preview(); // the real displaced geometry moved: recompute it once, at the end @@ -2989,7 +3251,13 @@ void GLGizmoTextureDisplacement::update_model_object() if (!mv->is_model_part()) continue; ++idx; - updated |= mv->texture_displacement_facet(m_active_layer_slot).set(*m_triangle_selectors[idx]); + FacetsAnnotation &facet = mv->texture_displacement_facet(m_active_layer_slot); + // See m_selectors_stale: the mask could not be loaded into this selector, so an empty selector + // here is "failed to load", not "nothing painted", and writing it back would erase the paint. + // A selector that does hold something is the user's own work and must be flushed as usual. + if (m_selectors_stale && !facet.empty() && m_triangle_selectors[idx]->serialize().triangles_to_split.empty()) + continue; + updated |= facet.set(*m_triangle_selectors[idx]); } // The fast (bump) preview reads the live selector, so it has to be rebuilt after any stroke that @@ -3018,13 +3286,28 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update) ebt_colors.push_back(GLVolume::NEUTRAL_COLOR); ebt_colors.push_back(TriangleSelectorGUI::enforcers_color); ebt_colors.push_back(TriangleSelectorGUI::blockers_color); + m_selectors_stale = false; for (const ModelVolume *mv : mo->volumes) { if (!mv->is_model_part()) continue; - const TriangleMesh *mesh = &mv->mesh(); + const TriangleMesh *mesh = &mv->mesh(); + const TriangleSelector::TriangleSplittingData &data = + mv->texture_displacement_facet(m_active_layer_slot).get_data(); + // The same bound TriangleSelector::deserialize() checks before it gives up - silently, with a + // void return and no way to report it. A mask recorded before the mesh was replaced indexes + // triangles that no longer exist, and the selector then comes back empty even though the mask + // is not. That has to be caught here, because the next update_model_object() would otherwise + // write the empty selector back over the mask: the paint would vanish, and the bake would + // report "nothing is painted" about the very data the flush had just deleted. + const size_t facet_count = mesh->its.indices.size(); + for (const TriangleSelector::TriangleBitStreamMapping &m : data.triangles_to_split) + if (m.triangle_idx < 0 || size_t(m.triangle_idx) >= facet_count) { + m_selectors_stale = true; + break; + } m_triangle_selectors.emplace_back(std::make_unique(*mesh, ebt_colors)); - m_triangle_selectors.back()->deserialize(mv->texture_displacement_facet(m_active_layer_slot).get_data(), false); + m_triangle_selectors.back()->deserialize(data, false); m_triangle_selectors.back()->request_update_render_data(); } @@ -3079,6 +3362,7 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() "texture_displacement_map_view.svg", "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg", "texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg", "texture_displacement_move_down.svg", "texture_displacement_drag.svg", + "texture_displacement_select_all.svg", "texture_displacement_erase_all.svg", }; std::vector paths; paths.reserve(names.size()); @@ -3870,9 +4154,10 @@ TextureColorSettings GLGizmoTextureDisplacement::color_settings_for(const ModelV if (!any_layer_colors(mv)) return out; // nothing is colouring: every colour path stays switched off out.palette = cached_palette(); + out.palette_pure = make_palette(m_palette_filaments, /* mixing */ false); 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(); + out.layer_height = color_band_mm(mv); // 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; @@ -3906,6 +4191,19 @@ std::vector GLGizmoTextureDisplacement::filament_palette() return palette; } +float GLGizmoTextureDisplacement::color_band_mm(const ModelVolume &mv) +{ + const float lh = print_layer_height(); + const float edge = (mv.texture_displacement_options.v2_refine_mm > 0.f) ? mv.texture_displacement_options.v2_refine_mm + : v2_recommendation(mv).edge_mm; + if (edge <= 0.f || lh <= 0.f) + return lh; + // A refined triangle of edge e stacks in rows about 0.87 * e apart (an equilateral triangle's + // height), and a dither needs at least two rows per period to be a dither at all. + constexpr float ROW_PER_EDGE = 0.87f; + return lh * std::max(1.f, std::ceil(2.f * ROW_PER_EDGE * edge / lh)); +} + float GLGizmoTextureDisplacement::print_layer_height() { try { @@ -3968,7 +4266,7 @@ ColorResolveFn GLGizmoTextureDisplacement::make_mix_resolver(const std::vector

int { + return [entries, mode, band, cell](int index, const Vec3f &pos, const Vec3f &normal) -> int { if (index < 0 || size_t(index) >= entries->size()) return -1; const PaletteEntry &e = (*entries)[size_t(index)]; @@ -3977,7 +4275,15 @@ ColorResolveFn GLGizmoTextureDisplacement::make_mix_resolver(const std::vector

= e.den ? e.a : e.b; + const bool bands = mode == ColorMixMode::ZBands || mode == ColorMixMode::Auto; + if (bands) { // 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; @@ -4018,15 +4324,25 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve float l0, a0, b0; const Vec3f lab0 = srgb_to_lab(Vec3f((r + 0.5f) / E, (g + 0.5f) / E, (b + 0.5f) / E)); l0 = lab0.x(); a0 = lab0.y(); b0 = lab0.z(); - int best = 0; - float best_d = std::numeric_limits::max(); + int best = 0, best_pure = -1; + float best_d = std::numeric_limits::max(), best_pure_d = best_d; 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); } + if (!palette[i].is_mix() && d < best_pure_d) { + best_pure_d = d; + best_pure = int(i); + } } + // A mix is an interleave that only reads as its colour from a distance; up close + // it is stripes. Spend it only where it buys a clearly better match than the nearest + // single filament: ten Delta E is a visible step, less is not worth the stripes. + constexpr float PREFER_PURE_DE = 10.f; + if (best_pure >= 0 && palette[size_t(best)].is_mix() && best_pure_d - best_d < PREFER_PURE_DE) + best = best_pure; (*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best); } }); @@ -4929,16 +5245,25 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const float approx_height = m_imgui->scaled(24.f); y = std::min(y, bottom_limit - approx_height); - // Docked (the default) the panel is pinned next to the gizmo toolbar and cannot be moved, like - // every other gizmo's. Undocked it becomes an ordinary floating window: a title bar to drag it - // by, and no forced position - this panel is tall enough (layer stack, per-layer controls) that - // it can cover the very part of the model being painted, and being able to shove it aside is the - // point. The position is deliberately *not* seeded on undock, so the window stays exactly where - // it already was and the user just gains the ability to move it from there. + // Docked (the default) the panel is pinned to the right edge of the 3D canvas and cannot be + // moved. Deliberately *not* next to the gizmo toolbar, which is where `x` points and where every + // other gizmo's window goes: this panel is far taller than those (layer stack plus the whole + // per-layer control set), so at the toolbar it sits right on top of the part of the model being + // painted. Pinning it to the canvas edge also parks it against the UV editor, since that pane is + // docked on the right and the canvas therefore ends exactly at the pane's left edge - so the + // panel follows the pane in and out instead of being clipped by it. + // + // Undocked it becomes an ordinary floating window: a title bar to drag it by, and no forced + // position - the position is deliberately not seeded on undock, so the window stays exactly + // where it already was and the user just gains the ability to move it from there. ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse; if (!m_undocked) { flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar; - GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 1.0f, 0.0f); + // Right-aligned (pivot 1), so the width the panel auto-resized to last frame does not need to + // be known here. Width 0 skips GizmoImguiSetNextWIndowPos()'s own left-aligned fit-to-canvas + // clamp, which would push the window back off the edge it is being pinned to. + float right = float(m_parent.get_canvas_size().get_width()) - m_imgui->scaled(0.5f); + GizmoImguiSetNextWIndowPos(right, y, 0.f, 0.f, ImGuiCond_Always, 1.0f, 0.0f); } ImGuiWrapper::push_toolbar_style(m_parent.get_scale()); @@ -5251,7 +5576,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_erase_mode = true; } - // ---- Tools: brush / face / connected area, and the active tool's own control ---- + // ---- Tools: brush / face / connected area on the left, the whole-model actions on the right, and the + // active tool's own control on the line below ---- // "Face" and "Connected area" reuse the exact same selection machinery every other paint gizmo has // (single-facet click, and angle-limited flood fill respectively). { @@ -5276,7 +5602,36 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_tool_type = ToolType::SMART_FILL; m_cursor_type = TriangleSelector::CursorType::POINTER; } + + // Whole model: paint every face with the active layer, or clear its paint from all of them. Actions + // rather than tools, so they sit apart at the right end of the row. + const wxString whole_na = busy ? _L("Wait for the bake to finish.") : + active == nullptr ? _L("Add a layer first.") : + wxString(); + const wxString erase_na = !whole_na.empty() ? whole_na : + !slot_painted(m_active_layer_slot) ? _L("The active layer has no paint yet.") : + wxString(); ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - (2.f * icon_md + gap_s))); + if (icon_toggle(806, "texture_displacement_select_all.svg", false, icon_md, _L("Select whole model"), + _L("Select whole model - paint every face of the model with the active layer"), whole_na)) + select_whole_model(); + ImGui::SameLine(0.f, gap_s); + if (icon_toggle(807, "texture_displacement_erase_all.svg", false, icon_md, _L("Erase whole model"), + _L("Erase whole model - clear the active layer's paint from every face"), erase_na)) { + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"), + UndoRedo::SnapshotType::GizmoAction); + int idx = -1; + for (ModelVolume *v : mo->volumes) + if (v->is_model_part()) { + ++idx; + m_triangle_selectors[idx]->reset(); + m_triangle_selectors[idx]->request_update_render_data(); + } + update_model_object(); + m_parent.set_as_dirty(); + } + if (is_brush_mode) { ImGui::SetNextItemWidth(-(3.f * gap_s + 1.f + 2.f * icon_sm)); ImGui::SliderFloat("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", @@ -5302,33 +5657,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } } - // ---- Whole model ---- - { - const float half = std::floor((ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) * 0.5f); - m_imgui->disabled_begin(busy || active == nullptr); - if (ImGui::Button(_u8L("Select whole model").c_str(), ImVec2(half, 0.f))) - select_whole_model(); - m_imgui->disabled_end(); - hover_tip(_u8L("Paint every face of the model with the active layer")); - ImGui::SameLine(); - m_imgui->disabled_begin(busy || active == nullptr || !slot_painted(m_active_layer_slot)); - if (ImGui::Button(_u8L("Erase whole model").c_str(), ImVec2(half, 0.f))) { - Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"), - UndoRedo::SnapshotType::GizmoAction); - int idx = -1; - for (ModelVolume *v : mo->volumes) - if (v->is_model_part()) { - ++idx; - m_triangle_selectors[idx]->reset(); - m_triangle_selectors[idx]->request_update_render_data(); - } - update_model_object(); - m_parent.set_as_dirty(); - } - m_imgui->disabled_end(); - hover_tip(_u8L("Clear the active layer's paint from every face")); - } - // ---- View: Normal / Fast / Checker / Distortion as one group, Wireframe on its own ---- // The underlying state stays m_use_bump_preview + m_uv_check_mode. { @@ -5651,13 +5979,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (ImGui::Checkbox(_u8L("Mix filaments").c_str(), &opts.color_mix_enabled)) m_preview_params_dirty = true; hover_tip(_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.")); + "filaments cover far more than a few colours. Used only on images with " + "continuous colour (photographs, gradients); a texture of flat colours " + "prints in single filaments either way. Off forces single filaments.")); if (opts.color_mix_enabled) { slider_label(_L("Mix by")); 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() }; + const std::string mix_auto = _u8L("Automatic"); + const char *mix_items[] = { mix_z.c_str(), mix_xy.c_str(), mix_auto.c_str() }; int mix_mode = int(opts.color_mix_mode); ImGui::SetNextItemWidth(-card_pad); if (scoped_combo("##color_mix_mode", &mix_mode, mix_items, IM_ARRAYSIZE(mix_items))) { @@ -5668,7 +5998,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "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.")); + "any angle but can read as texture rather than as a blend.\n" + "Automatic: layers on upright faces; flat-facing faces take the nearer " + "single filament, since a checkerboard there shows as a pattern.")); ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colours from %2% filaments"), int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); } @@ -6409,9 +6741,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_parent.set_as_dirty(); } m_imgui->disabled_end(); - hover_tip(_u8L("Auto: the resolution and the budget are chosen from the texture (its pixel " - "size on the model and how sharp it is) and the model's size, the way " - "BumpMesh's smart resolution does. Untick to set them by hand.")); + hover_tip(_u8L("Auto: the resolution follows the model's size and the budget is the standard " + "750 k, the same defaults as bumpmesh.com. Untick to set them by hand.")); ImGui::SameLine(); ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX()); float shown = auto_res ? rec.edge_mm : opts.v2_refine_mm; @@ -6424,10 +6755,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } m_imgui->disabled_end(); if (auto_res && rec.edge_mm > 0.f) - hover_tip(Slic3r::format(_u8L("%1% texture pixels per edge x %2% mm per pixel%3%. Budget %4% k."), - rec.pixels_per_edge, Slic3r::format("%.3f", rec.texel_mm), - rec.budget_bound ? _u8L(", held back by the triangle cap") : std::string(), - rec.budget_k)); + hover_tip(Slic3r::format(_u8L("The model's diagonal / 250, as bumpmesh.com sets it. Budget %1% k."), rec.budget_k)); else hover_tip(_u8L("Triangle edge length the painted area is refined to before displacement. " "Smaller carries finer texture detail and costs more triangles; the budget " @@ -6487,7 +6815,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::PopStyleColor(5); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) m_imgui->tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ? - _u8L("Nothing is painted yet.") : + (m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so " + "strokes on the model mark seams instead of painting - turn " + "it off in the pane to paint.") : + _u8L("Nothing is painted yet.")) : pro_mode() ? _u8L("Turn the painted height maps into real geometry, by moving the vertices that are " "already there. Use Subdivide first if the mesh is too coarse to show the detail.") : diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 8cbfa9c84e..7ec7428c20 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -104,6 +104,14 @@ public: // 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 Z band height, in mm. One print layer is the ideal, but the interleave is realised per + // *facet*: a band thinner than the mesh can resolve does not dither, it beats against the triangle + // grid and comes out as broad horizontal stripes - and since MMU segmentation reads facet colour, + // it does so in the print too, not only on screen. The refinement edge is chosen from the model's + // diagonal and knows nothing about the layer height, so the band is rounded up to a whole number of + // layers at least two facet rows tall: still exact on the printer, and representable by the mesh + // that has to carry it. Used by both the bake settings and the preview shader, so the two agree. + float color_band_mm(const ModelVolume &mv); // 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 @@ -480,6 +488,17 @@ private: // painting into a slot with no texture assigned is harmless, it just has no visible/bake // effect until a texture is added to that slot. int m_active_layer_slot = 0; + // Set when m_triangle_selectors could not be loaded from the stored paint masks, because a mask + // was recorded against a different topology: TriangleSelector::deserialize() rejects that and + // returns without a word, leaving the selector empty even though the mask is not. While this is + // set, an *empty* selector says nothing about the paint, so update_model_object() must not flush + // one back - serializing it over the mask destroys the user's paint for good, and the bake then + // reports "nothing is painted" about the data the flush had just deleted. + // + // Deliberately not a blanket refusal to flush: once the user paints, the selector holds real + // content again and writing it back is exactly right - it replaces the unusable mask with one + // recorded against the current mesh. So only the empty-over-non-empty case is held back. + bool m_selectors_stale = false; bool m_bake_in_progress = false; // When set, the true-displacement geometry is rebuilt on every parameter change (live), instead of @@ -636,11 +655,22 @@ private: // patch only), translucent (the preview stays visible through it) and rebuilt live during a // stroke. GLModel m_paint_overlay_glmodel; + // The islands selected in the UV editor, tinted on the model so the pane's selection can be seen + // in place. Rebuilt whenever the pane's selection differs from the one it was built for. + GLModel m_island_overlay_glmodel; + std::vector m_island_overlay_selection; + void rebuild_island_overlay(const std::vector &selection); + void render_island_overlay(); // Set on every paint event, cleared when the overlay is rebuilt in render_painter_gizmo(). Kept // separate from m_bump_preview_dirty so a stroke refreshes only the small painted patch per frame, bool m_paint_overlay_dirty = false; void rebuild_paint_overlay(); - void render_paint_overlay(); + void render_paint_overlay(GLModel &overlay); + // Every *other* layer's paint, muted, so all layers stay visible while one is edited. Rebuilt only when that + // paint, the active layer or the preview it is lifted onto changes (m_other_paint_key). + GLModel m_other_paint_glmodel; + std::string m_other_paint_key; + void rebuild_other_paint_overlay(); // Whether render_bump_preview_mesh() would actually draw something. Checked before the real volume // is hidden: with no layer, no texture or no shader the bump path draws nothing, and hiding the // volume for it left the model invisible. @@ -648,6 +678,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 projection frame handed to the bump shader, captured when the mesh is built. Cylindrical and + // Spherical are reconstructed in the fragment shader (there is no per-vertex uv for them) and wrap + // around the whole patch, which no fragment can work out for itself. See layer_projection_frame(). + int m_bump_projection_mode = 0; + Vec3f m_bump_patch_center = Vec3f::Zero(); + Vec3f m_bump_patch_axis = Vec3f::UnitZ(); // 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 @@ -659,14 +695,23 @@ private: // the dragged island's vertices flagged, v_normal.y = 1) and then moved purely through the shader's // island_delta uniform - one uniform update per mouse move, no rebuild - so it tracks the cursor // as smoothly as Adjust placement. m_bump_active_chart is the dragged island (or -1); - // m_bump_active_vertex flags its base vertices; m_bump_baked_active_xf is that island's placement - // baked into the current mesh, against which the live delta is measured; m_bump_island_delta is the - // resulting final-uv-space affine handed to the shader (identity except mid-drag). + // m_bump_active_face flags the dragged islands' *triangles*, indexed by painted-patch face; + // m_bump_baked_active_xf is that island's placement baked into the current mesh, against which the + // live delta is measured; m_bump_island_delta is the resulting final-uv-space affine handed to the + // shader (identity except mid-drag). + // + // Per triangle rather than per vertex deliberately: a seam vertex belongs to every chart touching + // it, so flagging the dragged chart's base vertices also flagged the corners its neighbours use. + // island_active is an interpolated varying, so those neighbouring triangles then had island_delta + // applied too - dragging one island moved every adjacent island's texture while the editor, which + // is per chart, correctly moved only the one. A triangle belongs to exactly one chart. int m_bump_active_chart = -1; - std::vector m_bump_active_vertex; + std::vector m_bump_active_face; Eigen::Matrix m_bump_baked_active_xf = Eigen::Matrix::Identity(); Eigen::Matrix m_bump_island_delta = Eigen::Matrix::Identity(); - void compute_bump_active_vertices(const std::vector &charts); + // Flags `charts`' triangles in m_bump_active_face, sized to `patch_face_count` (the painted patch + // the bump mesh is being built from). Cleared if the unwrap carries no face map. + void compute_bump_active_faces(const std::vector &charts, size_t patch_face_count); // The set of islands the current UV-editor drag moves together: the pane's multi-selection unioned // with each selected island's join group (see build_island_move_set()). Populated at drag start and @@ -687,6 +732,19 @@ private: // the shader projects on its own. Shared by the bump preview and the UV-check overlay. std::vector compute_layer_vertex_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer) const; + // The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what + // the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different + // UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined + // seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is + // single-valued per point, so there a corner's UV is just its vertex's. + std::vector compute_layer_corner_uvs(const indexed_triangle_set &patch, + const TextureDisplacementLayer &layer) const; + // The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two + // wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the + // shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview + // can never wrap around a different centre, or pick a different axis, than the bake will. + int layer_projection_frame(const indexed_triangle_set &local_patch, const TextureDisplacementLayer &layer, + Vec3f ¢er, Vec3f &axis) const; // `patch` with its vertices moved into world millimetres - the space the bake maps the texture in // (see build_texture_displacement()). Returned by value because the caller usually still needs the // original: the patch doubles as render geometry, which is drawn through the volume's own matrix. @@ -699,6 +757,10 @@ private: UVCheckMode m_uv_check_mode = UVCheckMode::None; GLModel m_uvcheck_glmodel; bool m_uvcheck_uses_vertex_uv = false; + // As m_bump_projection_mode and friends, for the Checker overlay. + int m_uvcheck_projection_mode = 0; + Vec3f m_uvcheck_patch_center = Vec3f::Zero(); + Vec3f m_uvcheck_patch_axis = Vec3f::UnitZ(); void rebuild_uvcheck_mesh(); void render_uvcheck_mesh(); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index 05df414ca5..f48ec6b28c 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -38,6 +38,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) TextureColorRequest *color = nullptr; if (!m_input.color.empty()) { color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette); + if (!m_input.color.palette_pure.empty()) + color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure); 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); @@ -79,6 +81,22 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept if (canceled || eptr || m_result.empty()) return; + // A bake that moved nothing - no layer could be sampled, or every sample was zero - must not be + // committed: committing is what clears the baked layers' paint, so the user would see the painted + // region simply vanish with no relief in its place and no idea why. Keep the paint and say so. + { + const indexed_triangle_set &out = m_result.its; + bool unchanged = out.indices.size() == m_input.base_mesh.indices.size() && + out.vertices.size() == m_input.base_mesh.vertices.size(); + for (size_t i = 0; unchanged && i < out.vertices.size(); ++i) + unchanged = (out.vertices[i] - m_input.base_mesh.vertices[i]).cwiseAbs().maxCoeff() < 1e-5f; + if (unchanged) { + show_error(nullptr, _u8L("The bake produced no displacement, so nothing was changed and the paint was kept. " + "Check that the painted layer has a texture and a non-zero depth.")); + return; + } + } + Plater *plater = wxGetApp().plater(); const auto commit = [this, plater]() { @@ -109,11 +127,21 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept // 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 - // untouched so the user can keep sculpting with the same textures. - for (const TextureDisplacementLayer &layer : m_input.layers) - if (!layer.empty() && layer.slot >= 0 && layer.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) - volume->texture_displacement_facet(layer.slot).reset(); + // texture layer definitions themselves are left untouched so the user can keep sculpting with + // the same textures. + // + // A mask the bake did *not* consume only still means what it did if the topology is unchanged, + // which is true of the classic path (it moves existing vertices) but not of the one-run + // pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old + // topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and + // then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object(). + const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size(); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { + const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(), + [slot](const TextureDisplacementLayer &l) { return l.slot == slot; }); + if (topology_changed || (it != m_input.layers.end() && !it->empty())) + volume->texture_displacement_facet(slot).reset(); + } ModelObject *object = volume->get_object(); if (object == nullptr) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 013ea79750..1845314f0d 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -24,6 +24,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) TextureColorRequest *color = nullptr; if (!m_input.color.empty()) { color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette); + if (!m_input.color.palette_pure.empty()) + color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure); 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); diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index e2f0147efb..3b9f0dac25 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -1313,54 +1313,42 @@ TEST_CASE("TextureDisplacement: edge flips lay a stepped field's wall along the // Automatic resolution (v2 pipeline) // --------------------------------------------------------------------------------------------- -TEST_CASE("TextureDisplacement: automatic resolution follows the texture's texel size and sharpness", "[TextureDisplacement]") +TEST_CASE("TextureDisplacement: automatic resolution follows the model's size like bumpmesh.com", "[TextureDisplacement]") { - // A 20 mm cube (diagonal 34.6 mm, so the edge may go up to 0.69 mm) with an 8 mm tile. + // A 20 mm cube: diagonal 34.64 mm, so diagonal / 250 = 0.1386 mm, rounded up to 0.14. const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f); + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(16, 16); + layer.tiling_scale = 8.f; - SECTION("a hard-edged texture gets one texel per edge") + SECTION("edge from the diagonal, budget the standard 750 k") { - TextureDisplacementLayer layer; - layer.image_data = make_checkerboard_png(16, 16); // 2x2 texel checks: every other texel is a step - layer.tiling_scale = 8.f; // texel = 0.5 mm - const TextureDetail detail = analyze_texture_detail(layer); - CHECK(detail.sharp_fraction > 0.15f); - CHECK_THAT(detail.pixels_per_edge, WithinAbs(1.f, 1e-6f)); const V2Resolution rec = recommend_v2_resolution(cube, { layer }); - CHECK_THAT(rec.texel_mm, WithinAbs(0.5f, 1e-4f)); - CHECK_THAT(rec.edge_mm, WithinAbs(0.5f, 1e-4f)); - CHECK(rec.budget_k >= 10); - CHECK(rec.budget_k <= 2000); + CHECK_THAT(rec.edge_mm, WithinAbs(0.14f, 1e-4f)); + CHECK(rec.budget_k == 750); + CHECK_THAT(rec.texel_mm, WithinAbs(0.5f, 1e-4f)); // reported for the panel } - SECTION("a flat texture gets four texels per edge, within the model's clamp") + SECTION("the world transform scales the diagonal") { - TextureDisplacementLayer layer; - layer.image_data = make_flat_gray_png(128, 16, 16); - layer.tiling_scale = 8.f; // texel 0.5 mm x 4 = 2 mm, clamped to diagonal / 50 - const TextureDetail detail = analyze_texture_detail(layer); - CHECK_THAT(detail.pixels_per_edge, WithinAbs(4.f, 1e-6f)); - const V2Resolution rec = recommend_v2_resolution(cube, { layer }); - CHECK_THAT(rec.edge_mm, WithinAbs(0.70f, 0.011f)); // ceil(34.64 / 50 = 0.693) at 0.01 - } - - SECTION("the world transform scales the tile against the model") - { - TextureDisplacementLayer layer; - layer.image_data = make_checkerboard_png(16, 16); - layer.tiling_scale = 8.f; - // Scaled up 3x the cube is 60 mm; the texel is still 0.5 mm in world terms, so the edge holds. const V2Resolution rec = recommend_v2_resolution(cube, { layer }, Transform3d(Eigen::Scaling(3.0))); - CHECK_THAT(rec.edge_mm, WithinAbs(0.5f, 1e-4f)); - const V2Resolution plain = recommend_v2_resolution(cube, { layer }); - CHECK(rec.budget_k > plain.budget_k); // nine times the area wants more triangles + CHECK_THAT(rec.edge_mm, WithinAbs(0.42f, 1e-4f)); // 103.9 / 250 = 0.4157 -> 0.42 } - SECTION("no usable texture gives no recommendation") + SECTION("a tiny model stops at the 0.05 mm floor") { - TextureDisplacementLayer empty; - const V2Resolution rec = recommend_v2_resolution(cube, { empty }); - CHECK(rec.edge_mm == 0.f); + const indexed_triangle_set small = its_make_cube(2.f, 2.f, 2.f); + const V2Resolution rec = recommend_v2_resolution(small, { layer }); + CHECK_THAT(rec.edge_mm, WithinAbs(0.05f, 1e-4f)); + } + + SECTION("the texture's sharpness class is still measured") + { + const TextureDetail sharp = analyze_texture_detail(layer); + CHECK_THAT(sharp.pixels_per_edge, WithinAbs(1.f, 1e-6f)); + TextureDisplacementLayer flat; + flat.image_data = make_flat_gray_png(128, 16, 16); + CHECK_THAT(analyze_texture_detail(flat).pixels_per_edge, WithinAbs(4.f, 1e-6f)); } } @@ -1464,6 +1452,122 @@ static std::vector unwrap_charts_are_disks(const PatchUnwrap &u) return disks; } +TEST_CASE("TextureDisplacement: the default pipeline bakes an unwrap (LSCM) layer", "[TextureDisplacement]") +{ + // The one-run pipeline samples per point, and an unwrap has no per-point formula, so unwrap layers + // used to be dropped from it altogether: the bake moved nothing, and the job then cleared the paint + // as if it had baked - "paint, unwrap, bake, and the painted region just disappears". + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_flat_gray_png(255); + layer.depth_mm = 0.5f; + layer.tiling_scale = 10.f; + layer.projection_method = TextureProjectionMethod::LSCM; + + TextureDisplacementFacetsData facets; + facets[0] = paint_whole_mesh(cube); + + TextureDisplacementOptions options; + options.pipeline_v2 = true; + options.v2_refine_mm = 2.f; + options.v2_max_triangles_k = 0; + const indexed_triangle_set out = build_texture_displacement(cube, { layer }, facets, options); + REQUIRE(!out.vertices.empty()); + + // A flat white texture at depth 0.5 pushes the faces out by 0.5, so the bounding box grows on + // every side (face interiors move the full depth; only corners, moving along their blended normal, + // move less). Before the fix it stayed exactly [0, 10]. + Vec3f lo = out.vertices.front(), hi = lo; + for (const Vec3f &v : out.vertices) { + lo = lo.cwiseMin(v); + hi = hi.cwiseMax(v); + } + CHECK(lo.x() < -0.4f); + CHECK(hi.z() > 10.4f); +} + +TEST_CASE("Per-corner LSCM UVs give each triangle its own island's placement", "[TextureDisplacement]") +{ + // compute_lscm_uvs() has to collapse a seam vertex onto one chart, because displacement is + // per vertex. Everything that samples per *triangle* must not: a cube corner belongs to three + // faces, so the collapse handed a triangle at an unjoined seam a neighbouring island's placement. + // That showed up as one visibly skewed triangle per face, and as every island's texture following + // the lowest-numbered island whenever it was dragged. + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + const PatchUnwrap unwrap = compute_patch_unwrap(cube, 30.f, 0.f); + REQUIRE(unwrap.chart_count == 6); + // The map back to the patch's own triangle order, without which there are no per-corner UVs. + REQUIRE(unwrap.source_face.size() == unwrap.indices.size()); + + TextureDisplacementLayer layer; + layer.projection_method = TextureProjectionMethod::LSCM; + layer.lscm_seam_angle_deg = 30.f; + layer.islands = compute_connected_net(unwrap); + REQUIRE(layer.islands.size() == 6); + // Move one island by hand. Its neighbours must stay exactly where they were. + layer.islands[0].offset += Vec2f(37.f, -19.f); + + const std::vector corner = compute_lscm_corner_uvs(cube, layer); + REQUIRE(corner.size() == cube.indices.size() * 3); + + for (size_t t = 0; t < unwrap.indices.size(); ++t) { + const size_t f = size_t(unwrap.source_face[t]); + REQUIRE(f < cube.indices.size()); + // A triangle lies in exactly one chart, so any of its corners names that chart. + const int c = unwrap.vertex_chart[size_t(unwrap.indices[t][0])]; + for (int k = 0; k < 3; ++k) { + const Vec2f want = apply_island_transform(unwrap.uvs[size_t(unwrap.indices[t][k])], c, unwrap, layer.islands); + CHECK_THAT(corner[f * 3 + size_t(k)].x(), WithinAbs(want.x(), 1e-4)); + CHECK_THAT(corner[f * 3 + size_t(k)].y(), WithinAbs(want.y(), 1e-4)); + } + } + + // And the per-vertex path must disagree somewhere - otherwise this test proves nothing, because + // the bug it guards against is precisely that the two were the same thing. + const std::vector per_vertex = compute_lscm_uvs(cube, layer); + REQUIRE(per_vertex.size() == cube.vertices.size()); + bool differs = false; + for (size_t f = 0; f < cube.indices.size() && !differs; ++f) + for (int k = 0; k < 3; ++k) + if ((corner[f * 3 + size_t(k)] - per_vertex[size_t(cube.indices[f][k])]).norm() > 1e-3f) + differs = true; + CHECK(differs); +} + +TEST_CASE("The Cylindrical/Spherical patch frame is the bake's own, so a preview can share it", "[TextureDisplacement]") +{ + // The fast preview reconstructs these two projections in the fragment shader and needs the very + // centroid and axis the bake wraps around - a patch-only normal average would sometimes quantize + // to a different world axis and wrap the texture the other way round. + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + const std::vector normals = texture_displacement_vertex_normals(cube); + REQUIRE(normals.size() == cube.vertices.size()); + + // Just the +X face: its average normal is +X, so the cylinder axis must be a world axis + // perpendicular to it, and the centroid must sit on that face. + indexed_triangle_set face; + face.vertices = cube.vertices; + for (const stl_triangle_vertex_indices &t : cube.indices) { + const Vec3f n = (cube.vertices[size_t(t[1])] - cube.vertices[size_t(t[0])]) + .cross(cube.vertices[size_t(t[2])] - cube.vertices[size_t(t[0])]); + if (n.normalized().x() > 0.99f) + face.indices.push_back(t); + } + REQUIRE(face.indices.size() == 2); + + Vec3f center, axis, average_normal; + texture_displacement_patch_frame(face, normals, center, axis, average_normal); + CHECK_THAT(center.x(), WithinAbs(10., 1e-4)); + CHECK_THAT(std::abs(axis.x()), WithinAbs(0., 1e-4)); // never the face's own normal direction + CHECK_THAT(axis.norm(), WithinAbs(1., 1e-4)); + + // An empty patch must not divide by zero; it falls back to +Z. + texture_displacement_patch_frame(indexed_triangle_set{}, normals, center, axis, average_normal); + CHECK_THAT(center.norm(), WithinAbs(0., 1e-6)); + CHECK_THAT(axis.z(), WithinAbs(1., 1e-6)); +} + TEST_CASE("A cube unwraps into one island per face, laid out as a connected net", "[TextureDisplacement]") { const indexed_triangle_set cube = its_make_cube(10., 10., 10.); @@ -1595,3 +1699,267 @@ TEST_CASE("TextureDisplacement: moving the model about the plate does not move t max_diff = std::max(max_diff, (moved.vertices[i] - at_origin.vertices[i]).norm()); CHECK(max_diff < 1e-3f); } + +// Longest edge over the shortest altitude: 1.15 for an equilateral triangle, 2 for a right isosceles +// one, unbounded for a needle. +static float triangle_aspect(const Vec3f &a, const Vec3f &b, const Vec3f &c) +{ + const float longest = std::max({ (b - a).norm(), (c - b).norm(), (a - c).norm() }); + const float twice_area = (b - a).cross(c - a).norm(); + return twice_area > 0.f ? longest * longest / twice_area : std::numeric_limits::infinity(); +} + +TEST_CASE("TextureDisplacement: baking a second face leaves the first face's relief untouched", "[TextureDisplacement]") +{ + // Paint the top of a cube and bake, then paint the front of the *result* and bake again, the way + // the gizmo does (each bake replaces the mesh and clears the baked paint). The top is unpainted the + // second time round, so it is excluded from refinement and pinned by the displacement: every + // vertex of its relief must still be there, in the same number of triangles, and no needle may + // appear on it. + const indexed_triangle_set cube = subdivide_mesh_uniform(its_make_cube(20., 20., 20.), 2.f, 6); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_checkerboard_png(16, 16); + layer.tiling_scale = 5.f; + layer.depth_mm = 0.5f; + + TextureDisplacementOptions options; + options.pipeline_v2 = true; + options.v2_refine_mm = 0.5f; + options.v2_max_triangles_k = 0; // no simplification + + // Paints exactly the triangles whose three corners satisfy `on_face` - no brush spill. + const auto paint_where = [](const indexed_triangle_set &mesh, auto on_face) { + const TriangleMesh tm(mesh); + TriangleSelector selector(tm); + for (size_t f = 0; f < mesh.indices.size(); ++f) { + const stl_triangle_vertex_indices &t = mesh.indices[f]; + if (on_face(mesh.vertices[size_t(t[0])]) && on_face(mesh.vertices[size_t(t[1])]) && + on_face(mesh.vertices[size_t(t[2])])) + selector.set_facet(int(f), EnforcerBlockerType::ENFORCER); + } + return selector.serialize(); + }; + const auto on_top = [](const Vec3f &v) { return v.z() > 19.9f; }; + const auto on_front = [](const Vec3f &v) { return v.y() < 0.1f; }; + + // The top region of a result: its vertices, and its triangles' count and worst aspect ratio. + struct TopRegion + { + std::vector vertices; + size_t triangles = 0; + float max_aspect = 0.f; + }; + const auto top_region = [&on_top](const indexed_triangle_set &mesh) { + TopRegion r; + for (const Vec3f &v : mesh.vertices) + if (on_top(v)) + r.vertices.push_back(v); + for (const stl_triangle_vertex_indices &t : mesh.indices) { + const Vec3f &a = mesh.vertices[size_t(t[0])], &b = mesh.vertices[size_t(t[1])], &c = mesh.vertices[size_t(t[2])]; + if (!on_top(a) || !on_top(b) || !on_top(c)) + continue; + ++r.triangles; + r.max_aspect = std::max(r.max_aspect, triangle_aspect(a, b, c)); + } + return r; + }; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_where(cube, on_top); + const indexed_triangle_set first = build_texture_displacement(cube, { layer }, facets, options); + REQUIRE(first.indices.size() > cube.indices.size()); + const TopRegion top_before = top_region(first); + REQUIRE(top_before.triangles > 0); + // The relief really is there: the checkerboard raises part of the top by the full depth. + float top_z_max = 0.f; + for (const Vec3f &v : top_before.vertices) + top_z_max = std::max(top_z_max, v.z()); + REQUIRE(top_z_max > 20.3f); + REQUIRE(top_before.max_aspect < 20.f); + + // Only the front of the baked mesh is painted for the second bake. + facets[0] = paint_where(first, on_front); + REQUIRE_FALSE(facets[0].triangles_to_split.empty()); + + const auto check_top_untouched = [&](const indexed_triangle_set &second) { + REQUIRE_FALSE(second.indices.empty()); // an aborted bake returns {} + const TopRegion top_after = top_region(second); + + // Every vertex of the first relief still exists, at the same place. O(n*m) over a few + // thousand vertices each, restricted to the top region on both sides. + size_t missing = 0; + Vec3f first_missing = Vec3f::Zero(); + for (const Vec3f &v : top_before.vertices) { + bool found = false; + for (const Vec3f &w : top_after.vertices) + if ((w - v).squaredNorm() <= 1e-6f) { // within 1e-3 mm + found = true; + break; + } + if (!found) { + if (missing == 0) + first_missing = v; + ++missing; + } + } + INFO("first vertex of the top relief missing from the second bake: " << first_missing.transpose()); + CHECK(missing == 0); + + // Nothing was added to or taken from the top either - its triangles are excluded from the + // refinement, and a rim edge it shares with the front was already at the refine length. + CHECK(top_after.triangles == top_before.triangles); + + // And no needles: the worst triangle on the top is no worse than after the first bake. + INFO("worst top-face aspect ratio after the second bake: " << top_after.max_aspect + << ", after the first: " << top_before.max_aspect); + CHECK(top_after.max_aspect < 20.f); + }; + + SECTION("bake mode: no simplification") + { + check_top_untouched(build_texture_displacement(first, { layer }, facets, options)); + } + + SECTION("export mode: simplification and T-junction repair run over the excluded region too") + { + // A budget far below the mesh forces the decimation (the locked top alone is over it, so it + // only harvests flat faces) and with it the repair pass - the two stages that walk every face, + // excluded ones included. + TextureDisplacementOptions export_options = options; + export_options.v2_max_triangles_k = 1; + check_top_untouched(build_texture_displacement(first, { layer }, facets, export_options)); + } +} + +TEST_CASE("TextureDisplacement: a brush stroke smaller than a triangle displaces only the stroke", "[TextureDisplacement]") +{ + // A plain 12-triangle cube. The selector splits the top triangle under a 3 mm spherical brush, + // so the painted pieces are far smaller than the triangle. The one-run pipeline used to include + // the whole source triangle: the entire top face rose. + const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f); + const TriangleMesh mesh(cube); + int top = -1; + for (size_t t = 0; t < cube.indices.size() && top < 0; ++t) { + const auto &f = cube.indices[t]; + if (cube.vertices[size_t(f[0])].z() > 19.9f && cube.vertices[size_t(f[1])].z() > 19.9f && + cube.vertices[size_t(f[2])].z() > 19.9f) + // The triangle that contains the face centre: the brush starts there. + for (int k = 0; k < 3; ++k) + if ((cube.vertices[size_t(f[k])] - Vec3f(10.f, 10.f, 20.f)).norm() < 15.f) + top = int(t); + } + REQUIRE(top >= 0); + TriangleSelector selector(mesh); + selector.select_patch(top, + TriangleSelector::SinglePointCursor::cursor_factory( + Vec3f(10.f, 10.f, 20.f), Vec3f(10.f, 10.f, 100.f), 3.f, TriangleSelector::CursorType::SPHERE, + Transform3d::Identity(), TriangleSelector::ClippingPlane()), + EnforcerBlockerType::ENFORCER, Transform3d::Identity(), /* triangle_splitting */ true); + TextureDisplacementFacetsData facets; + facets[0] = selector.serialize(); + REQUIRE(TriangleSelector::has_facets(facets[0], EnforcerBlockerType::ENFORCER)); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_flat_gray_png(255, 8, 8); // uniform full height: every painted point rises + layer.tiling_scale = 4.f; + layer.depth_mm = 0.5f; + TextureDisplacementOptions options; + options.pipeline_v2 = true; + options.v2_refine_mm = 0.5f; + options.v2_max_triangles_k = 0; + const indexed_triangle_set out = build_texture_displacement(cube, { layer }, facets, options); + REQUIRE(out.indices.size() > cube.indices.size()); + + size_t raised_inside = 0, raised_outside = 0, outside = 0; + for (const Vec3f &v : out.vertices) { + if (v.z() < 19.9f) + continue; // not the top face + const float r = (Vec2f(v.x(), v.y()) - Vec2f(10.f, 10.f)).norm(); + if (r < 2.f && v.z() > 20.3f) + ++raised_inside; + if (r > 4.5f) { + ++outside; + if (v.z() > 20.01f) + ++raised_outside; + } + } + CHECK(raised_inside > 0); // the stroke itself is displaced + CHECK(outside > 0); + CHECK(raised_outside == 0); // the rest of the face, inside the same source triangle, is not +} + +TEST_CASE("TextureDisplacement: a texture of flat colours is told apart from a continuous one", "[TextureDisplacement]") +{ + // The verdict that decides whether a layer may use filament mixes: a checkerboard is two colours, + // a ramp spreads over every level. + TextureDisplacementLayer checker; + checker.image_data = make_checkerboard_png(32, 32); + CHECK(analyze_texture_detail(checker).flat_colors); + + std::vector ramp(64 * 64); + for (size_t y = 0; y < 64; ++y) + for (size_t x = 0; x < 64; ++x) + ramp[y * 64 + x] = uint8_t((x * 4 + y) & 255); + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), 64, 64, ramp)); + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + TextureDisplacementLayer gradient; + gradient.image_data = std::make_shared>(std::move(bytes)); + const TextureDetail d = analyze_texture_detail(gradient); + CHECK_FALSE(d.flat_colors); + CHECK(d.flat_share < 0.85f); +} + +TEST_CASE("Each layer's texture is sampled only on its own painted area", "[TextureDisplacement]") +{ + // Two layers with different textures and depths, one painted on the cube's top, one on its -X side. + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + const TriangleMesh cube_mesh(cube); + const auto paint_facing = [&](const Vec3f &dir) { + TriangleSelector selector(cube_mesh); + for (int f = 0; f < int(cube.indices.size()); ++f) { + const stl_triangle_vertex_indices &t = cube.indices[size_t(f)]; + const Vec3f n = (cube.vertices[size_t(t[1])] - cube.vertices[size_t(t[0])]) + .cross(cube.vertices[size_t(t[2])] - cube.vertices[size_t(t[0])]) + .normalized(); + if (n.dot(dir) > 0.99f) + selector.set_facet(f, EnforcerBlockerType::ENFORCER); + } + return selector.serialize(); + }; + TextureDisplacementFacetsData facets{}; + facets[0] = paint_facing(Vec3f::UnitZ()); + facets[1] = paint_facing(-Vec3f::UnitX()); + + TextureDisplacementLayer top; + top.slot = 0; + top.depth_mm = 1.0f; + top.tiling_scale = 5.0f; + top.image_data = make_flat_gray_png(255); + TextureDisplacementLayer side = top; + side.slot = 1; + side.depth_mm = 0.5f; + side.image_data = make_flat_gray_png(64); + + const HeightFieldSampler both = make_combined_displacement_sampler(cube, { top, side }, facets); + const HeightFieldSampler top_only = make_combined_displacement_sampler(cube, { top }, facets); + const HeightFieldSampler side_only = make_combined_displacement_sampler(cube, { side }, facets); + REQUIRE(both); + REQUIRE(top_only); + REQUIRE(side_only); + + const Vec3f on_top(5.f, 5.f, 10.f), on_side(0.f, 5.f, 5.f), unpainted(5.f, 10.f, 5.f); + CHECK_THAT(both(on_top, Vec3f::UnitZ()), WithinAbs(top_only(on_top, Vec3f::UnitZ()), 1e-5f)); + CHECK_THAT(both(on_side, -Vec3f::UnitX()), WithinAbs(side_only(on_side, -Vec3f::UnitX()), 1e-5f)); + CHECK_THAT(both(unpainted, Vec3f::UnitY()), WithinAbs(0.f, 1e-6f)); +}