Compare commits

...
Author SHA1 Message Date
ExPikaPaka 1abd6649c7 Put the UV orientation tests beside the projection tests
They were appended at the end of test_texture_displacement.cpp, where every
other open texture-displacement branch also appends, so the four of them
cannot be merged in any order without a conflict in a file none of them
really disagree about. Same tests, moved next to the patch-frame test they
belong with.
2026-10-12 13:58:28 +02:00
ExPikaPaka 56b559d36a Fix texture projection UV orientation
An image laid onto a model came out upside down under the ordinary box
(tri-planar) projection, and on three of a cube's six faces it was also
mirrored left to right, which reads as a 180 degree rotation.

DecodedHeightTexture::sample() defines v = 0 as the height map's first
pixel row, i.e. its top. Every projection built v from world +Z, so v
grew upward and the image landed bottom row up. The one path that was
already right is the projective "project from view" matrix, which
measures window y downward.

Both halves of the box basis were wrong:

- v is now negated on all three world planes, so the top of an image
  sits at the top of the model.
- u now carries a sign for the side of the plane the face is on, chosen
  so that u x v == -normal. Without it the two faces sharing a plane get
  one u and one of them is mirrored. The sign flips exactly where that
  plane's blend weight (the fourth power of a normal component) reaches
  zero, so the blended tri-planar sum stays continuous and the hard axis
  switch the old code removed does not come back.

The same v convention was missing from the other mapping modes:
cylindrical (which also had a left handed frame, right x up rather than
up x right, so its wrap ran mirrored), spherical, the affine branch of
view projection, and the flat chart fallback of the LSCM unwrap, which
now reuses the box basis so a cube face unwraps to the orientation
tri-planar would have given it.

The basis table is exported as box_projection_axes() and the gizmo's
adjust_tangent_basis() calls it instead of restating it, so the
placement ring and offset handle cannot drift from the bake. All four
preview shaders get the matching change, including projection_axes(),
whose tangent and bitangent have to flip with the coordinate or the
relief shading inverts.

Re-baking an existing project now places its texture upright, so it will
differ from how it looked before. Geometry already baked into a 3mf is
untouched, so nothing changes until the user re-bakes.

Verified by measuring the sampled texture row and column against world
position for every mapping mode on all six faces of a cube, and by a
full bake of a quadrant coded image, which lands each quadrant within
1e-3 mm of its expected depth.
2026-10-12 07:57:50 +02:00
9 changed files with 538 additions and 91 deletions
@@ -72,17 +72,21 @@ varying float weight;
varying float island_active; varying float island_active;
varying vec2 vertex_uv; varying vec2 vertex_uv;
// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including // The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right
// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so // handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the
// the preview wraps the texture the same way round as the bake. // cylinder is to the viewer's right rather than mirrored.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) 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); 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); 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)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // 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(), // 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. // project_cylindrical() and project_spherical(). `n` is read by the planar mode only.
@@ -94,7 +98,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -102,10 +106,18 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * 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 // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0)
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); // sits at the top of the model instead of upside down, and u is flipped on the far side of each
// plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two
// siblings, term for term.
vec3 an = abs(n);
if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -124,7 +136,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); vec2 xy = vec2(dot(rel, right), dot(rel, fwd));
float r = length(xy); float r = length(xy);
t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it 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 b = -up; // v is the distance *against* the axis
return; return;
} }
if (projection_mode == 2) { if (projection_mode == 2) {
@@ -133,20 +145,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0);
float c = length(dir.xy); // cos(latitude) 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); 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 b = -cross(dir, t); // v grows away from the pole, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return; return;
} }
vec3 an = abs(n); vec3 an = abs(n); // the same signed basis projection_raw() uses
if (an.x >= an.y && an.x >= an.z) { // planar = p.yz if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z)
t = vec3(0.0, 1.0, 0.0); t = vec3(0.0, box_face_sign(n.x), 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z)
t = vec3(1.0, 0.0, 0.0); t = vec3(-box_face_sign(n.y), 0.0, 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else { // planar = p.xy } else { // planar = (+-x, -y)
t = vec3(1.0, 0.0, 0.0); t = vec3(box_face_sign(n.z), 0.0, 0.0);
b = vec3(0.0, 1.0, 0.0); b = vec3(0.0, -1.0, 0.0);
} }
} }
@@ -36,14 +36,20 @@ varying vec4 world_pos;
varying float distortion; varying float distortion;
varying vec2 vertex_uv; varying vec2 vertex_uv;
// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows
// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) 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); 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); 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)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm.
vec2 projection_raw(vec3 p, vec3 n) vec2 projection_raw(vec3 p, vec3 n)
{ {
@@ -53,7 +59,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { if (projection_mode == 2) {
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -61,10 +67,16 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
} }
// v negated so the image's first pixel row sits at the top of the model, u flipped on the far side
// of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz().
vec3 an = abs(n); 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); if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
vec2 project_uv(vec3 p, vec3 n) vec2 project_uv(vec3 p, vec3 n)
@@ -138,17 +138,21 @@ in vec2 vertex_uv;
out vec4 out_color; out vec4 out_color;
// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including // The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right
// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so // handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the
// the preview wraps the texture the same way round as the bake. // cylinder is to the viewer's right rather than mirrored.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) 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); 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); 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)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // 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(), // 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. // project_cylindrical() and project_spherical(). `n` is read by the planar mode only.
@@ -160,7 +164,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -168,10 +172,18 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * 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 // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0)
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); // sits at the top of the model instead of upside down, and u is flipped on the far side of each
// plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two
// siblings, term for term.
vec3 an = abs(n);
if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -190,7 +202,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); vec2 xy = vec2(dot(rel, right), dot(rel, fwd));
float r = length(xy); float r = length(xy);
t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it 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 b = -up; // v is the distance *against* the axis
return; return;
} }
if (projection_mode == 2) { if (projection_mode == 2) {
@@ -199,20 +211,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0);
float c = length(dir.xy); // cos(latitude) 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); 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 b = -cross(dir, t); // v grows away from the pole, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return; return;
} }
vec3 an = abs(n); vec3 an = abs(n); // the same signed basis projection_raw() uses
if (an.x >= an.y && an.x >= an.z) { // planar = p.yz if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z)
t = vec3(0.0, 1.0, 0.0); t = vec3(0.0, box_face_sign(n.x), 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z)
t = vec3(1.0, 0.0, 0.0); t = vec3(-box_face_sign(n.y), 0.0, 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else { // planar = p.xy } else { // planar = (+-x, -y)
t = vec3(1.0, 0.0, 0.0); t = vec3(box_face_sign(n.z), 0.0, 0.0);
b = vec3(0.0, 1.0, 0.0); b = vec3(0.0, -1.0, 0.0);
} }
} }
@@ -47,14 +47,20 @@ in vec2 vertex_uv;
out vec4 out_color; out vec4 out_color;
// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows
// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) 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); 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); 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)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm.
vec2 projection_raw(vec3 p, vec3 n) vec2 projection_raw(vec3 p, vec3 n)
{ {
@@ -64,7 +70,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { if (projection_mode == 2) {
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -72,10 +78,16 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
} }
// v negated so the image's first pixel row sits at the top of the model, u flipped on the far side
// of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz().
vec3 an = abs(n); 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); if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
vec2 project_uv(vec3 p, vec3 n) vec2 project_uv(vec3 p, vec3 n)
+101 -22
View File
@@ -491,6 +491,57 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set
return out; return out;
} }
namespace {
// Which side of a world plane a face is on, with an exactly zero component counted as the positive
// side. The tie never shows: a normal component of zero carries zero tri-planar blend weight (see
// triplanar_weights()), so the plane it orients contributes nothing at that point.
inline float box_face_sign(float n) { return n >= 0.f ? 1.f : -1.f; }
// The box projection's 2D basis for one of the three world planes, oriented the way a viewer
// standing outside the model sees that face.
//
// v runs *down* the image, because that is what v means to the sampler: v = 0 is the texture's first
// pixel row, i.e. its top (DecodedHeightTexture::sample(); the UV editor negates Y on screen for the
// same reason, see UVEditorCanvas::render()). So v = -z on the four upright planes and v = -y on the
// horizontal one, which is what puts the top of an image at the top of the model rather than
// upside down.
//
// u then has to depend on which *side* of the plane the face is on, or one of the two faces sharing
// a plane comes out left-right mirrored: with a single u per plane, a portrait image reads upright on
// +X and 180 degrees round on -X. Fixing that is the sign below, chosen so that u x v == -normal,
// i.e. (right, down, into the surface) is right-handed for a viewer outside:
//
// face u v face u v face u v
// +X +y -z +Y -x -z +Z +x -y
// -X -y -z -Y +x -z -Z -x -y
//
// The sign flips where its own normal component crosses zero, and the blend weight of the plane it
// belongs to is exactly zero there, so the blended tri-planar sum stays continuous across the flip -
// it is not a return of the hard axis switch described on TextureProjectionMethod::Triplanar.
inline Vec2f box_plane_yz(const Vec3f &p, float nx) { return Vec2f(box_face_sign(nx) * p.y(), -p.z()); }
inline Vec2f box_plane_xz(const Vec3f &p, float ny) { return Vec2f(-box_face_sign(ny) * p.x(), -p.z()); }
inline Vec2f box_plane_xy(const Vec3f &p, float nz) { return Vec2f(box_face_sign(nz) * p.x(), -p.y()); }
} // namespace
void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis)
{
// The same table as box_plane_yz()/box_plane_xz()/box_plane_xy(), handed out as a pair of world
// directions for the plane the normal is closest to. Two shapes of one table, because the bake's
// hot path wants the closed form and the GUI wants the axes; the test suite checks them against
// each other so they cannot drift apart.
const Vec3f n = normal.cwiseAbs();
if (n.x() >= n.y() && n.x() >= n.z()) {
u_axis = box_face_sign(normal.x()) * Vec3f::UnitY();
v_axis = -Vec3f::UnitZ();
} else if (n.y() >= n.x() && n.y() >= n.z()) {
u_axis = -box_face_sign(normal.y()) * Vec3f::UnitX();
v_axis = -Vec3f::UnitZ();
} else {
u_axis = box_face_sign(normal.z()) * Vec3f::UnitX();
v_axis = -Vec3f::UnitY();
}
}
Vec2f project_planar(const Vec3f &position, const Vec3f &normal) Vec2f project_planar(const Vec3f &position, const Vec3f &normal)
{ {
// Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called // Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called
@@ -501,23 +552,26 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal)
// other face in the same patch - exactly the bug an earlier version of this feature had). // other face in the same patch - exactly the bug an earlier version of this feature had).
const Vec3f n = normal.cwiseAbs(); const Vec3f n = normal.cwiseAbs();
if (n.x() >= n.y() && n.x() >= n.z()) if (n.x() >= n.y() && n.x() >= n.z())
return Vec2f(position.y(), position.z()); return box_plane_yz(position, normal.x());
if (n.y() >= n.x() && n.y() >= n.z()) if (n.y() >= n.x() && n.y() >= n.z())
return Vec2f(position.x(), position.z()); return box_plane_xz(position, normal.y());
return Vec2f(position.x(), position.y()); return box_plane_xy(position, normal.z());
} }
namespace { namespace {
// Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at // Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at
// this point's own radius, v is the signed distance along the axis - a reasonable approximation // this point's own radius, v is the distance along the axis, measured *against* it - a reasonable
// for roughly cylindrical selections, not an exact fit for arbitrary geometry. // approximation for roughly cylindrical selections, not an exact fit for arbitrary geometry.
Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis) Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis)
{ {
Vec3f up = patch_axis; Vec3f up = patch_axis;
up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ(); up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ();
const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX();
const Vec3f right = Vec3f(up.cross(arbitrary).normalized()); const Vec3f right = Vec3f(up.cross(arbitrary).normalized());
const Vec3f fwd = Vec3f(right.cross(up).normalized()); // up x right, not right x up: that makes (right, fwd, up) right-handed, so the angle below grows
// anticlockwise about the axis - which, seen from outside the cylinder, is to the viewer's right.
// The other way round the texture wraps on mirrored, as if seen through the model from behind.
const Vec3f fwd = Vec3f(up.cross(right).normalized());
const Vec3f rel = position - patch_center; const Vec3f rel = position - patch_center;
const float along_axis = rel.dot(up); const float along_axis = rel.dot(up);
@@ -526,7 +580,9 @@ Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, cons
const float radius = std::sqrt(x * x + y * y); const float radius = std::sqrt(x * x + y * y);
const float angle = std::atan2(y, x); const float angle = std::atan2(y, x);
return Vec2f(angle * radius, along_axis); // Negated, like every other projection's v: v = 0 is the texture's top row, so v has to grow
// *down* the axis for the top of the image to sit at the top of the model.
return Vec2f(angle * radius, -along_axis);
} }
// Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the // Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the
@@ -542,7 +598,9 @@ Vec2f project_spherical(const Vec3f &position, const Vec3f &patch_center)
const Vec3f dir = rel / radius; const Vec3f dir = rel / radius;
const float longitude = std::atan2(dir.y(), dir.x()); const float longitude = std::atan2(dir.y(), dir.x());
const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f)); const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f));
return Vec2f(longitude, latitude) * radius; // Longitude already grows to the right as seen from outside; latitude grows toward the north
// pole, so it is negated to put the image's top row (v = 0) at the top of the model.
return Vec2f(longitude, -latitude) * radius;
} }
// CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but // CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but
@@ -837,9 +895,26 @@ std::vector<int> segment_into_charts(const indexed_triangle_set &mesh, const std
// solve at all, and why this also serves as the fallback for a chart LSCM cannot handle. // solve at all, and why this also serves as the fallback for a chart LSCM cannot handle.
std::vector<Vec2f> project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal) std::vector<Vec2f> project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal)
{ {
// Oriented like the box projection (see box_projection_axes()), so a chart reads the right way up
// and unmirrored instead of being flattened to whichever basis a cross product happened to give.
// For a chart whose normal *is* a world axis - a cube face, which is the common case once the
// seam angle has cut the patch along its sharp edges - this is exactly the box basis, so such a
// face unwraps to the same orientation Triplanar would have given it.
Vec3f u, v;
box_projection_axes(normal, u, v);
// The box axes lie in the tangent plane already when the normal is a world axis; for any other
// normal, u is squared up against it and v follows. u x v == -normal keeps the chart the way
// round a viewer outside the model sees it (a plain normal x u would mirror it).
u = u - normal * normal.dot(u);
if (u.norm() <= 1e-6f) {
// Out of reach for a unit normal - the box basis puts u perpendicular to the normal's own
// dominant axis, so the two can never be within 45 degrees of each other - but a caller
// handing over an unnormalized normal should still get a usable basis rather than a NaN.
const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX();
const Vec3f u = Vec3f(seed.cross(normal).normalized()); u = seed.cross(normal);
const Vec3f v = Vec3f(normal.cross(u).normalized()); }
u = Vec3f(u.normalized());
v = Vec3f(u.cross(normal).normalized());
std::vector<Vec2f> uvs(chart.vertices.size()); std::vector<Vec2f> uvs(chart.vertices.size());
for (size_t i = 0; i < chart.vertices.size(); ++i) for (size_t i = 0; i < chart.vertices.size(); ++i)
@@ -1814,8 +1889,10 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
return texture.sample(uv, layer.tile_enabled, layer.tile_method); return texture.sample(uv, layer.tile_enabled, layer.tile_method);
} }
// Flat projection onto the captured projector plane. Single-valued per point, so unlike // Flat projection onto the captured projector plane. Single-valued per point, so unlike
// blended triplanar it is one sample, and it is what "project from view" places. // blended triplanar it is one sample, and it is what "project from view" places. The
return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); // projector's up axis is negated because v runs *down* the image (v = 0 is its first pixel
// row), the same reason the projective branch above measures window y downward.
return sample_at(Vec2f(position.dot(layer.view_project_right), -position.dot(layer.view_project_up)));
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar: case TextureProjectionMethod::Triplanar:
default: break; default: break;
@@ -1830,14 +1907,15 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
const float w_sum = w.x() + w.y() + w.z(); const float w_sum = w.x() + w.y() + w.z();
if (w_sum < 1e-8f) if (w_sum < 1e-8f)
// Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either. // Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either.
return sample_at(Vec2f(position.x(), position.y())); return sample_at(box_plane_xy(position, normal.z()));
w /= w_sum; w /= w_sum;
// Each plane drops the axis it is named for, matching project_planar()'s own convention (which // Each plane drops the axis it is named for and is oriented for the side of that plane the normal
// the GUI's on-canvas placement gizmo also relies on). // points to, matching project_planar()'s own convention (which the GUI's on-canvas placement
return w.x() * sample_at(Vec2f(position.y(), position.z())) + // gizmo also relies on). See box_plane_yz() for why the orientation is not simply (y, z).
w.y() * sample_at(Vec2f(position.x(), position.z())) + return w.x() * sample_at(box_plane_yz(position, normal.x())) +
w.z() * sample_at(Vec2f(position.x(), position.y())); w.y() * sample_at(box_plane_xz(position, normal.y())) +
w.z() * sample_at(box_plane_xy(position, normal.z()));
} }
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer, bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
@@ -1899,7 +1977,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method); out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method);
return true; return true;
} else { } else {
const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up)); // Negated up, exactly as sample_layer_height() does it.
const Vec2f p(position.dot(layer.view_project_right), -position.dot(layer.view_project_up));
if (!covered(p)) if (!covered(p))
return false; return false;
out = sample_at(p); out = sample_at(p);
@@ -1915,7 +1994,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
Vec3f w = triplanar_weights(normal); Vec3f w = triplanar_weights(normal);
const float w_sum = w.x() + w.y() + w.z(); const float w_sum = w.x() + w.y() + w.z();
if (w_sum < 1e-8f) { if (w_sum < 1e-8f) {
const Vec2f p(position.x(), position.y()); const Vec2f p = box_plane_xy(position, normal.z());
if (!covered(p)) if (!covered(p))
return false; return false;
out = sample_at(p); out = sample_at(p);
@@ -1926,8 +2005,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
// A blend of three planes is only "not covered" where *every* contributing plane is outside the // A blend of three planes is only "not covered" where *every* contributing plane is outside the
// placement; where some are, the covered ones are renormalised so the colour does not fade toward // placement; where some are, the covered ones are renormalised so the colour does not fade toward
// black at the edge of an untiled tri-planar layer. // black at the edge of an untiled tri-planar layer.
const std::array<Vec2f, 3> planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()), const std::array<Vec2f, 3> planes = { box_plane_yz(position, normal.x()), box_plane_xz(position, normal.y()),
Vec2f(position.x(), position.y()) }; box_plane_xy(position, normal.z()) };
Vec3f acc = Vec3f::Zero(); Vec3f acc = Vec3f::Zero();
float acc_w = 0.f; float acc_w = 0.f;
for (int i = 0; i < 3; ++i) for (int i = 0; i < 3; ++i)
+17 -4
View File
@@ -524,11 +524,24 @@ struct TextureColorSettings
bool empty() const { return palette.empty(); } bool empty() const { return palette.empty(); }
}; };
// The two world directions the box projection takes its u and v along on a face whose outward
// normal is `normal`: the plane is the one that normal is closest to, and the pair is oriented as a
// viewer standing outside the model sees it - u to their right, v *down* the image, which is the
// direction v runs in the texture (v = 0 is the image's first pixel row, see
// DecodedHeightTexture::sample()). So an image comes out the way it looks in a file manager rather
// than upside down, and it is not mirrored on the three faces facing the other way.
//
// Exposed because the GUI needs the same basis for the on-canvas "adjust texture placement" gizmo
// (its ring's zero angle and its offset handle's plane), and it must not be a second, drifting copy
// of the table.
void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis);
// Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any // Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any
// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather // layer), dropping the axis position that best aligns with `normal`. Equivalent to projecting onto
// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas // box_projection_axes()' pair. Exposed on its own (rather than only inline inside
// "adjust texture placement" gizmo - can map a dragged 3D point into the exact same 2D space // project_texture_displacement_uv()) so GUI code - the on-canvas "adjust texture placement" gizmo -
// tiling_scale/rotation_deg/offset operate in, without duplicating the axis-selection logic. // can map a dragged 3D point into the exact same 2D space tiling_scale/rotation_deg/offset operate
// in, without duplicating the axis-selection logic.
Vec2f project_planar(const Vec3f &position, const Vec3f &normal); Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate // Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate
@@ -1252,8 +1252,9 @@ std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in
uv[vi] = Vec2f(-1e6f, -1e6f); uv[vi] = Vec2f(-1e6f, -1e6f);
continue; continue;
} }
// Negated up, as sample_layer_height()'s affine branch has it: v runs down the image.
const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right), const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right),
patch.vertices[vi].dot(layer.view_project_up)); -patch.vertices[vi].dot(layer.view_project_up));
uv[vi] = apply_uv_transform(planar, layer, aspect); uv[vi] = apply_uv_transform(planar, layer, aspect);
} }
return uv; return uv;
@@ -3292,19 +3293,10 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement
void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const
{ {
// Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and // libslic3r's own basis, asked for rather than restated here, so the ring's "0 degrees" and the
// the offset handle's plane always agree with what project_texture_displacement_uv() does. // offset handle's plane always agree with what project_texture_displacement_uv() does - including
const Vec3f n = m_adjust_anchor_normal.cwiseAbs(); // v pointing *down* the texture and u flipping on the far side of each plane.
if (n.x() >= n.y() && n.x() >= n.z()) { box_projection_axes(m_adjust_anchor_normal, u_axis, v_axis);
u_axis = Vec3f::UnitY();
v_axis = Vec3f::UnitZ();
} else if (n.y() >= n.x() && n.y() >= n.z()) {
u_axis = Vec3f::UnitX();
v_axis = Vec3f::UnitZ();
} else {
u_axis = Vec3f::UnitX();
v_axis = Vec3f::UnitY();
}
} }
void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() void GLGizmoTextureDisplacement::render_adjust_texture_gizmo()
@@ -423,8 +423,8 @@ private:
// obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's // obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's
// foreground draw list, so it must be called from inside the gizmo's ImGui frame. // foreground draw list, so it must be called from inside the gizmo's ImGui frame.
void render_paint_cursor_hint(); void render_paint_cursor_hint();
// Mesh-local tangent-plane basis at m_adjust_anchor_normal, matching project_planar()'s // Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// dominant-axis convention so dragging on-canvas maps consistently onto offset. // on-canvas maps consistently onto offset whichever face the anchor is on.
void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const; void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const;
// The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface. // The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface.
@@ -1652,6 +1652,321 @@ TEST_CASE("Per-corner LSCM UVs give each triangle its own island's placement", "
CHECK(differs); CHECK(differs);
} }
// ---------------------------------------------------------------------------------------------
// UV orientation: which way up a texture lands on the model.
//
// v = 0 is the height map's *first* pixel row, i.e. its top (DecodedHeightTexture::sample(), and
// the UV editor negates Y on screen for the same reason). So "the image is the right way up" is the
// statement that the row a point samples gets *lower* as the point moves up the face, and "it is
// not left-right mirrored" is that the column gets higher as the point moves to the right of a
// viewer standing outside the model. Both are measured below by sampling a ramp whose value says
// which row (or column) it came from, which settles the question without having to reason about the
// uv transform at all.
static constexpr int RAMP_N = 64; // the ramp texture's size, in texels
static constexpr float RAMP_TILING = 40.f; // mm per tile
static constexpr float CUBE_HALF = 5.f; // half extent of the cube the probes walk over
// How far a probe steps off a face centre. Short on purpose: the cylindrical and spherical u is an
// arc length (angle * radius), so a long step also changes the radius, and that term swamps the
// angular one. It is symmetric about the face centre, so at a short step it cancels between the two
// readings a check compares.
static constexpr float RAMP_STEP = 1.f;
// What one step's worth of travel is in ramp rows: 2 * RAMP_STEP mm of separation, RAMP_N texels per
// RAMP_TILING mm. Halved as the margin a check demands, so "flat" cannot pass as either direction.
static constexpr double RAMP_MARGIN = RAMP_N * RAMP_STEP / RAMP_TILING;
// A height map whose rows (or columns) carry a linear ramp: row r holds 3 + 4r, so the sampled
// height identifies the row. Built directly rather than through a PNG because these tests are about
// the projection, not the decoder - decode_height_texture() has its own tests.
static DecodedHeightTexture ramp_texture(bool by_row)
{
DecodedHeightTexture t;
t.width = t.height = RAMP_N;
t.pixels.resize(size_t(RAMP_N) * size_t(RAMP_N));
for (int y = 0; y < RAMP_N; ++y)
for (int x = 0; x < RAMP_N; ++x)
t.pixels[size_t(y) * size_t(RAMP_N) + size_t(x)] = uint8_t(3 + 4 * (by_row ? y : x));
return t;
}
// uv 0.5 at the cube's centre, and a tile wide enough that every point the probes visit stays well
// inside the one tile - so no reading is a wrapped-around neighbour of the one it is compared with.
static TextureDisplacementLayer ramp_layer(TextureProjectionMethod method)
{
TextureDisplacementLayer layer;
layer.projection_method = method;
layer.tiling_scale = RAMP_TILING;
layer.offset = Vec2f(0.5f, 0.5f);
layer.depth_mm = 1.f;
return layer;
}
// Which row (or column) of `ramp` the layer reads at `p`. Fractional, because a point between two
// texels reads the ramp between them; only the direction it moves in matters here.
static double ramp_index(const DecodedHeightTexture &ramp, const TextureDisplacementLayer &layer, const Vec3f &p,
const Vec3f &n, const Vec3f &center = Vec3f(Vec3f::Zero()),
const Vec3f &axis = Vec3f(Vec3f::UnitZ()))
{
return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0;
}
// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on
// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y.
static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up)
{
up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f);
right = Vec3f((-n).cross(up).normalized());
}
static const std::array<std::pair<const char *, Vec3f>, 6> CUBE_FACES = { {
{ "+X", Vec3f(1.f, 0.f, 0.f) },
{ "-X", Vec3f(-1.f, 0.f, 0.f) },
{ "+Y", Vec3f(0.f, 1.f, 0.f) },
{ "-Y", Vec3f(0.f, -1.f, 0.f) },
{ "+Z", Vec3f(0.f, 0.f, 1.f) },
{ "-Z", Vec3f(0.f, 0.f, -1.f) },
} };
TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube",
"[TextureDisplacement]")
{
const DecodedHeightTexture rows = ramp_texture(true);
const DecodedHeightTexture cols = ramp_texture(false);
const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar);
for (const auto &[name, n] : CUBE_FACES) {
DYNAMIC_SECTION("face " << name)
{
Vec3f right, up;
outside_viewer_frame(n, right, up);
const Vec3f centre = n * CUBE_HALF;
// Upright: the row read near the top of the face is nearer the image's first row.
const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n);
const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n);
INFO("row at the top of the face " << row_up << ", at the bottom " << row_down);
CHECK(row_down - row_up > RAMP_MARGIN);
// Unmirrored: the column read to the viewer's right is further into the image. Without a
// per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which
// is the mirroring a bare v flip leaves behind.
const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n);
const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n);
INFO("column at the left of the face " << col_left << ", at the right " << col_right);
CHECK(col_right - col_left > RAMP_MARGIN);
}
}
}
TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]")
{
const DecodedHeightTexture rows = ramp_texture(true);
// ViewProjected is one flat projection from the captured camera rather than a per-face map, so its
// "up" is the projector's own (view_project_up), which the default capture puts at +Y.
const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical,
TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected);
const TextureDisplacementLayer layer = ramp_layer(method);
for (const auto &[name, n] : CUBE_FACES) {
// Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its
// caps) and a sphere's is at an extremum (they are its poles), so neither says anything about
// which way up the image is.
if (std::abs(n.z()) > 0.9f)
continue;
DYNAMIC_SECTION("method " << int(method) << ", face " << name)
{
const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up :
Vec3f(0.f, 0.f, 1.f);
const Vec3f centre = n * CUBE_HALF;
const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n);
const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n);
INFO("row at the top " << row_up << ", at the bottom " << row_down);
CHECK(row_down - row_up > RAMP_MARGIN);
}
}
}
TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]")
{
const DecodedHeightTexture cols = ramp_texture(false);
const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical);
const TextureDisplacementLayer layer = ramp_layer(method);
for (const auto &[name, n] : CUBE_FACES) {
// The caps and poles are out, as above - u is undefined on the axis. So is whichever face the
// atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both
// projections, and a reading taken across it compares two opposite sides of the model.
if (std::abs(n.z()) > 0.9f)
continue;
const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f);
if (on_the_cut)
continue;
DYNAMIC_SECTION("method " << int(method) << ", face " << name)
{
Vec3f right, up;
outside_viewer_frame(n, right, up);
const Vec3f centre = n * CUBE_HALF;
const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n);
const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n);
INFO("column at the left " << col_left << ", at the right " << col_right);
CHECK(col_right - col_left > RAMP_MARGIN);
}
}
}
TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]")
{
indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF);
for (Vec3f &v : cube.vertices)
v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF);
const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG);
REQUIRE_FALSE(unwrap.empty());
REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges
// Each chart carries one face. The four upright ones span world Z, and their v has to grow
// downwards like every other projection's - whatever the packing then did with the island.
int upright_charts = 0;
for (int c = 0; c < unwrap.chart_count; ++c) {
int top = -1, bottom = -1;
for (size_t i = 0; i < unwrap.uvs.size(); ++i) {
if (unwrap.vertex_chart[i] != c)
continue;
const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z();
if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z())
top = int(i);
if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z())
bottom = int(i);
}
REQUIRE(top >= 0);
REQUIRE(bottom >= 0);
const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y();
if (std::abs(v_top - v_bottom) < 1e-3f)
continue; // a cap: the whole chart is at one height, so it has no up
++upright_charts;
DYNAMIC_SECTION("chart " << c)
{
INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom);
CHECK(v_bottom - v_top > 1.f);
}
}
CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above
}
TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]")
{
// The two express the same table in different shapes - one a pair of world axes for the GUI, the
// other the closed form the bake's hot path uses - so they are checked against each other here
// rather than left to drift apart.
const Vec3f p(2.f, -3.f, 7.f);
for (const auto &[name, n] : CUBE_FACES) {
DYNAMIC_SECTION("face " << name)
{
Vec3f u_axis, v_axis;
box_projection_axes(n, u_axis, v_axis);
const Vec2f planar = project_planar(p, n);
CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f));
CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f));
// Right-handed for a viewer outside the model: u to their right, v down, so u x v points
// into the surface. Getting this wrong is exactly what mirrors a face.
CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f));
}
}
}
// An image with a different value in each quadrant, so one bake says both which way up it landed
// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the
// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0.
static std::shared_ptr<std::vector<unsigned char>> make_quadrant_marker_png(size_t w = 32, size_t h = 32)
{
std::vector<uint8_t> pixels(w * h);
for (size_t y = 0; y < h; ++y)
for (size_t x = 0; x < w; ++x)
pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0);
const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png");
REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels));
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);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]")
{
// An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four
// quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex
// normal is then exactly the face normal, which is what makes the expected depth below an exact
// number instead of a tri-planar blend of three of them.
constexpr int STEPS = 4; // cells per side, so 5x5 vertices
constexpr float SPAN = 20.f; // mm, the wall's width and height
indexed_triangle_set wall;
for (int j = 0; j <= STEPS; ++j)
for (int i = 0; i <= STEPS; ++i)
wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f,
SPAN * float(j) / float(STEPS));
const auto at = [](int i, int j) { return j * (STEPS + 1) + i; };
for (int j = 0; j < STEPS; ++j)
for (int i = 0; i < STEPS; ++i) {
// Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin.
wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1));
wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1));
}
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(wall);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.projection_method = TextureProjectionMethod::Triplanar;
layer.image_data = make_quadrant_marker_png();
layer.depth_mm = 2.f;
// The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in
// both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a
// whole number of texels away from both.
layer.tiling_scale = 40.f;
layer.offset = Vec2f(0.5f, 0.75f);
// A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by
// the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With
// tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way.
layer.tile_enabled = false;
const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options());
REQUIRE(result.vertices.size() == wall.vertices.size());
// Seen from outside this face the viewer's right is +X and their up is +Z, so the image's
// top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value
// whose quadrant it must have read.
const std::array<std::tuple<const char *, int, int, int>, 4> expected = { {
{ "top-left (255)", 1, 3, 255 },
{ "top-right (170)", 3, 3, 170 },
{ "bottom-left (85)", 1, 1, 85 },
{ "bottom-right (0)", 3, 1, 0 },
} };
for (const auto &[name, i, j, value] : expected) {
DYNAMIC_SECTION("quadrant " << name)
{
const size_t vi = size_t(at(i, j));
// The whole displacement is along -Y, so this is the relief's height at that vertex.
const float raised = wall.vertices[vi].y() - result.vertices[vi].y();
INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z());
CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f));
// Nothing moved sideways or vertically: a mirrored or rotated basis would still have
// displaced along the normal, so this is here to catch the relief leaning instead.
CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f));
CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f));
}
}
}
TEST_CASE("The Cylindrical/Spherical patch frame is the bake's own, so a preview can share it", "[TextureDisplacement]") 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 // The fast preview reconstructs these two projections in the fragment shader and needs the very