Compare commits

..
Author SHA1 Message Date
ExPikaPaka 56b559d36a Fix texture projection UV orientation
An image laid onto a model came out upside down under the ordinary box
(tri-planar) projection, and on three of a cube's six faces it was also
mirrored left to right, which reads as a 180 degree rotation.

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

Both halves of the box basis were wrong:

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

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

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

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

Verified by measuring the sampled texture row and column against world
position for every mapping mode on all six faces of a cube, and by a
full bake of a quadrant coded image, which lands each quadrant within
1e-3 mm of its expected depth.
2026-10-12 07:57:50 +02:00
27 changed files with 569 additions and 240 deletions
@@ -72,17 +72,21 @@ varying float weight;
varying float island_active; varying float island_active;
varying vec2 vertex_uv; varying vec2 vertex_uv;
// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including // The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right
// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so // handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the
// the preview wraps the texture the same way round as the bake. // cylinder is to the viewer's right rather than mirrored.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{ {
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's
// tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), // tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(),
// project_cylindrical() and project_spherical(). `n` is read by the planar mode only. // project_cylindrical() and project_spherical(). `n` is read by the planar mode only.
@@ -94,7 +98,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -102,10 +106,18 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
} }
vec3 an = abs(n); // Triplanar: drop the dominant normal axis // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0)
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); // sits at the top of the model instead of upside down, and u is flipped on the far side of each
// plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two
// siblings, term for term.
vec3 an = abs(n);
if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -124,7 +136,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); vec2 xy = vec2(dot(rel, right), dot(rel, fwd));
float r = length(xy); float r = length(xy);
t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it
b = up; // v is the distance along the axis b = -up; // v is the distance *against* the axis
return; return;
} }
if (projection_mode == 2) { if (projection_mode == 2) {
@@ -133,20 +145,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0);
float c = length(dir.xy); // cos(latitude) float c = length(dir.xy); // cos(latitude)
t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0);
b = cross(dir, t); // increasing latitude, unit length b = -cross(dir, t); // v grows away from the pole, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return; return;
} }
vec3 an = abs(n); vec3 an = abs(n); // the same signed basis projection_raw() uses
if (an.x >= an.y && an.x >= an.z) { // planar = p.yz if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z)
t = vec3(0.0, 1.0, 0.0); t = vec3(0.0, box_face_sign(n.x), 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z)
t = vec3(1.0, 0.0, 0.0); t = vec3(-box_face_sign(n.y), 0.0, 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else { // planar = p.xy } else { // planar = (+-x, -y)
t = vec3(1.0, 0.0, 0.0); t = vec3(box_face_sign(n.z), 0.0, 0.0);
b = vec3(0.0, 1.0, 0.0); b = vec3(0.0, -1.0, 0.0);
} }
} }
@@ -36,14 +36,20 @@ varying vec4 world_pos;
varying float distortion; varying float distortion;
varying vec2 vertex_uv; varying vec2 vertex_uv;
// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows
// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{ {
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm.
vec2 projection_raw(vec3 p, vec3 n) vec2 projection_raw(vec3 p, vec3 n)
{ {
@@ -53,7 +59,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { if (projection_mode == 2) {
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -61,10 +67,16 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
} }
// v negated so the image's first pixel row sits at the top of the model, u flipped on the far side
// of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz().
vec3 an = abs(n); vec3 an = abs(n);
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
vec2 project_uv(vec3 p, vec3 n) vec2 project_uv(vec3 p, vec3 n)
@@ -138,17 +138,21 @@ in vec2 vertex_uv;
out vec4 out_color; out vec4 out_color;
// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including // The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right
// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so // handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the
// the preview wraps the texture the same way round as the bake. // cylinder is to the viewer's right rather than mirrored.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{ {
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's
// tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), // tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(),
// project_cylindrical() and project_spherical(). `n` is read by the planar mode only. // project_cylindrical() and project_spherical(). `n` is read by the planar mode only.
@@ -160,7 +164,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -168,10 +172,18 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
} }
vec3 an = abs(n); // Triplanar: drop the dominant normal axis // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0)
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); // sits at the top of the model instead of upside down, and u is flipped on the far side of each
// plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two
// siblings, term for term.
vec3 an = abs(n);
if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -190,7 +202,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); vec2 xy = vec2(dot(rel, right), dot(rel, fwd));
float r = length(xy); float r = length(xy);
t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it
b = up; // v is the distance along the axis b = -up; // v is the distance *against* the axis
return; return;
} }
if (projection_mode == 2) { if (projection_mode == 2) {
@@ -199,20 +211,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0);
float c = length(dir.xy); // cos(latitude) float c = length(dir.xy); // cos(latitude)
t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0);
b = cross(dir, t); // increasing latitude, unit length b = -cross(dir, t); // v grows away from the pole, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return; return;
} }
vec3 an = abs(n); vec3 an = abs(n); // the same signed basis projection_raw() uses
if (an.x >= an.y && an.x >= an.z) { // planar = p.yz if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z)
t = vec3(0.0, 1.0, 0.0); t = vec3(0.0, box_face_sign(n.x), 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z)
t = vec3(1.0, 0.0, 0.0); t = vec3(-box_face_sign(n.y), 0.0, 0.0);
b = vec3(0.0, 0.0, 1.0); b = vec3(0.0, 0.0, -1.0);
} else { // planar = p.xy } else { // planar = (+-x, -y)
t = vec3(1.0, 0.0, 0.0); t = vec3(box_face_sign(n.z), 0.0, 0.0);
b = vec3(0.0, 1.0, 0.0); b = vec3(0.0, -1.0, 0.0);
} }
} }
@@ -47,14 +47,20 @@ in vec2 vertex_uv;
out vec4 out_color; out vec4 out_color;
// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows
// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{ {
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary)); right = normalize(cross(up, arbitrary));
fwd = normalize(cross(right, up)); fwd = normalize(cross(up, right));
} }
// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
// sign(), which returns 0 there and would collapse the coordinate onto a line.
float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm.
vec2 projection_raw(vec3 p, vec3 n) vec2 projection_raw(vec3 p, vec3 n)
{ {
@@ -64,7 +70,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
float x = dot(rel, right); float x = dot(rel, right);
float y = dot(rel, fwd); float y = dot(rel, fwd);
return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
} }
if (projection_mode == 2) { if (projection_mode == 2) {
vec3 rel = p - patch_center; vec3 rel = p - patch_center;
@@ -72,10 +78,16 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8) if (radius < 1e-8)
return vec2(0.0); return vec2(0.0);
vec3 dir = rel / radius; vec3 dir = rel / radius;
return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
} }
// v negated so the image's first pixel row sits at the top of the model, u flipped on the far side
// of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz().
vec3 an = abs(n); vec3 an = abs(n);
return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); if (an.x >= an.y && an.x >= an.z)
return vec2(box_face_sign(n.x) * p.y, -p.z);
if (an.y >= an.x && an.y >= an.z)
return vec2(-box_face_sign(n.y) * p.x, -p.z);
return vec2(box_face_sign(n.z) * p.x, -p.y);
} }
vec2 project_uv(vec3 p, vec3 n) vec2 project_uv(vec3 p, vec3 n)
+2 -2
View File
@@ -1729,8 +1729,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
//plate->pattern_file = it->second->pattern_file; //plate->pattern_file = it->second->pattern_file;
plate->no_light_thumbnail_file = it->second->no_light_thumbnail_file; plate->no_light_thumbnail_file = it->second->no_light_thumbnail_file;
plate->top_file = it->second->top_file; plate->top_file = it->second->top_file;
plate->pick_file = it->second->pick_file; plate->pick_file = it->second->pick_file.empty();
plate->pattern_bbox_file = it->second->pattern_bbox_file; plate->pattern_bbox_file = it->second->pattern_bbox_file.empty();
plate->config = it->second->config; plate->config = it->second->config;
if (!plate->thumbnail_file.empty()) if (!plate->thumbnail_file.empty())
+1 -1
View File
@@ -3295,7 +3295,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
m_is_role_based_fan_on.fill(false); m_is_role_based_fan_on.fill(false);
m_role_based_fan_marker_layer.fill(-1); m_role_based_fan_marker_layer.fill(-1);
m_fan_mover.reset(); m_fan_mover.release();
m_ordering_cache.clear(); m_ordering_cache.clear();
// Belt printer: initialize belt-specific writer via virtual hook. // Belt printer: initialize belt-specific writer via virtual hook.
+101 -22
View File
@@ -491,6 +491,57 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set
return out; return out;
} }
namespace {
// Which side of a world plane a face is on, with an exactly zero component counted as the positive
// side. The tie never shows: a normal component of zero carries zero tri-planar blend weight (see
// triplanar_weights()), so the plane it orients contributes nothing at that point.
inline float box_face_sign(float n) { return n >= 0.f ? 1.f : -1.f; }
// The box projection's 2D basis for one of the three world planes, oriented the way a viewer
// standing outside the model sees that face.
//
// v runs *down* the image, because that is what v means to the sampler: v = 0 is the texture's first
// pixel row, i.e. its top (DecodedHeightTexture::sample(); the UV editor negates Y on screen for the
// same reason, see UVEditorCanvas::render()). So v = -z on the four upright planes and v = -y on the
// horizontal one, which is what puts the top of an image at the top of the model rather than
// upside down.
//
// u then has to depend on which *side* of the plane the face is on, or one of the two faces sharing
// a plane comes out left-right mirrored: with a single u per plane, a portrait image reads upright on
// +X and 180 degrees round on -X. Fixing that is the sign below, chosen so that u x v == -normal,
// i.e. (right, down, into the surface) is right-handed for a viewer outside:
//
// face u v face u v face u v
// +X +y -z +Y -x -z +Z +x -y
// -X -y -z -Y +x -z -Z -x -y
//
// The sign flips where its own normal component crosses zero, and the blend weight of the plane it
// belongs to is exactly zero there, so the blended tri-planar sum stays continuous across the flip -
// it is not a return of the hard axis switch described on TextureProjectionMethod::Triplanar.
inline Vec2f box_plane_yz(const Vec3f &p, float nx) { return Vec2f(box_face_sign(nx) * p.y(), -p.z()); }
inline Vec2f box_plane_xz(const Vec3f &p, float ny) { return Vec2f(-box_face_sign(ny) * p.x(), -p.z()); }
inline Vec2f box_plane_xy(const Vec3f &p, float nz) { return Vec2f(box_face_sign(nz) * p.x(), -p.y()); }
} // namespace
void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis)
{
// The same table as box_plane_yz()/box_plane_xz()/box_plane_xy(), handed out as a pair of world
// directions for the plane the normal is closest to. Two shapes of one table, because the bake's
// hot path wants the closed form and the GUI wants the axes; the test suite checks them against
// each other so they cannot drift apart.
const Vec3f n = normal.cwiseAbs();
if (n.x() >= n.y() && n.x() >= n.z()) {
u_axis = box_face_sign(normal.x()) * Vec3f::UnitY();
v_axis = -Vec3f::UnitZ();
} else if (n.y() >= n.x() && n.y() >= n.z()) {
u_axis = -box_face_sign(normal.y()) * Vec3f::UnitX();
v_axis = -Vec3f::UnitZ();
} else {
u_axis = box_face_sign(normal.z()) * Vec3f::UnitX();
v_axis = -Vec3f::UnitY();
}
}
Vec2f project_planar(const Vec3f &position, const Vec3f &normal) Vec2f project_planar(const Vec3f &position, const Vec3f &normal)
{ {
// Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called // Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called
@@ -501,23 +552,26 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal)
// other face in the same patch - exactly the bug an earlier version of this feature had). // other face in the same patch - exactly the bug an earlier version of this feature had).
const Vec3f n = normal.cwiseAbs(); const Vec3f n = normal.cwiseAbs();
if (n.x() >= n.y() && n.x() >= n.z()) if (n.x() >= n.y() && n.x() >= n.z())
return Vec2f(position.y(), position.z()); return box_plane_yz(position, normal.x());
if (n.y() >= n.x() && n.y() >= n.z()) if (n.y() >= n.x() && n.y() >= n.z())
return Vec2f(position.x(), position.z()); return box_plane_xz(position, normal.y());
return Vec2f(position.x(), position.y()); return box_plane_xy(position, normal.z());
} }
namespace { namespace {
// Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at // Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at
// this point's own radius, v is the signed distance along the axis - a reasonable approximation // this point's own radius, v is the distance along the axis, measured *against* it - a reasonable
// for roughly cylindrical selections, not an exact fit for arbitrary geometry. // approximation for roughly cylindrical selections, not an exact fit for arbitrary geometry.
Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis) Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis)
{ {
Vec3f up = patch_axis; Vec3f up = patch_axis;
up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ(); up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ();
const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX();
const Vec3f right = Vec3f(up.cross(arbitrary).normalized()); const Vec3f right = Vec3f(up.cross(arbitrary).normalized());
const Vec3f fwd = Vec3f(right.cross(up).normalized()); // up x right, not right x up: that makes (right, fwd, up) right-handed, so the angle below grows
// anticlockwise about the axis - which, seen from outside the cylinder, is to the viewer's right.
// The other way round the texture wraps on mirrored, as if seen through the model from behind.
const Vec3f fwd = Vec3f(up.cross(right).normalized());
const Vec3f rel = position - patch_center; const Vec3f rel = position - patch_center;
const float along_axis = rel.dot(up); const float along_axis = rel.dot(up);
@@ -526,7 +580,9 @@ Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, cons
const float radius = std::sqrt(x * x + y * y); const float radius = std::sqrt(x * x + y * y);
const float angle = std::atan2(y, x); const float angle = std::atan2(y, x);
return Vec2f(angle * radius, along_axis); // Negated, like every other projection's v: v = 0 is the texture's top row, so v has to grow
// *down* the axis for the top of the image to sit at the top of the model.
return Vec2f(angle * radius, -along_axis);
} }
// Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the // Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the
@@ -542,7 +598,9 @@ Vec2f project_spherical(const Vec3f &position, const Vec3f &patch_center)
const Vec3f dir = rel / radius; const Vec3f dir = rel / radius;
const float longitude = std::atan2(dir.y(), dir.x()); const float longitude = std::atan2(dir.y(), dir.x());
const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f)); const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f));
return Vec2f(longitude, latitude) * radius; // Longitude already grows to the right as seen from outside; latitude grows toward the north
// pole, so it is negated to put the image's top row (v = 0) at the top of the model.
return Vec2f(longitude, -latitude) * radius;
} }
// CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but // CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but
@@ -837,9 +895,26 @@ std::vector<int> segment_into_charts(const indexed_triangle_set &mesh, const std
// solve at all, and why this also serves as the fallback for a chart LSCM cannot handle. // solve at all, and why this also serves as the fallback for a chart LSCM cannot handle.
std::vector<Vec2f> project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal) std::vector<Vec2f> project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal)
{ {
// Oriented like the box projection (see box_projection_axes()), so a chart reads the right way up
// and unmirrored instead of being flattened to whichever basis a cross product happened to give.
// For a chart whose normal *is* a world axis - a cube face, which is the common case once the
// seam angle has cut the patch along its sharp edges - this is exactly the box basis, so such a
// face unwraps to the same orientation Triplanar would have given it.
Vec3f u, v;
box_projection_axes(normal, u, v);
// The box axes lie in the tangent plane already when the normal is a world axis; for any other
// normal, u is squared up against it and v follows. u x v == -normal keeps the chart the way
// round a viewer outside the model sees it (a plain normal x u would mirror it).
u = u - normal * normal.dot(u);
if (u.norm() <= 1e-6f) {
// Out of reach for a unit normal - the box basis puts u perpendicular to the normal's own
// dominant axis, so the two can never be within 45 degrees of each other - but a caller
// handing over an unnormalized normal should still get a usable basis rather than a NaN.
const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX();
const Vec3f u = Vec3f(seed.cross(normal).normalized()); u = seed.cross(normal);
const Vec3f v = Vec3f(normal.cross(u).normalized()); }
u = Vec3f(u.normalized());
v = Vec3f(u.cross(normal).normalized());
std::vector<Vec2f> uvs(chart.vertices.size()); std::vector<Vec2f> uvs(chart.vertices.size());
for (size_t i = 0; i < chart.vertices.size(); ++i) for (size_t i = 0; i < chart.vertices.size(); ++i)
@@ -1814,8 +1889,10 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
return texture.sample(uv, layer.tile_enabled, layer.tile_method); return texture.sample(uv, layer.tile_enabled, layer.tile_method);
} }
// Flat projection onto the captured projector plane. Single-valued per point, so unlike // Flat projection onto the captured projector plane. Single-valued per point, so unlike
// blended triplanar it is one sample, and it is what "project from view" places. // blended triplanar it is one sample, and it is what "project from view" places. The
return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); // projector's up axis is negated because v runs *down* the image (v = 0 is its first pixel
// row), the same reason the projective branch above measures window y downward.
return sample_at(Vec2f(position.dot(layer.view_project_right), -position.dot(layer.view_project_up)));
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar: case TextureProjectionMethod::Triplanar:
default: break; default: break;
@@ -1830,14 +1907,15 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
const float w_sum = w.x() + w.y() + w.z(); const float w_sum = w.x() + w.y() + w.z();
if (w_sum < 1e-8f) if (w_sum < 1e-8f)
// Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either. // Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either.
return sample_at(Vec2f(position.x(), position.y())); return sample_at(box_plane_xy(position, normal.z()));
w /= w_sum; w /= w_sum;
// Each plane drops the axis it is named for, matching project_planar()'s own convention (which // Each plane drops the axis it is named for and is oriented for the side of that plane the normal
// the GUI's on-canvas placement gizmo also relies on). // points to, matching project_planar()'s own convention (which the GUI's on-canvas placement
return w.x() * sample_at(Vec2f(position.y(), position.z())) + // gizmo also relies on). See box_plane_yz() for why the orientation is not simply (y, z).
w.y() * sample_at(Vec2f(position.x(), position.z())) + return w.x() * sample_at(box_plane_yz(position, normal.x())) +
w.z() * sample_at(Vec2f(position.x(), position.y())); w.y() * sample_at(box_plane_xz(position, normal.y())) +
w.z() * sample_at(box_plane_xy(position, normal.z()));
} }
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer, bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
@@ -1899,7 +1977,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method); out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method);
return true; return true;
} else { } else {
const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up)); // Negated up, exactly as sample_layer_height() does it.
const Vec2f p(position.dot(layer.view_project_right), -position.dot(layer.view_project_up));
if (!covered(p)) if (!covered(p))
return false; return false;
out = sample_at(p); out = sample_at(p);
@@ -1915,7 +1994,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
Vec3f w = triplanar_weights(normal); Vec3f w = triplanar_weights(normal);
const float w_sum = w.x() + w.y() + w.z(); const float w_sum = w.x() + w.y() + w.z();
if (w_sum < 1e-8f) { if (w_sum < 1e-8f) {
const Vec2f p(position.x(), position.y()); const Vec2f p = box_plane_xy(position, normal.z());
if (!covered(p)) if (!covered(p))
return false; return false;
out = sample_at(p); out = sample_at(p);
@@ -1926,8 +2005,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
// A blend of three planes is only "not covered" where *every* contributing plane is outside the // A blend of three planes is only "not covered" where *every* contributing plane is outside the
// placement; where some are, the covered ones are renormalised so the colour does not fade toward // placement; where some are, the covered ones are renormalised so the colour does not fade toward
// black at the edge of an untiled tri-planar layer. // black at the edge of an untiled tri-planar layer.
const std::array<Vec2f, 3> planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()), const std::array<Vec2f, 3> planes = { box_plane_yz(position, normal.x()), box_plane_xz(position, normal.y()),
Vec2f(position.x(), position.y()) }; box_plane_xy(position, normal.z()) };
Vec3f acc = Vec3f::Zero(); Vec3f acc = Vec3f::Zero();
float acc_w = 0.f; float acc_w = 0.f;
for (int i = 0; i < 3; ++i) for (int i = 0; i < 3; ++i)
+17 -4
View File
@@ -524,11 +524,24 @@ struct TextureColorSettings
bool empty() const { return palette.empty(); } bool empty() const { return palette.empty(); }
}; };
// The two world directions the box projection takes its u and v along on a face whose outward
// normal is `normal`: the plane is the one that normal is closest to, and the pair is oriented as a
// viewer standing outside the model sees it - u to their right, v *down* the image, which is the
// direction v runs in the texture (v = 0 is the image's first pixel row, see
// DecodedHeightTexture::sample()). So an image comes out the way it looks in a file manager rather
// than upside down, and it is not mirrored on the three faces facing the other way.
//
// Exposed because the GUI needs the same basis for the on-canvas "adjust texture placement" gizmo
// (its ring's zero angle and its offset handle's plane), and it must not be a second, drifting copy
// of the table.
void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis);
// Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any // Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any
// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather // layer), dropping the axis position that best aligns with `normal`. Equivalent to projecting onto
// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas // box_projection_axes()' pair. Exposed on its own (rather than only inline inside
// "adjust texture placement" gizmo - can map a dragged 3D point into the exact same 2D space // project_texture_displacement_uv()) so GUI code - the on-canvas "adjust texture placement" gizmo -
// tiling_scale/rotation_deg/offset operate in, without duplicating the axis-selection logic. // can map a dragged 3D point into the exact same 2D space tiling_scale/rotation_deg/offset operate
// in, without duplicating the axis-selection logic.
Vec2f project_planar(const Vec3f &position, const Vec3f &normal); Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate // Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate
+2 -3
View File
@@ -25,9 +25,8 @@ DockPanel::DockPanel(wxWindow* parent,
const std::string& html, const std::string& html,
MessageHandler on_message, MessageHandler on_message,
CloseHandler on_close, CloseHandler on_close,
CloseHandler on_destroyed, CloseHandler on_destroyed)
const wxString& profile) : WebPanel(parent, web_hosting::orca_bridge_script())
: WebPanel(parent, web_hosting::orca_bridge_script(), profile)
, m_html(html) , m_html(html)
, m_on_message(std::move(on_message)) , m_on_message(std::move(on_message))
, m_on_close(std::move(on_close)) , m_on_close(std::move(on_close))
+1 -4
View File
@@ -6,8 +6,6 @@
#include <optional> #include <optional>
#include <string> #include <string>
#include <wx/string.h>
class wxWindow; class wxWindow;
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
@@ -29,8 +27,7 @@ public:
const std::string& html, const std::string& html,
MessageHandler on_message, MessageHandler on_message,
CloseHandler on_close, CloseHandler on_close,
CloseHandler on_destroyed, CloseHandler on_destroyed);
const wxString& profile = wxEmptyString);
~DockPanel() override; ~DockPanel() override;
// Main thread only. // Main thread only.
+8 -11
View File
@@ -1617,7 +1617,7 @@ void ObjectList::list_manipulation(const wxPoint& mouse_pos, bool evt_context_me
int obj_idx, vol_idx; int obj_idx, vol_idx;
get_selected_item_indexes(obj_idx, vol_idx, item); get_selected_item_indexes(obj_idx, vol_idx, item);
if (obj_idx != -1) { if (obj_idx != -1) {
Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); Plater::TakeSnapshot(plater, _u8L("Shift objects to bed"));
(*m_objects)[obj_idx]->ensure_on_bed(); (*m_objects)[obj_idx]->ensure_on_bed();
cnv->reload_scene(true, true); cnv->reload_scene(true, true);
update_info_items(obj_idx); update_info_items(obj_idx);
@@ -2779,13 +2779,12 @@ void ObjectList::del_info_item(const int obj_idx, InfoItemType type)
GLCanvas3D* cnv = plater->canvas3D(); GLCanvas3D* cnv = plater->canvas3D();
switch (type) { switch (type) {
case InfoItemType::CustomSupports: { case InfoItemType::CustomSupports:
cnv->get_gizmos_manager().reset_all_states(); cnv->get_gizmos_manager().reset_all_states();
Plater::TakeSnapshot snapshot(plater, "Remove support painting"); Plater::TakeSnapshot(plater, "Remove support painting");
for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes)
mv->supported_facets.reset(); mv->supported_facets.reset();
break; break;
}
// BBS: remove CustomSeam // BBS: remove CustomSeam
@@ -2796,21 +2795,19 @@ void ObjectList::del_info_item(const int obj_idx, InfoItemType type)
} }
break; break;
case InfoItemType::MmSegmentation: { case InfoItemType::MmSegmentation:
cnv->get_gizmos_manager().reset_all_states(); cnv->get_gizmos_manager().reset_all_states();
Plater::TakeSnapshot snapshot(plater, "Remove color painting"); Plater::TakeSnapshot(plater, "Remove color painting");
for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes)
mv->mmu_segmentation_facets.reset(); mv->mmu_segmentation_facets.reset();
break; break;
}
case InfoItemType::FuzzySkin: { case InfoItemType::FuzzySkin:
cnv->get_gizmos_manager().reset_all_states(); cnv->get_gizmos_manager().reset_all_states();
Plater::TakeSnapshot snapshot(plater, _u8L("Remove paint-on fuzzy skin")); Plater::TakeSnapshot(plater, _u8L("Remove paint-on fuzzy skin"));
for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes)
mv->fuzzy_skin_facets.reset(); mv->fuzzy_skin_facets.reset();
break; break;
}
// BBS: remove Sinking // BBS: remove Sinking
case InfoItemType::Undef : assert(false); break; case InfoItemType::Undef : assert(false); break;
@@ -6721,7 +6718,7 @@ void ObjectList::OnEditingStarted(wxDataViewEvent &event)
else if (col == colSinking) { else if (col == colSinking) {
Plater * plater = wxGetApp().plater(); Plater * plater = wxGetApp().plater();
GLCanvas3D *cnv = plater->canvas3D(); GLCanvas3D *cnv = plater->canvas3D();
Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); Plater::TakeSnapshot(plater, _u8L("Shift objects to bed"));
int obj_idx, vol_idx; int obj_idx, vol_idx;
get_selected_item_indexes(obj_idx, vol_idx, item); get_selected_item_indexes(obj_idx, vol_idx, item);
(*m_objects)[obj_idx]->ensure_on_bed(); (*m_objects)[obj_idx]->ensure_on_bed();
@@ -1252,8 +1252,9 @@ std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in
uv[vi] = Vec2f(-1e6f, -1e6f); uv[vi] = Vec2f(-1e6f, -1e6f);
continue; continue;
} }
// Negated up, as sample_layer_height()'s affine branch has it: v runs down the image.
const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right), const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right),
patch.vertices[vi].dot(layer.view_project_up)); -patch.vertices[vi].dot(layer.view_project_up));
uv[vi] = apply_uv_transform(planar, layer, aspect); uv[vi] = apply_uv_transform(planar, layer, aspect);
} }
return uv; return uv;
@@ -3292,19 +3293,10 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement
void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const
{ {
// Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and // libslic3r's own basis, asked for rather than restated here, so the ring's "0 degrees" and the
// the offset handle's plane always agree with what project_texture_displacement_uv() does. // offset handle's plane always agree with what project_texture_displacement_uv() does - including
const Vec3f n = m_adjust_anchor_normal.cwiseAbs(); // v pointing *down* the texture and u flipping on the far side of each plane.
if (n.x() >= n.y() && n.x() >= n.z()) { box_projection_axes(m_adjust_anchor_normal, u_axis, v_axis);
u_axis = Vec3f::UnitY();
v_axis = Vec3f::UnitZ();
} else if (n.y() >= n.x() && n.y() >= n.z()) {
u_axis = Vec3f::UnitX();
v_axis = Vec3f::UnitZ();
} else {
u_axis = Vec3f::UnitX();
v_axis = Vec3f::UnitY();
}
} }
void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() void GLGizmoTextureDisplacement::render_adjust_texture_gizmo()
@@ -423,8 +423,8 @@ private:
// obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's // obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's
// foreground draw list, so it must be called from inside the gizmo's ImGui frame. // foreground draw list, so it must be called from inside the gizmo's ImGui frame.
void render_paint_cursor_hint(); void render_paint_cursor_hint();
// Mesh-local tangent-plane basis at m_adjust_anchor_normal, matching project_planar()'s // Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// dominant-axis convention so dragging on-canvas maps consistently onto offset. // on-canvas maps consistently onto offset whichever face the anchor is on.
void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const; void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const;
// The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface. // The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface.
+2 -3
View File
@@ -27,8 +27,7 @@ WebDialog::WebDialog(wxWindow* parent,
SubmitHandler on_submit, SubmitHandler on_submit,
CloseHandler on_close, CloseHandler on_close,
CloseHandler on_destroyed, CloseHandler on_destroyed,
long wx_style, long wx_style)
const wxString& profile)
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style) : WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style)
, m_html(html) , m_html(html)
, m_on_message(std::move(on_message)) , m_on_message(std::move(on_message))
@@ -38,7 +37,7 @@ WebDialog::WebDialog(wxWindow* parent,
{ {
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML // A tiny bundled bootstrap page brings the webview up; the real plugin HTML
// is swapped in via SetPage once the bootstrap finishes loading. // is swapped in via SetPage once the bootstrap finishes loading.
create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240), profile); create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240));
// Paint the window/webview in the themed background so there is no white // Paint the window/webview in the themed background so there is no white
// flash before the (transparent) bootstrap page and plugin HTML render. // flash before the (transparent) bootstrap page and plugin HTML render.
+1 -2
View File
@@ -49,8 +49,7 @@ public:
SubmitHandler on_submit, SubmitHandler on_submit,
CloseHandler on_close, CloseHandler on_close,
CloseHandler on_destroyed, CloseHandler on_destroyed,
long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER, long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
const wxString& profile = wxEmptyString);
~WebDialog() override; ~WebDialog() override;
static void post_message(WebDialog* dialog, const nlohmann::json& data); static void post_message(WebDialog* dialog, const nlohmann::json& data);
+2 -2
View File
@@ -17,7 +17,7 @@
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile) WebPanel::WebPanel(wxWindow* parent, const char* bridge_script)
: wxPanel(parent, wxID_ANY) : wxPanel(parent, wxID_ANY)
{ {
SetBackgroundColour(wxGetApp().get_window_default_clr()); SetBackgroundColour(wxGetApp().get_window_default_clr());
@@ -25,7 +25,7 @@ WebPanel::WebPanel(wxWindow* parent, const char* bridge_script, const wxString&
SetSizer(sizer); SetSizer(sizer);
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available. // Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url(), profile); m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url());
m_browser->SetBackgroundColour(GetBackgroundColour()); m_browser->SetBackgroundColour(GetBackgroundColour());
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script())); m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script())); m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
+1 -1
View File
@@ -22,7 +22,7 @@ namespace Slic3r { namespace GUI {
class WebPanel : public wxPanel class WebPanel : public wxPanel
{ {
public: public:
WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile = wxEmptyString); WebPanel(wxWindow* parent, const char* bridge_script);
protected: protected:
wxWebView* browser() const { return m_browser; } wxWebView* browser() const { return m_browser; }
+4 -58
View File
@@ -3,8 +3,6 @@
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/Utils/MacDarkMode.hpp" #include "slic3r/Utils/MacDarkMode.hpp"
#include <libslic3r/Utils.hpp>
#include <algorithm> #include <algorithm>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
@@ -74,26 +72,6 @@ webkit_javascript_result_unref (WebKitJavascriptResult *js_result);
} }
#endif #endif
// Private storage root for an isolated (plugin) profile. Plugin pages must not reach the shared
// default store's cookies/session (e.g. the OrcaCloud login), so each profile gets its own dir.
// macOS isolates with an in-memory store instead (see CreateWebView), so this is unused there.
#if !defined(__WXOSX__)
static wxString isolated_profile_path(const wxString& profile)
{
wxString safe;
safe.reserve(profile.size());
for (wxString::const_iterator it = profile.begin(); it != profile.end(); ++it) {
const wxChar c = *it;
const bool keep = (c >= wxT('a') && c <= wxT('z')) || (c >= wxT('A') && c <= wxT('Z')) ||
(c >= wxT('0') && c <= wxT('9')) || c == wxT('.') || c == wxT('-') || c == wxT('_');
safe += keep ? c : wxT('_');
}
if (safe.empty())
safe = wxT("default");
return wxString::FromUTF8((boost::filesystem::path(Slic3r::data_dir()) / "web_profiles" / safe.ToStdString()).string());
}
#endif
#ifdef __WIN32__ #ifdef __WIN32__
// Run Download and Install in another thread so we don't block the UI thread // Run Download and Install in another thread so we don't block the UI thread
DWORD DownloadAndInstallWV2RT() { DWORD DownloadAndInstallWV2RT() {
@@ -157,9 +135,6 @@ DWORD DownloadAndInstallWV2RT() {
class WebViewEdge : public wxWebViewEdge class WebViewEdge : public wxWebViewEdge
{ {
public: public:
WebViewEdge() = default;
explicit WebViewEdge(const wxWebViewConfiguration& config) : wxWebViewEdge(config) {}
bool SetUserAgent(const wxString &userAgent) override bool SetUserAgent(const wxString &userAgent) override
{ {
bool dark = userAgent.Contains("dark"); bool dark = userAgent.Contains("dark");
@@ -238,11 +213,6 @@ public:
{ {
} }
explicit WebViewWebKit(const wxWebViewConfiguration& config)
: wxWebViewWebKit(config)
{
}
~WebViewWebKit() override ~WebViewWebKit() override
{ {
RemoveScriptMessageHandler("wx"); RemoveScriptMessageHandler("wx");
@@ -314,7 +284,7 @@ static WebViewRef *webview_ref(wxWebView *webView)
return webView ? static_cast<WebViewRef *>(webView->GetRefData()) : nullptr; return webView ? static_cast<WebViewRef *>(webView->GetRefData()) : nullptr;
} }
wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url, wxString const & profile) wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url)
{ {
#if wxUSE_WEBVIEW_EDGE #if wxUSE_WEBVIEW_EDGE
// Check if a fixed version of edge is present in // Check if a fixed version of edge is present in
@@ -334,36 +304,12 @@ wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url, wxStr
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); } if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << url2.ToUTF8(); BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << url2.ToUTF8();
const bool isolated = !profile.IsEmpty();
#ifdef __WIN32__ #ifdef __WIN32__
wxWebView* webView = nullptr; wxWebView* webView = new WebViewEdge;
if (isolated) {
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendEdge);
config.SetDataPath(isolated_profile_path(profile));
webView = new WebViewEdge(config);
} else {
webView = new WebViewEdge;
}
#elif defined(__WXOSX__) #elif defined(__WXOSX__)
wxWebView* webView = nullptr; wxWebView* webView = new WebViewWebKit;
if (isolated) {
// wx has no SetDataPath on macOS; give the view its own in-memory store instead.
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendWebKit);
Slic3r::GUI::WKWebViewConfiguration_setNonPersistentDataStore(config.GetNativeConfiguration());
webView = new WebViewWebKit(config);
} else {
webView = new WebViewWebKit;
}
#else #else
wxWebView* webView = nullptr; auto webView = wxWebView::New();
if (isolated) {
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendWebKit);
config.SetDataPath(isolated_profile_path(profile));
webView = wxWebView::New(config);
} else {
webView = wxWebView::New();
}
#endif #endif
if (webView) { if (webView) {
webView->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); webView->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE));
+1 -5
View File
@@ -14,11 +14,7 @@ wxDECLARE_EVENT(EVT_WEBVIEW_RECREATED, wxCommandEvent);
class WebView class WebView
{ {
public: public:
// `profile` empty -> the shared default backend store used by all trusted UI. static wxWebView *CreateWebView(wxWindow *parent, wxString const &url);
// `profile` non-empty -> a private store keyed by `profile`, so this view shares no
// cookies/storage/session with the shared store or any other profile. Used for
// plugin-supplied (untrusted) content.
static wxWebView *CreateWebView(wxWindow *parent, wxString const &url, wxString const &profile = wxEmptyString);
#if wxUSE_WEBVIEW_EDGE #if wxUSE_WEBVIEW_EDGE
static bool CheckWebViewRuntime(); static bool CheckWebViewRuntime();
static bool DownloadAndInstallWebViewRuntime(); static bool DownloadAndInstallWebViewRuntime();
+2 -3
View File
@@ -172,8 +172,7 @@ WebViewHostDialog::WebViewHostDialog(wxWindow* parent,
bool WebViewHostDialog::create_webview(const std::string& resource_path, bool WebViewHostDialog::create_webview(const std::string& resource_path,
const wxString& title, const wxString& title,
const wxSize& dialog_size, const wxSize& dialog_size,
const wxSize& min_size, const wxSize& min_size)
const wxString& profile)
{ {
SetTitle(title); SetTitle(title);
SetMinSize(FromDIP(min_size)); SetMinSize(FromDIP(min_size));
@@ -182,7 +181,7 @@ bool WebViewHostDialog::create_webview(const std::string& resource_path,
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL); wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
// Created empty and loaded below, after the user scripts are added, so the first load already has them. // Created empty and loaded below, after the user scripts are added, so the first load already has them.
m_browser = WebView::CreateWebView(this, wxEmptyString, profile); m_browser = WebView::CreateWebView(this, wxEmptyString);
if (m_browser == nullptr) { if (m_browser == nullptr) {
wxLogError("Could not init m_browser"); wxLogError("Could not init m_browser");
delete topsizer; delete topsizer;
+1 -2
View File
@@ -38,8 +38,7 @@ public:
bool create_webview(const std::string& resource_path, bool create_webview(const std::string& resource_path,
const wxString& title, const wxString& title,
const wxSize& dialog_size = wxSize(820, 660), const wxSize& dialog_size = wxSize(820, 660),
const wxSize& min_size = wxSize(640, 640), const wxSize& min_size = wxSize(640, 640));
const wxString& profile = wxEmptyString);
void load_url(const wxString& url); void load_url(const wxString& url);
bool run_script(const wxString& script); bool run_script(const wxString& script);
-1
View File
@@ -12,7 +12,6 @@ extern double mac_max_scaling_factor();
extern void set_miniaturizable(void * window); extern void set_miniaturizable(void * window);
void WKWebView_evaluateJavaScript(void * web, wxString const & script, void (*callback)(wxString const &)); void WKWebView_evaluateJavaScript(void * web, wxString const & script, void (*callback)(wxString const &));
void WKWebView_setTransparentBackground(void * web); void WKWebView_setTransparentBackground(void * web);
void WKWebViewConfiguration_setNonPersistentDataStore(void * configuration);
void WKWebView_force_display(void * web); void WKWebView_force_display(void * web);
void set_tag_when_enter_full_screen(bool isfullscreen); void set_tag_when_enter_full_screen(bool isfullscreen);
void set_title_colour_after_set_title(void * window); void set_title_colour_after_set_title(void * window);
-9
View File
@@ -102,15 +102,6 @@ void WKWebView_setTransparentBackground(void * web)
[webView registerForDraggedTypes: @[NSFilenamesPboardType]]; [webView registerForDraggedTypes: @[NSFilenamesPboardType]];
} }
// Give a WKWebViewConfiguration its own in-memory website data store, so the view's cookies and
// storage neither come from nor leak into the shared default store. Used to isolate plugin web views.
void WKWebViewConfiguration_setNonPersistentDataStore(void * configuration)
{
WKWebViewConfiguration * config = (WKWebViewConfiguration*)configuration;
if (config)
config.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore];
}
// Force a WKWebView to re-lay-out and repaint. Needed for chrome-less popups: the window is // Force a WKWebView to re-lay-out and repaint. Needed for chrome-less popups: the window is
// transparent, so a WKWebView whose layer has no pending frame leaves the whole window invisible // transparent, so a WKWebView whose layer has no pending frame leaves the whole window invisible
// until the user generates input (scroll/arrow). setNeedsDisplay alone does not always reach the // until the user generates input (scroll/arrow). setNeedsDisplay alone does not always reach the
+2 -3
View File
@@ -390,8 +390,7 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
auto* dlg = new GUI::WebDialog(ui_parent(), wxString::FromUTF8(title), html, auto* dlg = new GUI::WebDialog(ui_parent(), wxString::FromUTF8(title), html,
wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter), wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter),
std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE, std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE);
wxString::FromUTF8(plugin_key));
UiRegistry::instance().bind(new_id, dlg, plugin_key); UiRegistry::instance().bind(new_id, dlg, plugin_key);
if (modal) { if (modal) {
dlg->ShowModal(); dlg->ShowModal();
@@ -488,7 +487,7 @@ py::object ui_create_dock_panel(const std::string& html, const std::string& titl
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); }; auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
auto* panel = new GUI::DockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder), auto* panel = new GUI::DockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
std::move(on_destroyed), wxString::FromUTF8(plugin_key)); std::move(on_destroyed));
UiRegistry::instance().bind(new_id, panel, plugin_key); UiRegistry::instance().bind(new_id, panel, plugin_key);
plater->add_dock_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock, plater->add_dock_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
wxSize(w, h), [panel]() { panel->fire_close(); }); wxSize(w, h), [panel]() { panel->fire_close(); });
+1 -2
View File
@@ -77,8 +77,7 @@ constexpr char PLUGIN_PAGE_BRIDGE_JS[] = R"JS(
} // namespace } // namespace
PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability) PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability)
: GUI::WebPanel(parent, PLUGIN_PAGE_BRIDGE_JS, : GUI::WebPanel(parent, PLUGIN_PAGE_BRIDGE_JS)
wxString::FromUTF8(capability ? capability->identity().plugin_key : std::string()))
, m_cap(std::move(capability)) , m_cap(std::move(capability))
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this)) , m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
{ {
-37
View File
@@ -2043,40 +2043,3 @@ TEST_CASE("3MF XML entries declaring more than an int can hold fail to load", "[
CHECK_FALSE(parser.check_3mf_from_prusa(path)); CHECK_FALSE(parser.check_3mf_from_prusa(path));
} }
} }
TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern file paths", "[3mf][Regression]")
{
Model model;
REQUIRE(load_stl((std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl").c_str(), &model));
model.add_default_instances();
ScopedTemporaryDir backup_dir("orca_pick");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
auto plate = std::make_unique<PlateData>();
plate->plate_index = 0;
plate->pick_file = (backup_dir.path() / "pick.png").string();
plate->pattern_bbox_file = (backup_dir.path() / "plate.json").string();
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.plate_data_list.push_back(plate.get());
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
boost::nowide::ifstream in(test_file, std::ios::binary);
Model dst_model;
DynamicPrintConfig dst_config;
PlateDataPtrs dst_plates;
Semver file_version;
dst_model.set_backup_path(backup_dir.string());
REQUIRE(load_gcode_3mf_from_stream(in, &dst_config, &dst_model, &dst_plates, &file_version));
REQUIRE(dst_plates.size() == 1);
CHECK(dst_plates.front()->pick_file == "Metadata/pick_1.png");
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
release_PlateData_list(dst_plates);
}
@@ -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{})); 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 &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));
}
}
}