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
@@ -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].
|
||||
|
||||
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 522 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 209 KiB |
@@ -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()
|
||||
@@ -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 ¢roid)> 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 ¢roid, 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);
|
||||
|
||||
@@ -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 ¢er, Vec3f &axis, Vec3f &average_normal)
|
||||
{
|
||||
Vec3f normal_sum = Vec3f::Zero();
|
||||
Vec3f centroid_sum = Vec3f::Zero();
|
||||
int count = 0;
|
||||
for (const stl_triangle_vertex_indices &tri : patch.indices)
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int vi = tri[i];
|
||||
centroid_sum += patch.vertices[size_t(vi)];
|
||||
++count;
|
||||
// A brush stroke that split a triangle appends new vertices past the base mesh's own, and
|
||||
// vertex_normals is sized to the base mesh, so those indices must be skipped here.
|
||||
if (vi < int(vertex_normals.size()))
|
||||
normal_sum += vertex_normals[size_t(vi)];
|
||||
}
|
||||
average_normal = (normal_sum.norm() > 1e-8f) ? Vec3f(normal_sum.normalized()) : Vec3f::UnitZ();
|
||||
center = (count > 0) ? Vec3f(centroid_sum / float(count)) : Vec3f::Zero();
|
||||
|
||||
// The world axis least aligned with the average normal - perpendicular to the outward radial
|
||||
// normal, as a cylinder's own axis would be.
|
||||
const Vec3f an = average_normal.cwiseAbs();
|
||||
axis = (an.x() <= an.y() && an.x() <= an.z()) ? Vec3f::UnitX() :
|
||||
(an.y() <= an.x() && an.y() <= an.z()) ? Vec3f::UnitY() : Vec3f::UnitZ();
|
||||
}
|
||||
|
||||
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world)
|
||||
{
|
||||
// World orientation and scale, but the origin moved to where the volume's own origin sits: the
|
||||
@@ -2704,11 +2966,89 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<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;
|
||||
|
||||
@@ -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 ¢er, Vec3f &axis, Vec3f &average_normal);
|
||||
|
||||
// Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from
|
||||
// `volume` and forwards to the overload above.
|
||||
indexed_triangle_set build_texture_displacement(const ModelVolume &volume);
|
||||
@@ -844,7 +904,8 @@ using ColorFieldSampler = std::function<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 ¢er, Vec3f &axis) const
|
||||
{
|
||||
center = Vec3f::Zero();
|
||||
axis = Vec3f::UnitZ();
|
||||
const ModelVolume *mv = texture_volume();
|
||||
if (mv == nullptr || (layer.projection_method != TextureProjectionMethod::Cylindrical &&
|
||||
layer.projection_method != TextureProjectionMethod::Spherical))
|
||||
return 0;
|
||||
// The bake averages the *whole mesh's* vertex normals over the patch's corners, so this has to as
|
||||
// well: a patch-only average would sometimes quantize to a different world axis and wrap the
|
||||
// texture the other way round. Both meshes go through patch_in_world() first, which is the frame
|
||||
// the shaders' tex_pos lives in.
|
||||
Vec3f average_normal;
|
||||
texture_displacement_patch_frame(patch_in_world(local_patch),
|
||||
texture_displacement_vertex_normals(patch_in_world(mv->mesh().its)),
|
||||
center, axis, average_normal);
|
||||
return layer.projection_method == TextureProjectionMethod::Cylindrical ? 1 : 2;
|
||||
}
|
||||
|
||||
std::vector<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 ¢er, Vec3f &axis) const;
|
||||
// `patch` with its vertices moved into world millimetres - the space the bake maps the texture in
|
||||
// (see build_texture_displacement()). Returned by value because the caller usually still needs the
|
||||
// original: the patch doubles as render geometry, which is drawn through the volume's own matrix.
|
||||
@@ -699,6 +757,10 @@ private:
|
||||
UVCheckMode m_uv_check_mode = UVCheckMode::None;
|
||||
GLModel m_uvcheck_glmodel;
|
||||
bool m_uvcheck_uses_vertex_uv = false;
|
||||
// As m_bump_projection_mode and friends, for the Checker overlay.
|
||||
int m_uvcheck_projection_mode = 0;
|
||||
Vec3f m_uvcheck_patch_center = Vec3f::Zero();
|
||||
Vec3f m_uvcheck_patch_axis = Vec3f::UnitZ();
|
||||
void rebuild_uvcheck_mesh();
|
||||
void render_uvcheck_mesh();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||