diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 0b9192749c..93449af5d4 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -72,17 +72,21 @@ varying float weight; varying float island_active; varying vec2 vertex_uv; -// 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. +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right +// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the +// cylinder is to the viewer's right rather than mirrored. 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)); + 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 // 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. @@ -94,7 +98,7 @@ vec2 projection_raw(vec3 p, vec3 n) 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)); + 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; @@ -102,10 +106,18 @@ vec2 projection_raw(vec3 p, vec3 n) 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; + 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); + // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0) + // 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 @@ -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)); 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 + b = -up; // v is the distance *against* the axis return; } 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); 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 + b = -cross(dir, t); // v grows away from the pole, 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); - b = vec3(0.0, 0.0, 1.0); - } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else { // planar = p.xy - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 1.0, 0.0); + vec3 an = abs(n); // the same signed basis projection_raw() uses + if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z) + t = vec3(0.0, box_face_sign(n.x), 0.0); + b = vec3(0.0, 0.0, -1.0); + } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z) + t = vec3(-box_face_sign(n.y), 0.0, 0.0); + b = vec3(0.0, 0.0, -1.0); + } else { // planar = (+-x, -y) + t = vec3(box_face_sign(n.z), 0.0, 0.0); + b = vec3(0.0, -1.0, 0.0); } } diff --git a/resources/shaders/110/texture_displacement_uvcheck.fs b/resources/shaders/110/texture_displacement_uvcheck.fs index f9e998adc2..37fd1826f2 100644 --- a/resources/shaders/110/texture_displacement_uvcheck.fs +++ b/resources/shaders/110/texture_displacement_uvcheck.fs @@ -36,14 +36,20 @@ varying vec4 world_pos; varying float distortion; 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) { 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)); + 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. vec2 projection_raw(vec3 p, vec3 n) { @@ -53,7 +59,7 @@ vec2 projection_raw(vec3 p, vec3 n) 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)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { vec3 rel = p - patch_center; @@ -61,10 +67,16 @@ vec2 projection_raw(vec3 p, vec3 n) 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; + 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); - 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) diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index 911a919aaf..9398b21102 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -138,17 +138,21 @@ in vec2 vertex_uv; out vec4 out_color; -// 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. +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right +// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the +// cylinder is to the viewer's right rather than mirrored. 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)); + 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 // 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. @@ -160,7 +164,7 @@ vec2 projection_raw(vec3 p, vec3 n) 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)); + 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; @@ -168,10 +172,18 @@ vec2 projection_raw(vec3 p, vec3 n) 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; + 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); + // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0) + // 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 @@ -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)); 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 + b = -up; // v is the distance *against* the axis return; } 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); 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 + b = -cross(dir, t); // v grows away from the pole, 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); - b = vec3(0.0, 0.0, 1.0); - } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else { // planar = p.xy - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 1.0, 0.0); + vec3 an = abs(n); // the same signed basis projection_raw() uses + if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z) + t = vec3(0.0, box_face_sign(n.x), 0.0); + b = vec3(0.0, 0.0, -1.0); + } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z) + t = vec3(-box_face_sign(n.y), 0.0, 0.0); + b = vec3(0.0, 0.0, -1.0); + } else { // planar = (+-x, -y) + t = vec3(box_face_sign(n.z), 0.0, 0.0); + b = vec3(0.0, -1.0, 0.0); } } diff --git a/resources/shaders/140/texture_displacement_uvcheck.fs b/resources/shaders/140/texture_displacement_uvcheck.fs index e6fc8036da..34f80df2bd 100644 --- a/resources/shaders/140/texture_displacement_uvcheck.fs +++ b/resources/shaders/140/texture_displacement_uvcheck.fs @@ -47,14 +47,20 @@ in vec2 vertex_uv; 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) { 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)); + 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. vec2 projection_raw(vec3 p, vec3 n) { @@ -64,7 +70,7 @@ vec2 projection_raw(vec3 p, vec3 n) 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)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { vec3 rel = p - patch_center; @@ -72,10 +78,16 @@ vec2 projection_raw(vec3 p, vec3 n) 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; + 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); - 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) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..40416e7277 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -491,6 +491,57 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set 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) { // 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). const Vec3f n = normal.cwiseAbs(); 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()) - return Vec2f(position.x(), position.z()); - return Vec2f(position.x(), position.y()); + return box_plane_xz(position, normal.y()); + return box_plane_xy(position, normal.z()); } namespace { // 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 -// for roughly cylindrical selections, not an exact fit for arbitrary geometry. +// this point's own radius, v is the distance along the axis, measured *against* it - a reasonable +// 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) { Vec3f up = patch_axis; 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 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 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 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 @@ -542,7 +598,9 @@ Vec2f project_spherical(const Vec3f &position, const Vec3f &patch_center) const Vec3f dir = rel / radius; const float longitude = std::atan2(dir.y(), dir.x()); 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 @@ -837,9 +895,26 @@ std::vector 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. std::vector project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal) { - const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); - const Vec3f u = Vec3f(seed.cross(normal).normalized()); - const Vec3f v = Vec3f(normal.cross(u).normalized()); + // 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(); + u = seed.cross(normal); + } + u = Vec3f(u.normalized()); + v = Vec3f(u.cross(normal).normalized()); std::vector uvs(chart.vertices.size()); 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); } // 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. - return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); + // blended triplanar it is one sample, and it is what "project from view" places. The + // 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::Triplanar: 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(); if (w_sum < 1e-8f) // 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; - // Each plane drops the axis it is named for, matching project_planar()'s own convention (which - // the GUI's on-canvas placement gizmo also relies on). - return w.x() * sample_at(Vec2f(position.y(), position.z())) + - w.y() * sample_at(Vec2f(position.x(), position.z())) + - w.z() * sample_at(Vec2f(position.x(), position.y())); + // Each plane drops the axis it is named for and is oriented for the side of that plane the normal + // points to, matching project_planar()'s own convention (which the GUI's on-canvas placement + // gizmo also relies on). See box_plane_yz() for why the orientation is not simply (y, z). + return w.x() * sample_at(box_plane_yz(position, normal.x())) + + 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, @@ -1899,7 +1977,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method); return true; } else { - const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up)); + // 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)) return false; out = sample_at(p); @@ -1915,7 +1994,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla Vec3f w = triplanar_weights(normal); const float w_sum = w.x() + w.y() + w.z(); if (w_sum < 1e-8f) { - const Vec2f p(position.x(), position.y()); + const Vec2f p = box_plane_xy(position, normal.z()); if (!covered(p)) return false; 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 // placement; where some are, the covered ones are renormalised so the colour does not fade toward // black at the edge of an untiled tri-planar layer. - const std::array planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()), - Vec2f(position.x(), position.y()) }; + const std::array planes = { box_plane_yz(position, normal.x()), box_plane_xz(position, normal.y()), + box_plane_xy(position, normal.z()) }; Vec3f acc = Vec3f::Zero(); float acc_w = 0.f; for (int i = 0; i < 3; ++i) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..f096c847ad 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -524,11 +524,24 @@ struct TextureColorSettings 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 -// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather -// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas -// "adjust texture placement" gizmo - 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. +// layer), dropping the axis position that best aligns with `normal`. Equivalent to projecting onto +// box_projection_axes()' pair. Exposed on its own (rather than only inline inside +// project_texture_displacement_uv()) so GUI code - the on-canvas "adjust texture placement" gizmo - +// 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); // Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..d39785ade7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1252,8 +1252,9 @@ std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in uv[vi] = Vec2f(-1e6f, -1e6f); 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), - patch.vertices[vi].dot(layer.view_project_up)); + -patch.vertices[vi].dot(layer.view_project_up)); uv[vi] = apply_uv_transform(planar, layer, aspect); } 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 { - // Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and - // the offset handle's plane always agree with what project_texture_displacement_uv() does. - const Vec3f n = m_adjust_anchor_normal.cwiseAbs(); - if (n.x() >= n.y() && n.x() >= n.z()) { - 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(); - } + // libslic3r's own basis, asked for rather than restated here, so the ring's "0 degrees" and the + // offset handle's plane always agree with what project_texture_displacement_uv() does - including + // v pointing *down* the texture and u flipping on the far side of each plane. + box_projection_axes(m_adjust_anchor_normal, u_axis, v_axis); } void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..4e2fcefccb 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -423,8 +423,8 @@ private: // 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. void render_paint_cursor_hint(); - // Mesh-local tangent-plane basis at m_adjust_anchor_normal, matching project_planar()'s - // dominant-axis convention so dragging on-canvas maps consistently onto offset. + // Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging + // on-canvas maps consistently onto offset whichever face the anchor is on. 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. diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 485f4f2a0f..37745beaa0 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2174,3 +2174,318 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } + +// --------------------------------------------------------------------------------------------- +// 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 ¢er = 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, 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> make_quadrant_marker_png(size_t w = 32, size_t h = 32) +{ + std::vector 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 bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(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, 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)); + } + } +}