Texture displacement: per-layer bake, UV pane redesign, unwrap and layer view fixes

- Bake: each layer is sampled only on its own painted area; analytic
  projections used to stack every layer over every painted region, so the
  top layer's texture showed on all of them (colour sampler too)
- Auto resolution follows the texture's texel size and sharpness again
- Unwrap: charts cut by each face's own normal (a cube gives 6 islands, not
  12 triangles); non-disk charts (tubes, closed shells) are split until they
  flatten; connected nets test real triangle overlap, grow from the largest
  chart and are packed side by side
- UV edits are stored per unwrapped copy, so dragging a seam vertex no
  longer moves its copies in neighbouring islands
- UV pane: tool strip with unwrap settings moved in from the panel, sharp
  HiDPI icons, clearer island/edge/selection drawing with hover, texture
  picker from the thumbnail, texture no longer lost on reopen (GL state
  from the 3D view, background upload retries)
- Panel: whole-model select/erase as icons in the tools row; inactive
  layers' paint shown muted; colour textures shown in colour in the picker
- Built-in displacement texture library
- Tests for unwrap segmentation, connected nets, UV edits and per-layer
  sampling
This commit is contained in:
ExPikaPaka
2026-09-21 08:59:26 +02:00
parent 21a31660d2
commit be6c67758a
52 changed files with 2561 additions and 355 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><polygon points="0.5 3 6 5.5 11.5 3 6 0.5 0.5 3" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><polyline points="0.5 3 0.5 9.5 6 12 8 11.1" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><polyline points="11.5 3 11.5 8" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><line x1="6" y1="5.5" x2="6" y2="12" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><line x1="10" y1="10" x2="15" y2="15" style="fill:none;stroke:#009688;stroke-linecap:round;stroke-linejoin:round"/><line x1="15" y1="10" x2="10" y2="15" style="fill:none;stroke:#009688;stroke-linecap:round;stroke-linejoin:round"/></svg>

After

Width:  |  Height:  |  Size: 792 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><polygon points="0.5 3.5 7.5 6.5 7.5 14.5 0.5 11.5 0.5 3.5" style="fill:#009688;fill-opacity:0.55"/><polygon points="7.5 6.5 14.5 3.5 14.5 11.5 7.5 14.5 7.5 6.5" style="fill:#009688;fill-opacity:0.3"/><polygon points="0.5 3.5 7.5 6.5 14.5 3.5 7.5 0.5 0.5 3.5" style="fill:#009688"/><polygon points="0.5 3.5 7.5 6.5 14.5 3.5 7.5 0.5 0.5 3.5" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><polyline points="0.5 3.5 0.5 11.5 7.5 14.5 14.5 11.5 14.5 3.5" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><line x1="7.5" y1="6.5" x2="7.5" y2="14.5" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/></svg>

After

Width:  |  Height:  |  Size: 769 B

@@ -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);
}
@@ -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)
@@ -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);
}
@@ -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].
Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

+477
View File
@@ -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()
+5
View File
@@ -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)
@@ -98,7 +98,7 @@ struct HeapEntry
DecimateResult decimate(const TriSoup &geometry, size_t target_triangles, bool harvest_flat,
double harvest_tol, const std::vector<uint8_t> &locked_faces,
const DecimateProgressFn &on_progress)
const DecimateProgressFn &on_progress, const std::vector<int> &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();
@@ -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<uint8_t> &locked_faces = {},
const DecimateProgressFn &on_progress = {});
const DecimateProgressFn &on_progress = {},
const std::vector<int> &face_color = {});
} // namespace TextureBake
} // namespace Slic3r
@@ -1,5 +1,8 @@
#include "TextureBakePipeline.hpp"
#include <tbb/blocked_range.h>
#include <tbb/parallel_for.h>
#include "TextureBakeDebug.hpp"
#include <algorithm>
@@ -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<uint8_t> &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<uint8_t> soft_excluded;
if (settings.painted) {
const size_t nf = sub.geometry.triangle_count();
const bool have_w = !sub.geometry.exclude_weight.empty();
std::vector<uint8_t> unpainted(nf, 0);
tbb::parallel_for(tbb::blocked_range<size_t>(0, nf), [&](const tbb::blocked_range<size_t> &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<uint8_t> locked;
@@ -247,12 +291,40 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
std::vector<uint8_t> 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<int> 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<size_t>(0, nf), [&](const tbb::blocked_range<size_t> &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");
@@ -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<bool(const Vec3f &centroid)> 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<bool(const char *stage, double fraction)>;
// 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<int(const Vec3f &centroid, const Vec3f &normal)>;
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<uint8_t> &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);
+506 -144
View File
@@ -4,6 +4,7 @@
#include <array>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <cassert>
#include <chrono>
#include <cmath>
@@ -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<uint32_t> 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<uint32_t>());
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<std::mutex> 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<TextureDisplacementLayer> &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<double>::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<double>::max()), bmax = -bmin;
std::vector<Vec3d> world(mesh.vertices.size());
for (size_t i = 0; i < world.size(); ++i) {
world[i] = volume_to_world * mesh.vertices[i].cast<double>();
bmin = bmin.cwiseMin(world[i]);
bmax = bmax.cwiseMax(world[i]);
Vec3d bmin = Vec3d::Constant(std::numeric_limits<double>::max()), bmax = -bmin;
for (const Vec3f &v : mesh.vertices) {
const Vec3d w = volume_to_world * v.cast<double>();
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<Vec2f> uvs;
std::vector<int> to_patch; // chart vertex -> patch vertex
std::vector<stl_triangle_vertex_indices> 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<int> 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<Vec2f> compute_lscm_uvs(const indexed_triangle_set &patch, const Tex
return per_vertex;
}
std::vector<Vec2f> 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<Vec2f> 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<Vec3f> texture_displacement_vertex_normals(const indexed_triangle_set &its)
std::vector<Vec3f> texture_displacement_vertex_normals(const indexed_triangle_set &its)
{
std::vector<Vec3f> 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<int
}
} // namespace
void merge_small_color_regions(const indexed_triangle_set &mesh, std::vector<int> &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<Vec3i32> 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<int> component(n, -1);
std::vector<int> faces;
std::vector<size_t> start;
std::vector<float> area;
std::vector<int> 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<int> 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<std::pair<int, float>> 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<int, float> &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<int, float> &l, const std::pair<int, float> &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<uint8_t> 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<int> 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<uint8_t> out_color(out.indices.size(), 0);
const ColorFieldSampler sampler = make_combined_color_sampler(mesh, layers, facets_data, color->quantize);
if (want_color) {
std::vector<uint8_t> 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<int> palette(out.indices.size(), -1);
tbb::parallel_for(tbb::blocked_range<size_t>(0, out.indices.size()), [&](const tbb::blocked_range<size_t> &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<int> patch_source; // sub-triangle -> base mesh triangle, only built when colouring
const indexed_triangle_set patch =
selector.get_facets_strict(EnforcerBlockerType::ENFORCER, color_this_layer ? &patch_source : nullptr);
@@ -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<Vec2f> lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_uvs(patch, *layer) :
std::vector<Vec2f>{};
// The colour pass below samples per *triangle*, so it takes the per-corner unwrap instead: the
// per-vertex collapse above would hand a triangle at a seam the island layout did not join its
// neighbour's placement, painting one triangle per face from the wrong part of the texture.
// (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.)
const std::vector<Vec2f> lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_corner_uvs(patch, *layer) :
std::vector<Vec2f>{};
const bool corner_uv_ok = !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<uint8_t> out_color(mesh.indices.size(), 0);
for (size_t i = 0; i < mesh.indices.size(); ++i) {
if (triangle_palette[i] < 0)
continue;
const stl_triangle_vertex_indices &t = mesh.indices[i];
const Vec3f centroid = (mesh.vertices[size_t(t[0])] + mesh.vertices[size_t(t[1])] +
mesh.vertices[size_t(t[2])]) / 3.f;
const int filament = color->resolve ? color->resolve(triangle_palette[i], centroid)
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<Vec3f> &vertex_normals,
Vec3f &center, 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<uint8_t>
namespace {
// One decoded texture + placement per sampleable layer, in blend (slot) order. Held by shared_ptr so
// the returned closure owns it for as long as the subdivider keeps calling back.
// 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<Vec2f> corner; // compute_lscm_corner_uvs(patch, layer)
// False when `pos` is not on this layer's patch (farther than `tol`): there is no uv there, so the
// layer contributes nothing - the same as a non-tiled texture outside its placement.
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<const LscmLookup> 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<const PatchRegion> 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<std::vector<PreparedLayer>> prepare_sampleable_layers(
const std::vector<Vec3f> 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<std::vector<PreparedLayer>> 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<const LscmLookup> 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<LscmLookup>();
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<const PatchRegion> region;
if (!lscm && area_3d(patch) < 0.9999f * mesh_area) {
auto r = std::make_shared<PatchRegion>();
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<std::vector<PreparedLayer>> prepare_sampleable_layers(
ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &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<std::vector<uint8_t>>(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;
+80 -19
View File
@@ -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<int(const Vec3f &)>;
// criterion: that criterion asks where the **perceived** colour changes, and must not see the
// interleaving. Refining on every band or dither-cell boundary would spend the whole triangle budget
// drawing a pattern the eye is supposed to blend away.
using ColorResolveFn = std::function<int(int palette_index, const Vec3f &pos)>;
using ColorResolveFn = std::function<int(int palette_index, const Vec3f &pos, const Vec3f &normal)>;
// 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<PrintableColor> palette;
std::vector<PrintableColor> 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<Vec2f> uvs; // one per unwrapped vertex, in mm
std::vector<int> source_vertex; // unwrapped vertex -> index into patch.vertices
std::vector<int> vertex_chart; // unwrapped vertex -> chart (island) id
std::vector<stl_triangle_vertex_indices> 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<stl_triangle_vertex_indices> indices;
std::vector<int> 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<Vec2f> 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<TextureIs
PatchUnwrap compute_patch_unwrap(const indexed_triangle_set &patch, float seam_angle_deg = LSCM_DEFAULT_SEAM_ANGLE_DEG,
float padding_mm = -1.f, const std::vector<std::pair<int, int>> &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<Vec2f> 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<Vec2f> 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<uint8_t> *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<int> &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<Vec3f> 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<Vec3f> &vertex_normals,
Vec3f &center, 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<int(const Vec3f &pos, const Vec3f &norma
ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &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<TextureDisplacementLayer> &layers,
@@ -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<float, 2, 3>::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<Vec2f> 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 &center, 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<Vec2f> 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<Vec2f> 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<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer);
if (per_vertex.size() != local_patch.vertices.size())
return {};
std::vector<Vec2f> corner(local_patch.indices.size() * 3);
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<Vec2f> vertex_uv = active != nullptr ? compute_layer_vertex_uvs(patch, *active) : std::vector<Vec2f>{};
m_bump_preview_uses_vertex_uv = vertex_uv.size() == patch.vertices.size();
std::vector<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector<Vec2f>{};
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<int>{}, 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<int> &charts)
void GLGizmoTextureDisplacement::compute_bump_active_faces(const std::vector<int> &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<Vec3f> *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<uintptr_t>(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<Vec3f> *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<int> &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<uint8_t> 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<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer);
const bool have_uvs = uv.size() == patch.vertices.size();
m_uvcheck_uses_vertex_uv = have_uvs;
m_uvcheck_projection_mode = have_uvs ? 0 :
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<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer);
const bool have_corner_uvs = have_uvs && corner_uv.size() == patch.indices.size() * 3;
// Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible.
std::vector<float> 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<int>{ 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<float, 2, 3>::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<float, 2, 3>::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<TriangleSelectorPatch>(*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<std::string> 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<ColorRGBA> 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<P
const float band = std::max(layer_height, 0.01f);
const float cell = std::max(cell_mm, 0.01f);
return [entries, mode, band, cell](int index, const Vec3f &pos) -> int {
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<P
// Which of the two filaments this point falls on. Both patterns are *ordered*, never random:
// the eye blends a regular pattern into a flat colour, and turns a random one into noise.
if (mode == ColorMixMode::ZBands) {
// Auto: bands wherever the surface is steeper than ~45 degrees - consecutive layers alternate
// there, which is how a blend prints and reads. On a flat-facing surface a layer is one band
// and the only way to interleave is a checkerboard across the surface, which at print scale
// reads as a pattern rather than a colour; there the mix falls back to its dominant filament.
const bool upright = std::abs(normal.z()) < 0.7f;
if (mode == ColorMixMode::Auto && !upright)
return e.num * 2 >= 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<float>::max();
int best = 0, best_pure = -1;
float best_d = std::numeric_limits<float>::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.") :
@@ -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<int> m_island_overlay_selection;
void rebuild_island_overlay(const std::vector<int> &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<uint8_t> m_bump_active_vertex;
std::vector<uint8_t> m_bump_active_face;
Eigen::Matrix<float, 2, 3> m_bump_baked_active_xf = Eigen::Matrix<float, 2, 3>::Identity();
Eigen::Matrix<float, 2, 3> m_bump_island_delta = Eigen::Matrix<float, 2, 3>::Identity();
void compute_bump_active_vertices(const std::vector<int> &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<int> &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<Vec2f> 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<Vec2f> 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 &center, 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();
@@ -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)
@@ -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);
+404 -36
View File
@@ -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<bool> 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<Vec2f> 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<Vec2f> 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<Vec3f> 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<float>::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<Vec3f> 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<uint8_t> 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<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
TextureDisplacementLayer gradient;
gradient.image_data = std::make_shared<std::vector<unsigned char>>(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));
}