Compare commits

..
Author SHA1 Message Date
ExPikaPaka 41daef0488 Carry the paint across with transfer_facet_paint
The branch used TriangleSelector::remap_painting(), which pairs a source
paint triangle with a target triangle overlapping it to within
TriangleCursor::tolerance. That is right after a remesh, which leaves the
surface where it was, and wrong after a bake, which has moved it by the
relief depth - most of the paint finds no partner. transfer_facet_paint()
asks the other question: every triangle of the new mesh takes the state
painted at its foot point on the old one, which is exactly where it came
from however deep the relief.

It also does all four paint channels rather than colour alone, and it runs
where the volume is read, so the snapshot and the timestamp that guarded
against the user painting while the bake ran are no longer needed: the
annotation is read at commit time and is by definition current.

The function and its call site are byte for byte what the model-UV branch
carries, so the two merge in any order and in either order produce one
mechanism rather than two fighting over mmu_segmentation_facets. The
regression test is unchanged except for the call it makes.
2026-10-12 14:12:40 +02:00
ExPikaPaka dfab16c2f4 Carry existing colour across a bake instead of reindexing it
Baking, painting colour, then baking again left colour debris on surfaces
nothing had painted and destroyed what the earlier bake had laid down.

TextureDisplacementBakeJob::finalize() deserialized the volume's pre-bake
mmu_segmentation_facets into a TriangleSelector built on the post-bake mesh,
on the assumption that the bake is topology-preserving. That holds for the
classic path, which moves vertices and leaves the topology alone, but not for
the one-run pipeline, which rebuilds the mesh. TriangleSelector::deserialize()
only checks that the stored triangle ids are in range, and a bake almost
always grows the triangle count, so every stale id passed that check and
landed on an unrelated face.

Measured on a 26 mm slab baked twice at 0.19 mm with a 750 k budget: 7580
triangles ended up carrying paint neither bake produced, as far as 31 mm from
any colour that was actually laid down, while the first pass's colour was lost
where the second bake left the surface alone.

TriangleSelector::remap_painting() already transfers paint between meshes by
position, and ModelVolume::restore_painting() uses it for the same reason, so
the bake job now uses it too. It runs in process(), on the worker thread: it
builds an AABB tree over both meshes and took around a second on the case
above, which would have frozen the UI in finalize(). The bake's own colour is
still applied last, so it wins where it painted and the rest is kept, which is
what makes successive bakes accumulate rather than overwrite. If the volume is
painted while the bake is running, a timestamp check falls back to the live
mask so that stroke is not dropped.

The out_triangle comment in TextureColorRequest claimed the bake is
topology-preserving, which is what justified the original indexing; it now
says which mesh the colours belong to.

The regression test bakes twice, checks both results are closed, and checks
the first bake's colour survives the second at the positions where it was
laid down and nowhere else.
2026-10-12 09:43:59 +02:00
10 changed files with 383 additions and 541 deletions
@@ -72,21 +72,17 @@ 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 - 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.
// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including
// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so
// the preview wraps the texture the same way round as the bake.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary));
fwd = normalize(cross(up, right));
fwd = normalize(cross(right, up));
}
// 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.
@@ -98,7 +94,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;
@@ -106,18 +102,10 @@ 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;
}
// 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);
vec3 an = abs(n); // Triplanar: drop the dominant normal axis
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
}
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -136,7 +124,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 *against* the axis
b = up; // v is the distance along the axis
return;
}
if (projection_mode == 2) {
@@ -145,20 +133,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); // v grows away from the pole, unit length
b = cross(dir, t); // increasing latitude, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return;
}
vec3 an = abs(n); // 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);
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);
}
}
@@ -36,20 +36,14 @@ 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(up, right));
fwd = normalize(cross(right, up));
}
// 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)
{
@@ -59,7 +53,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;
@@ -67,16 +61,10 @@ 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);
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);
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
}
vec2 project_uv(vec3 p, vec3 n)
@@ -138,21 +138,17 @@ in vec2 vertex_uv;
out vec4 out_color;
// 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.
// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including
// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so
// the preview wraps the texture the same way round as the bake.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary));
fwd = normalize(cross(up, right));
fwd = normalize(cross(right, up));
}
// 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.
@@ -164,7 +160,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;
@@ -172,18 +168,10 @@ 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;
}
// 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);
vec3 an = abs(n); // Triplanar: drop the dominant normal axis
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
}
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -202,7 +190,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 *against* the axis
b = up; // v is the distance along the axis
return;
}
if (projection_mode == 2) {
@@ -211,20 +199,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); // v grows away from the pole, unit length
b = cross(dir, t); // increasing latitude, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return;
}
vec3 an = abs(n); // 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);
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);
}
}
@@ -47,20 +47,14 @@ 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(up, right));
fwd = normalize(cross(right, up));
}
// 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)
{
@@ -70,7 +64,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;
@@ -78,16 +72,10 @@ 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);
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);
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
}
vec2 project_uv(vec3 p, vec3 n)
+78 -102
View File
@@ -491,57 +491,6 @@ 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
@@ -552,26 +501,23 @@ 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 box_plane_yz(position, normal.x());
return Vec2f(position.y(), position.z());
if (n.y() >= n.x() && n.y() >= n.z())
return box_plane_xz(position, normal.y());
return box_plane_xy(position, normal.z());
return Vec2f(position.x(), position.z());
return Vec2f(position.x(), position.y());
}
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 distance along the axis, measured *against* it - a reasonable
// approximation for roughly cylindrical selections, not an exact fit for arbitrary geometry.
// 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.
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());
// 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 fwd = Vec3f(right.cross(up).normalized());
const Vec3f rel = position - patch_center;
const float along_axis = rel.dot(up);
@@ -580,9 +526,7 @@ 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);
// 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);
return Vec2f(angle * radius, along_axis);
}
// Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the
@@ -598,9 +542,7 @@ 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));
// 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;
return Vec2f(longitude, latitude) * radius;
}
// CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but
@@ -895,26 +837,9 @@ 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.
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();
u = seed.cross(normal);
}
u = Vec3f(u.normalized());
v = Vec3f(u.cross(normal).normalized());
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());
std::vector<Vec2f> uvs(chart.vertices.size());
for (size_t i = 0; i < chart.vertices.size(); ++i)
@@ -1645,6 +1570,61 @@ std::vector<TextureIsland> compute_connected_net(const PatchUnwrap &unwrap)
return islands;
}
std::vector<uint8_t> transfer_facet_paint(const indexed_triangle_set &src_mesh,
const TriangleSelector::TriangleSplittingData &src_paint,
const indexed_triangle_set &dst_mesh)
{
if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_paint.bitstream.empty())
return {};
const TriangleMesh src(src_mesh);
TriangleSelector selector(src);
selector.deserialize(src_paint, false);
// Every sub-triangle of the source, each carrying its own state - NONE included, so the soup
// covers the whole surface and a foot point always lands on something. One soup rather than one
// tree per state: a foot point has one nearest triangle, and that triangle's state is the answer.
std::vector<indexed_triangle_set> per_state;
selector.get_facets(per_state);
indexed_triangle_set soup;
std::vector<uint8_t> label;
for (size_t st = 0; st < per_state.size(); ++st) {
const indexed_triangle_set &part = per_state[st];
if (part.indices.empty())
continue;
const int base = int(soup.vertices.size());
soup.vertices.insert(soup.vertices.end(), part.vertices.begin(), part.vertices.end());
for (const stl_triangle_vertex_indices &t : part.indices) {
soup.indices.emplace_back(t[0] + base, t[1] + base, t[2] + base);
label.push_back(uint8_t(st));
}
}
if (soup.indices.empty())
return {};
const AABBTreeIndirect::Tree3f tree =
AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(soup.vertices, soup.indices);
std::vector<uint8_t> out(dst_mesh.indices.size(), 0);
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst_mesh.indices.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t j = range.begin(); j < range.end(); ++j) {
const stl_triangle_vertex_indices &t = dst_mesh.indices[j];
// The centroid, not a corner: a facet takes one state, and its middle is the point that
// is inside it whatever the triangle looks like.
const Vec3f centroid = (dst_mesh.vertices[size_t(t[0])] + dst_mesh.vertices[size_t(t[1])] +
dst_mesh.vertices[size_t(t[2])]) / 3.f;
size_t hit = 0;
Vec3f foot;
const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(soup.vertices, soup.indices,
tree, centroid, hit, foot);
if (d2 >= 0.f && hit < label.size())
out[j] = label[hit];
}
});
return out;
}
void average_island_scales(std::vector<TextureIsland> &islands)
{
if (islands.empty())
@@ -1889,10 +1869,8 @@ 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. 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)));
// 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)));
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar:
default: break;
@@ -1907,15 +1885,14 @@ 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(box_plane_xy(position, normal.z()));
return sample_at(Vec2f(position.x(), position.y()));
w /= w_sum;
// 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()));
// 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()));
}
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
@@ -1977,8 +1954,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method);
return true;
} else {
// Negated up, exactly as sample_layer_height() does it.
const Vec2f p(position.dot(layer.view_project_right), -position.dot(layer.view_project_up));
const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up));
if (!covered(p))
return false;
out = sample_at(p);
@@ -1994,7 +1970,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 = box_plane_xy(position, normal.z());
const Vec2f p(position.x(), position.y());
if (!covered(p))
return false;
out = sample_at(p);
@@ -2005,8 +1981,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<Vec2f, 3> planes = { box_plane_yz(position, normal.x()), box_plane_xz(position, normal.y()),
box_plane_xy(position, normal.z()) };
const std::array<Vec2f, 3> planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()),
Vec2f(position.x(), position.y()) };
Vec3f acc = Vec3f::Zero();
float acc_w = 0.f;
for (int i = 0; i < 3; ++i)
+29 -17
View File
@@ -524,24 +524,11 @@ 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`. 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.
// 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.
Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate
@@ -591,6 +578,31 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
const Vec3f &patch_center = Vec3f::Zero(), const Vec3f &patch_axis = Vec3f::UnitZ(),
const Vec2f *lscm_uv = nullptr);
// Carries a per-facet paint annotation - the colour paint above all - from a mesh onto the mesh a
// bake produced from it: every triangle of `dst_mesh` takes the state painted at its own foot point on
// `src_mesh`, i.e. on the nearest point of the surface it was built from. Returned as one
// EnforcerBlockerType per `dst_mesh` triangle, 0 (NONE) where that foot point carried no paint.
//
// ModelVolume::restore_painting() cannot do this. Its remap pairs a source paint triangle with a
// target triangle that *overlaps* it to within 0.01 mm, which is right for a remesh or a
// simplification - they leave the surface where it was - and useless after a bake, which moves the
// surface by the relief depth. Measured on a model displaced by a tenth of its own size, that remap
// kept a quarter of the colour and left the rest unpainted; this keeps essentially all of it, because
// a foot point is exactly as far away as the relief is deep and the lookup does not care how far that
// is.
//
// The source's *sub*-triangles are what is looked up, not its triangles, so the part of a triangle a
// brush stroke only clipped a corner off keeps its own state rather than rounding to the whole
// triangle's. No distance limit is needed or wanted: the nearest point on the source surface is where
// a baked triangle came from however far the relief carried it, and an unpainted origin reports NONE
// on its own.
//
// Returns an empty vector when `src_paint` is empty or either mesh is - the caller then has nothing to
// carry, which is not the same as "carry nothing".
std::vector<uint8_t> transfer_facet_paint(const indexed_triangle_set &src_mesh,
const TriangleSelector::TriangleSplittingData &src_paint,
const indexed_triangle_set &dst_mesh);
// Area-weighted centroid and average normal of a layer's currently painted patch, in mesh-local
// coordinates - the same measurements build_texture_displacement() uses to pick its dominant
// projection axis. Used by the GUI to anchor the on-canvas "adjust texture placement" gizmo to
@@ -1252,9 +1252,8 @@ std::vector<Vec2f> 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;
@@ -3293,10 +3292,19 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement
void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const
{
// 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);
// 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();
}
}
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
// 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: box_projection_axes(), so dragging
// on-canvas maps consistently onto offset whichever face the anchor is on.
// 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.
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.
@@ -8,6 +8,7 @@
#include "libslic3r/TextureDisplacement.hpp"
#include <exception>
#include <cstddef>
#include <cstdint>
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
@@ -26,6 +27,7 @@
#include "slic3r/Utils/UndoRedo.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <array>
namespace Slic3r::GUI {
@@ -126,16 +128,71 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
{ mix.a_percent(), 100 - mix.a_percent() });
});
// Whether the bake replaced the triangles or only moved the vertices it was given. The classic
// path is topology-preserving by contract, so every per-triangle annotation on the volume still
// means what it did; the default pipeline refines, cuts and simplifies, so none of them do.
// Both are checked: the flag is what the bake promised, the count is what it delivered. Only
// the triangles matter - the annotations are per facet and reference no vertex index - so a
// vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a
// reason to remap anything.
const bool topology_changed = m_input.options.pipeline_v2 ||
m_result.its.indices.size() != m_input.base_mesh.indices.size();
// Carry the volume's existing paint - the model's own colours above all - across that rebuild.
// Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that
// no longer existed, which is what scrambled the colours of an imported coloured model even
// with colour switched off on every layer and nothing in the bake that was meant to touch them.
//
// transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants
// a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is
// nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact:
// triangle i is still triangle i, so the paint is already exactly right and carrying it at all
// could only lose detail.
std::array<TriangleSelector::TriangleSplittingData, 4> before;
if (topology_changed) {
before[0] = volume->supported_facets.get_data();
before[1] = volume->seam_facets.get_data();
before[2] = volume->mmu_segmentation_facets.get_data();
before[3] = volume->fuzzy_skin_facets.get_data();
}
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
if (topology_changed) {
const std::array<FacetsAnnotation *, 4> target{ &volume->supported_facets, &volume->seam_facets,
&volume->mmu_segmentation_facets,
&volume->fuzzy_skin_facets };
for (size_t ch = 0; ch < target.size(); ++ch) {
const std::vector<uint8_t> state =
transfer_facet_paint(m_input.base_mesh, before[ch], volume->mesh().its);
// Nothing was painted in this channel, or there is nothing to paint it onto: either
// way the annotation must not keep describing the mesh that is gone.
if (state.empty()) {
target[ch]->reset();
continue;
}
TriangleSelector selector(volume->mesh());
bool any = false;
for (size_t i = 0; i < state.size(); ++i)
if (state[i] != 0) {
selector.set_facet(int(i), EnforcerBlockerType(state[i]));
any = true;
}
if (any)
target[ch]->set(selector);
else
target[ch]->reset();
}
}
// Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there
// rather than replacing it: a triangle the texture does not colour keeps its existing filament,
// and one the user never painted at all stays at NONE, which already means "the volume's own
// filament". That is what confines the effect to the painted area without having to invent a
// colour for everything outside it. Safe to index straight onto the new mesh - the bake is
// topology-preserving, so triangle i is still triangle i.
// colour for everything outside it. What was already painted is whatever the transfer above
// left in the annotation, so this bake's colour lands on top of it rather than instead of it.
if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) {
TriangleSelector selector(volume->mesh());
const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
@@ -159,7 +216,6 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
// pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old
// topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and
// then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object().
const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(),
[slot](const TextureDisplacementLayer &l) { return l.slot == slot; });
+153 -315
View File
@@ -35,10 +35,12 @@
#include <boost/filesystem.hpp>
#include "libslic3r/AABBMesh.hpp"
#include "libslic3r/AABBTreeIndirect.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TextureBake/TextureBakeDecimate.hpp"
#include "libslic3r/TextureBake/TextureBakeFlip.hpp"
#include "libslic3r/TextureBake/TextureBakeMesh.hpp"
#include "libslic3r/TextureBake/TextureBakeRepair.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/PNGReadWrite.hpp"
@@ -1652,321 +1654,6 @@ TEST_CASE("Per-corner LSCM UVs give each triangle its own island's placement", "
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]")
{
// The fast preview reconstructs these two projections in the fragment shader and needs the very
@@ -2418,6 +2105,157 @@ TEST_CASE("Cancelling while flat faces are harvested past the budget stops the d
CHECK(canceled > full);
}
TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colours across",
"[TextureDisplacement][Regression]")
{
// Bake, colour part of what came back, bake again. The one-run pipeline rebuilds the topology, so
// the colour recorded against the first bake's mesh indexes triangles that no longer mean the same
// thing. Taken across by triangle id it lands on unrelated faces - a bake almost always grows the
// triangle count, so every stale id passes the only bound check deserialize() makes - which both
// scatters colour over surfaces nothing painted and destroys what the first pass laid down.
// transfer_facet_paint() carries it across by position instead - every triangle of the new mesh
// takes the state painted at its foot point on the old one - which is what makes successive bakes
// accumulate. See TextureDisplacementBakeJob, which is where the two meet.
constexpr float side = 20.f;
constexpr float refine = 0.5f;
constexpr float radius = 4.f;
const indexed_triangle_set cube = its_make_cube(side, side, side);
const Vec2f first_stroke(6.f, 6.f), second_stroke(14.f, 14.f);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.image_data = make_rgb_png_2x2();
layer.tiling_scale = 5.f;
layer.depth_mm = 0.5f;
layer.color_enabled = true;
TextureDisplacementOptions options;
options.pipeline_v2 = true;
options.v2_refine_mm = refine;
options.v2_max_triangles_k = 750;
// Three primaries, so a palette entry is legible in the assertions below.
const std::array<Vec3f, 3> palette = { Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(0, 0, 1) };
TextureColorRequest request;
request.quantize = [&palette](const Vec3f &rgb) {
int best = 0;
float bd = std::numeric_limits<float>::max();
for (int i = 0; i < 3; ++i)
if (const float d = (palette[size_t(i)] - rgb).squaredNorm(); d < bd) { bd = d; best = i; }
return best;
};
const auto bake = [&](const indexed_triangle_set &mesh, const Vec2f &stroke, std::vector<uint8_t> &color) {
TextureDisplacementFacetsData facets;
facets[0] = paint_top_disc(mesh, side, stroke, radius);
TextureColorRequest req = request;
req.out_triangle = &color;
return build_texture_displacement(mesh, { layer }, facets, options, {}, &req);
};
// The pipeline's own counters, over welded positions, so a mesh watertight only after welding
// still reads as closed.
const auto defects = [](const indexed_triangle_set &mesh) {
return TextureBake::count_edge_defects(TextureBake::to_soup(mesh));
};
std::vector<uint8_t> color1;
const indexed_triangle_set first = bake(cube, first_stroke, color1);
REQUIRE(first.indices.size() > cube.indices.size());
REQUIRE(color1.size() == first.indices.size());
const TextureBake::EdgeDefects d1 = defects(first);
INFO("first bake: " << d1.triangles << " triangles, " << d1.open << " open, " << d1.non_manifold << " non-manifold");
CHECK(d1.open == 0);
CHECK(d1.non_manifold == 0);
// The colour the first bake committed, as TextureDisplacementBakeJob writes it.
TriangleSelector::TriangleSplittingData painted_first;
{
const TriangleMesh mesh(first);
TriangleSelector selector(mesh);
for (size_t i = 0; i < color1.size(); ++i)
if (color1[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(color1[i]));
painted_first = selector.serialize();
}
REQUIRE_FALSE(painted_first.bitstream.empty());
std::vector<uint8_t> color2;
const indexed_triangle_set second = bake(first, second_stroke, color2);
REQUIRE(color2.size() == second.indices.size());
REQUIRE(second.indices.size() != first.indices.size()); // the topology really did change
const TextureBake::EdgeDefects d2 = defects(second);
INFO("second bake: " << d2.triangles << " triangles, " << d2.open << " open, " << d2.non_manifold << " non-manifold");
CHECK(d2.open == 0);
CHECK(d2.non_manifold == 0);
// The commit: the earlier colour carried onto the new topology, this bake's own colour over it.
std::vector<uint8_t> committed(second.indices.size(), 0);
{
const TriangleMesh mesh(second);
TriangleSelector selector(mesh);
const std::vector<uint8_t> carried = transfer_facet_paint(first, painted_first, second);
REQUIRE(carried.size() == second.indices.size());
for (size_t i = 0; i < carried.size(); ++i)
if (carried[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(carried[i]));
for (size_t i = 0; i < color2.size(); ++i)
if (color2[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(color2[i]));
for (int state = 1; state <= int(palette.size()); ++state) {
std::vector<int> src;
if (selector.get_facets_strict(EnforcerBlockerType(state), &src).indices.empty())
continue;
for (const int s : src)
if (s >= 0 && size_t(s) < committed.size() && committed[size_t(s)] == 0)
committed[size_t(s)] = uint8_t(state);
}
}
// Just the triangles the first bake coloured, to ask "how far is this from colour that was really
// laid down" of anything the commit painted.
indexed_triangle_set coloured_first;
coloured_first.vertices = first.vertices;
for (size_t i = 0; i < color1.size(); ++i)
if (color1[i] != 0)
coloured_first.indices.push_back(first.indices[i]);
const AABBTreeIndirect::Tree3f tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(
coloured_first.vertices, coloured_first.indices);
// A triangle may carry colour the second bake did not produce only where the first bake's colour
// actually was. A foot point sits on the surface the bake started from, which the relief has since
// moved, and the transfer can widen the region by a triangle, so the margin is twice the edge
// length the bake was told to refine to; landing anywhere else means the mask was read against a
// mesh it no longer matches.
size_t own = 0, carried_over = 0, stranded = 0;
float worst = 0.f;
for (size_t i = 0; i < second.indices.size(); ++i) {
if (committed[i] == 0)
continue;
if (i < color2.size() && color2[i] != 0) {
CHECK(committed[i] == color2[i]); // this bake's own colour wins where it painted
++own;
continue;
}
const stl_triangle_vertex_indices &t = second.indices[i];
const Vec3f g = (second.vertices[size_t(t[0])] + second.vertices[size_t(t[1])] +
second.vertices[size_t(t[2])]) / 3.f;
size_t hit = 0;
Vec3f foot;
const float d = std::sqrt(std::max(0.f, AABBTreeIndirect::squared_distance_to_indexed_triangle_set(
coloured_first.vertices, coloured_first.indices, tree, g, hit, foot)));
worst = std::max(worst, d);
if (d <= 2.f * refine)
++carried_over;
else
++stranded;
}
INFO("committed: " << own << " from this bake, " << carried_over << " carried over, " << stranded
<< " stranded, worst distance from the first bake's colour " << worst << " mm");
CHECK(own > 0);
CHECK(carried_over > 0); // the first bake's colour survived the second
CHECK(stranded == 0); // and nothing was painted where neither bake put colour
}
TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[TextureDisplacement]")
{
// A stroke in each of the cube's two top triangles, baked one after the other. The first bake used