From bea412fddfa894143cfa641fd1ab24e7c478a496 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Thu, 1 Oct 2026 09:15:15 +0200 Subject: [PATCH 1/9] Move the G-code processing result instead of copying it GCodeProcessorResult declares a copy assignment, which suppresses the implicit move assignment, so `*result = std::move(processor.extract_result())` binds to the copy and duplicates the whole moves array: 96 bytes per move, measured at 147.7 MiB in one allocation for a 597k facet model at 0.08 mm, with the source staying alive until the export returns. Add the move assignment. It assigns exactly the same 39 members in the same order as the copy, including the ones the copy deliberately leaves alone, so a target that carries its own filament maps and nozzle type keeps them. A move constructor is intentionally not added: the type holds a std::mutex, so it is neither copy nor move constructible today and a partial one would leave ten members uninitialised. reset() now releases the two print sized vectors rather than clearing them. Its callers are the paths that discard a result, so the memory went back only at the next slice. ViewerImpl::reset() does the same for the two vectors sized by the print, which is what keeps a discarded preview resident, and counts m_vertices_colors in the reported CPU memory, where it was missing. --- src/libslic3r/GCode/GCodeProcessor.cpp | 8 +++-- src/libslic3r/GCode/GCodeProcessor.hpp | 50 ++++++++++++++++++++++++++ src/libvgcode/src/ViewerImpl.cpp | 9 +++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index e03be228d0..505a28335d 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2582,8 +2582,12 @@ void GCodeProcessorResult::reset() { //BBS: add mutex for protection of gcode result lock(); - moves.clear(); - lines_ends.clear(); + // release rather than clear: these two are sized by the print - one entry per move and one + // per g-code line - and a reset is where the memory is expected to go back to the allocator + // (see BackgroundSlicingProcess::apply()). The capacity would not be reused anyway: the + // result is refilled by move-assigning the processor's own result. + moves = std::vector(); + lines_ends = std::vector(); printable_area = Pointfs(); //BBS: add bed exclude area bed_exclude_area = Pointfs(); diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 192a80a43d..a99a5188d6 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -369,6 +369,56 @@ class Print; initial_layer_time = other.initial_layer_time; #if ENABLE_GCODE_VIEWER_STATISTICS time = other.time; +#endif + return *this; + } + // Orca: the user-declared copy assignment above suppresses the implicit move assignment, so + // `*result = std::move(processor.extract_result())` used to deep copy 'moves' (one MoveVertex + // per move, gigabytes on a large print) while the source stayed alive. This moves exactly the + // same members as the copy above, with the same omissions, so the members the copy leaves + // untouched on the target are left untouched here too. + GCodeProcessorResult& operator=(GCodeProcessorResult &&other) + { + filename = std::move(other.filename); + id = other.id; + moves = std::move(other.moves); + lines_ends = std::move(other.lines_ends); + printable_area = std::move(other.printable_area); + bed_exclude_area = std::move(other.bed_exclude_area); + wrapping_exclude_area = std::move(other.wrapping_exclude_area); + toolpath_outside = other.toolpath_outside; + label_object_enabled = other.label_object_enabled; + long_retraction_when_cut = other.long_retraction_when_cut; + timelapse_warning_code = other.timelapse_warning_code; + printable_height = other.printable_height; + settings_ids = std::move(other.settings_ids); + filaments_count = other.filaments_count; + extruder_colors = std::move(other.extruder_colors); + filament_diameters = std::move(other.filament_diameters); + filament_densities = std::move(other.filament_densities); + filament_costs = std::move(other.filament_costs); + print_statistics = std::move(other.print_statistics); + custom_gcode_per_print_z = std::move(other.custom_gcode_per_print_z); + spiral_vase_mode = other.spiral_vase_mode; + warnings = std::move(other.warnings); + bed_type = other.bed_type; + gcode_check_result = std::move(other.gcode_check_result); + limit_filament_maps = std::move(other.limit_filament_maps); + filament_printable_reuslt = std::move(other.filament_printable_reuslt); + nozzle_group_result = std::move(other.nozzle_group_result); + extruder_types = std::move(other.extruder_types); + printer_extruder_variant = std::move(other.printer_extruder_variant); + printer_extruder_id = std::move(other.printer_extruder_id); + layer_filaments = std::move(other.layer_filaments); + filament_change_sequence = std::move(other.filament_change_sequence); + used_mixed_filaments = std::move(other.used_mixed_filaments); + nozzle_change_sequence = std::move(other.nozzle_change_sequence); + optimal_assignment = std::move(other.optimal_assignment); + filament_change_count_map = std::move(other.filament_change_count_map); + skippable_part_time = std::move(other.skippable_part_time); + initial_layer_time = other.initial_layer_time; +#if ENABLE_GCODE_VIEWER_STATISTICS + time = other.time; #endif return *this; } diff --git a/src/libvgcode/src/ViewerImpl.cpp b/src/libvgcode/src/ViewerImpl.cpp index 8212717931..e3e106bb59 100644 --- a/src/libvgcode/src/ViewerImpl.cpp +++ b/src/libvgcode/src/ViewerImpl.cpp @@ -883,15 +883,17 @@ void ViewerImpl::reset() m_used_extruders.clear(); m_total_time = { 0.0f, 0.0f }; m_travels_time = { 0.0f, 0.0f }; - m_vertices.clear(); - m_vertices_colors.clear(); // swap rather than clear: these are sized by the print, and a reset means the memory // should go back, not sit reserved until the next load + std::vector().swap(m_vertices); + std::vector().swap(m_vertices_colors); for (std::vector& times : m_layer_start_times) std::vector().swap(times); std::vector().swap(m_layer_first_vertex); std::vector().swap(m_colors_scratch); - m_valid_lines_bitset.clear(); + // BitSet::clear() only zeroes the bits, it keeps the blocks allocated; load() builds a new + // bitset anyway and it is never read while m_vertices is empty + m_valid_lines_bitset = BitSet<>(); #if VGCODE_ENABLE_COG_AND_TOOL_MARKERS m_cog_marker.reset(); #endif // VGCODE_ENABLE_COG_AND_TOOL_MARKERS @@ -1812,6 +1814,7 @@ size_t ViewerImpl::get_used_cpu_memory() const ret += sizeof(m_extrusion_roles_colors); ret += sizeof(m_options_colors); ret += STDVEC_MEMSIZE(m_vertices, PathVertex); + ret += STDVEC_MEMSIZE(m_vertices_colors, float); for (const std::vector& times : m_layer_start_times) ret += STDVEC_MEMSIZE(times, float); ret += STDVEC_MEMSIZE(m_layer_first_vertex, uint32_t); From 56b559d36a6d67ab706ad60aaea343f9667c62b7 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 07:57:50 +0200 Subject: [PATCH 2/9] 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. --- .../110/texture_displacement_shaded.fs | 52 +-- .../110/texture_displacement_uvcheck.fs | 20 +- .../140/texture_displacement_shaded.fs | 52 +-- .../140/texture_displacement_uvcheck.fs | 20 +- src/libslic3r/TextureDisplacement.cpp | 125 +++++-- src/libslic3r/TextureDisplacement.hpp | 21 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 20 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 4 +- tests/libslic3r/test_texture_displacement.cpp | 315 ++++++++++++++++++ 9 files changed, 538 insertions(+), 91 deletions(-) diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 0b9192749c..93449af5d4 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -72,17 +72,21 @@ varying float weight; varying float island_active; varying vec2 vertex_uv; -// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including -// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so -// the preview wraps the texture the same way round as the bake. +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right +// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the +// cylinder is to the viewer's right rather than mirrored. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), // project_cylindrical() and project_spherical(). `n` is read by the planar mode only. @@ -94,7 +98,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius vec3 rel = p - patch_center; @@ -102,10 +106,18 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } - vec3 an = abs(n); // Triplanar: drop the dominant normal axis - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0) + // sits at the top of the model instead of upside down, and u is flipped on the far side of each + // plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two + // siblings, term for term. + vec3 an = abs(n); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one @@ -124,7 +136,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); float r = length(xy); t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it - b = up; // v is the distance along the axis + b = -up; // v is the distance *against* the axis return; } if (projection_mode == 2) { @@ -133,20 +145,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); float c = length(dir.xy); // cos(latitude) t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); - b = cross(dir, t); // increasing latitude, unit length + b = -cross(dir, t); // v grows away from the pole, unit length units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); return; } - vec3 an = abs(n); - if (an.x >= an.y && an.x >= an.z) { // planar = p.yz - t = vec3(0.0, 1.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else { // planar = p.xy - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 1.0, 0.0); + vec3 an = abs(n); // the same signed basis projection_raw() uses + if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z) + t = vec3(0.0, box_face_sign(n.x), 0.0); + b = vec3(0.0, 0.0, -1.0); + } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z) + t = vec3(-box_face_sign(n.y), 0.0, 0.0); + b = vec3(0.0, 0.0, -1.0); + } else { // planar = (+-x, -y) + t = vec3(box_face_sign(n.z), 0.0, 0.0); + b = vec3(0.0, -1.0, 0.0); } } diff --git a/resources/shaders/110/texture_displacement_uvcheck.fs b/resources/shaders/110/texture_displacement_uvcheck.fs index f9e998adc2..37fd1826f2 100644 --- a/resources/shaders/110/texture_displacement_uvcheck.fs +++ b/resources/shaders/110/texture_displacement_uvcheck.fs @@ -36,14 +36,20 @@ varying vec4 world_pos; varying float distortion; varying vec2 vertex_uv; +// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows +// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. vec2 projection_raw(vec3 p, vec3 n) { @@ -53,7 +59,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { vec3 rel = p - patch_center; @@ -61,10 +67,16 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } + // v negated so the image's first pixel row sits at the top of the model, u flipped on the far side + // of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz(). vec3 an = abs(n); - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } vec2 project_uv(vec3 p, vec3 n) diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index 911a919aaf..9398b21102 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -138,17 +138,21 @@ in vec2 vertex_uv; out vec4 out_color; -// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including -// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so -// the preview wraps the texture the same way round as the bake. +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right +// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the +// cylinder is to the viewer's right rather than mirrored. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), // project_cylindrical() and project_spherical(). `n` is read by the planar mode only. @@ -160,7 +164,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius vec3 rel = p - patch_center; @@ -168,10 +172,18 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } - vec3 an = abs(n); // Triplanar: drop the dominant normal axis - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0) + // sits at the top of the model instead of upside down, and u is flipped on the far side of each + // plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two + // siblings, term for term. + vec3 an = abs(n); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one @@ -190,7 +202,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); float r = length(xy); t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it - b = up; // v is the distance along the axis + b = -up; // v is the distance *against* the axis return; } if (projection_mode == 2) { @@ -199,20 +211,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); float c = length(dir.xy); // cos(latitude) t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); - b = cross(dir, t); // increasing latitude, unit length + b = -cross(dir, t); // v grows away from the pole, unit length units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); return; } - vec3 an = abs(n); - if (an.x >= an.y && an.x >= an.z) { // planar = p.yz - t = vec3(0.0, 1.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else { // planar = p.xy - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 1.0, 0.0); + vec3 an = abs(n); // the same signed basis projection_raw() uses + if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z) + t = vec3(0.0, box_face_sign(n.x), 0.0); + b = vec3(0.0, 0.0, -1.0); + } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z) + t = vec3(-box_face_sign(n.y), 0.0, 0.0); + b = vec3(0.0, 0.0, -1.0); + } else { // planar = (+-x, -y) + t = vec3(box_face_sign(n.z), 0.0, 0.0); + b = vec3(0.0, -1.0, 0.0); } } diff --git a/resources/shaders/140/texture_displacement_uvcheck.fs b/resources/shaders/140/texture_displacement_uvcheck.fs index e6fc8036da..34f80df2bd 100644 --- a/resources/shaders/140/texture_displacement_uvcheck.fs +++ b/resources/shaders/140/texture_displacement_uvcheck.fs @@ -47,14 +47,20 @@ in vec2 vertex_uv; out vec4 out_color; +// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows +// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. vec2 projection_raw(vec3 p, vec3 n) { @@ -64,7 +70,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { vec3 rel = p - patch_center; @@ -72,10 +78,16 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } + // v negated so the image's first pixel row sits at the top of the model, u flipped on the far side + // of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz(). vec3 an = abs(n); - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } vec2 project_uv(vec3 p, vec3 n) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..40416e7277 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -491,6 +491,57 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set return out; } +namespace { +// Which side of a world plane a face is on, with an exactly zero component counted as the positive +// side. The tie never shows: a normal component of zero carries zero tri-planar blend weight (see +// triplanar_weights()), so the plane it orients contributes nothing at that point. +inline float box_face_sign(float n) { return n >= 0.f ? 1.f : -1.f; } + +// The box projection's 2D basis for one of the three world planes, oriented the way a viewer +// standing outside the model sees that face. +// +// v runs *down* the image, because that is what v means to the sampler: v = 0 is the texture's first +// pixel row, i.e. its top (DecodedHeightTexture::sample(); the UV editor negates Y on screen for the +// same reason, see UVEditorCanvas::render()). So v = -z on the four upright planes and v = -y on the +// horizontal one, which is what puts the top of an image at the top of the model rather than +// upside down. +// +// u then has to depend on which *side* of the plane the face is on, or one of the two faces sharing +// a plane comes out left-right mirrored: with a single u per plane, a portrait image reads upright on +// +X and 180 degrees round on -X. Fixing that is the sign below, chosen so that u x v == -normal, +// i.e. (right, down, into the surface) is right-handed for a viewer outside: +// +// face u v face u v face u v +// +X +y -z +Y -x -z +Z +x -y +// -X -y -z -Y +x -z -Z -x -y +// +// The sign flips where its own normal component crosses zero, and the blend weight of the plane it +// belongs to is exactly zero there, so the blended tri-planar sum stays continuous across the flip - +// it is not a return of the hard axis switch described on TextureProjectionMethod::Triplanar. +inline Vec2f box_plane_yz(const Vec3f &p, float nx) { return Vec2f(box_face_sign(nx) * p.y(), -p.z()); } +inline Vec2f box_plane_xz(const Vec3f &p, float ny) { return Vec2f(-box_face_sign(ny) * p.x(), -p.z()); } +inline Vec2f box_plane_xy(const Vec3f &p, float nz) { return Vec2f(box_face_sign(nz) * p.x(), -p.y()); } +} // namespace + +void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis) +{ + // The same table as box_plane_yz()/box_plane_xz()/box_plane_xy(), handed out as a pair of world + // directions for the plane the normal is closest to. Two shapes of one table, because the bake's + // hot path wants the closed form and the GUI wants the axes; the test suite checks them against + // each other so they cannot drift apart. + const Vec3f n = normal.cwiseAbs(); + if (n.x() >= n.y() && n.x() >= n.z()) { + u_axis = box_face_sign(normal.x()) * Vec3f::UnitY(); + v_axis = -Vec3f::UnitZ(); + } else if (n.y() >= n.x() && n.y() >= n.z()) { + u_axis = -box_face_sign(normal.y()) * Vec3f::UnitX(); + v_axis = -Vec3f::UnitZ(); + } else { + u_axis = box_face_sign(normal.z()) * Vec3f::UnitX(); + v_axis = -Vec3f::UnitY(); + } +} + Vec2f project_planar(const Vec3f &position, const Vec3f &normal) { // Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called @@ -501,23 +552,26 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal) // other face in the same patch - exactly the bug an earlier version of this feature had). const Vec3f n = normal.cwiseAbs(); if (n.x() >= n.y() && n.x() >= n.z()) - return Vec2f(position.y(), position.z()); + return box_plane_yz(position, normal.x()); if (n.y() >= n.x() && n.y() >= n.z()) - return Vec2f(position.x(), position.z()); - return Vec2f(position.x(), position.y()); + return box_plane_xz(position, normal.y()); + return box_plane_xy(position, normal.z()); } namespace { // Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at -// this point's own radius, v is the signed distance along the axis - a reasonable approximation -// for roughly cylindrical selections, not an exact fit for arbitrary geometry. +// this point's own radius, v is the distance along the axis, measured *against* it - a reasonable +// approximation for roughly cylindrical selections, not an exact fit for arbitrary geometry. Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis) { Vec3f up = patch_axis; up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ(); const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f right = Vec3f(up.cross(arbitrary).normalized()); - const Vec3f fwd = Vec3f(right.cross(up).normalized()); + // up x right, not right x up: that makes (right, fwd, up) right-handed, so the angle below grows + // anticlockwise about the axis - which, seen from outside the cylinder, is to the viewer's right. + // The other way round the texture wraps on mirrored, as if seen through the model from behind. + const Vec3f fwd = Vec3f(up.cross(right).normalized()); const Vec3f rel = position - patch_center; const float along_axis = rel.dot(up); @@ -526,7 +580,9 @@ Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, cons const float radius = std::sqrt(x * x + y * y); const float angle = std::atan2(y, x); - return Vec2f(angle * radius, along_axis); + // Negated, like every other projection's v: v = 0 is the texture's top row, so v has to grow + // *down* the axis for the top of the image to sit at the top of the model. + return Vec2f(angle * radius, -along_axis); } // Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the @@ -542,7 +598,9 @@ Vec2f project_spherical(const Vec3f &position, const Vec3f &patch_center) const Vec3f dir = rel / radius; const float longitude = std::atan2(dir.y(), dir.x()); const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f)); - return Vec2f(longitude, latitude) * radius; + // Longitude already grows to the right as seen from outside; latitude grows toward the north + // pole, so it is negated to put the image's top row (v = 0) at the top of the model. + return Vec2f(longitude, -latitude) * radius; } // CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but @@ -837,9 +895,26 @@ std::vector segment_into_charts(const indexed_triangle_set &mesh, const std // solve at all, and why this also serves as the fallback for a chart LSCM cannot handle. std::vector project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal) { - const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); - const Vec3f u = Vec3f(seed.cross(normal).normalized()); - const Vec3f v = Vec3f(normal.cross(u).normalized()); + // Oriented like the box projection (see box_projection_axes()), so a chart reads the right way up + // and unmirrored instead of being flattened to whichever basis a cross product happened to give. + // For a chart whose normal *is* a world axis - a cube face, which is the common case once the + // seam angle has cut the patch along its sharp edges - this is exactly the box basis, so such a + // face unwraps to the same orientation Triplanar would have given it. + Vec3f u, v; + box_projection_axes(normal, u, v); + // The box axes lie in the tangent plane already when the normal is a world axis; for any other + // normal, u is squared up against it and v follows. u x v == -normal keeps the chart the way + // round a viewer outside the model sees it (a plain normal x u would mirror it). + u = u - normal * normal.dot(u); + if (u.norm() <= 1e-6f) { + // Out of reach for a unit normal - the box basis puts u perpendicular to the normal's own + // dominant axis, so the two can never be within 45 degrees of each other - but a caller + // handing over an unnormalized normal should still get a usable basis rather than a NaN. + const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); + u = seed.cross(normal); + } + u = Vec3f(u.normalized()); + v = Vec3f(u.cross(normal).normalized()); std::vector uvs(chart.vertices.size()); for (size_t i = 0; i < chart.vertices.size(); ++i) @@ -1814,8 +1889,10 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp return texture.sample(uv, layer.tile_enabled, layer.tile_method); } // Flat projection onto the captured projector plane. Single-valued per point, so unlike - // blended triplanar it is one sample, and it is what "project from view" places. - return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); + // blended triplanar it is one sample, and it is what "project from view" places. The + // projector's up axis is negated because v runs *down* the image (v = 0 is its first pixel + // row), the same reason the projective branch above measures window y downward. + return sample_at(Vec2f(position.dot(layer.view_project_right), -position.dot(layer.view_project_up))); case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar case TextureProjectionMethod::Triplanar: default: break; @@ -1830,14 +1907,15 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp const float w_sum = w.x() + w.y() + w.z(); if (w_sum < 1e-8f) // Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either. - return sample_at(Vec2f(position.x(), position.y())); + return sample_at(box_plane_xy(position, normal.z())); w /= w_sum; - // Each plane drops the axis it is named for, matching project_planar()'s own convention (which - // the GUI's on-canvas placement gizmo also relies on). - return w.x() * sample_at(Vec2f(position.y(), position.z())) + - w.y() * sample_at(Vec2f(position.x(), position.z())) + - w.z() * sample_at(Vec2f(position.x(), position.y())); + // Each plane drops the axis it is named for and is oriented for the side of that plane the normal + // points to, matching project_planar()'s own convention (which the GUI's on-canvas placement + // gizmo also relies on). See box_plane_yz() for why the orientation is not simply (y, z). + return w.x() * sample_at(box_plane_yz(position, normal.x())) + + w.y() * sample_at(box_plane_xz(position, normal.y())) + + w.z() * sample_at(box_plane_xy(position, normal.z())); } bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer, @@ -1899,7 +1977,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method); return true; } else { - const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up)); + // Negated up, exactly as sample_layer_height() does it. + const Vec2f p(position.dot(layer.view_project_right), -position.dot(layer.view_project_up)); if (!covered(p)) return false; out = sample_at(p); @@ -1915,7 +1994,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla Vec3f w = triplanar_weights(normal); const float w_sum = w.x() + w.y() + w.z(); if (w_sum < 1e-8f) { - const Vec2f p(position.x(), position.y()); + const Vec2f p = box_plane_xy(position, normal.z()); if (!covered(p)) return false; out = sample_at(p); @@ -1926,8 +2005,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla // A blend of three planes is only "not covered" where *every* contributing plane is outside the // placement; where some are, the covered ones are renormalised so the colour does not fade toward // black at the edge of an untiled tri-planar layer. - const std::array planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()), - Vec2f(position.x(), position.y()) }; + const std::array planes = { box_plane_yz(position, normal.x()), box_plane_xz(position, normal.y()), + box_plane_xy(position, normal.z()) }; Vec3f acc = Vec3f::Zero(); float acc_w = 0.f; for (int i = 0; i < 3; ++i) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..f096c847ad 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -524,11 +524,24 @@ struct TextureColorSettings bool empty() const { return palette.empty(); } }; +// The two world directions the box projection takes its u and v along on a face whose outward +// normal is `normal`: the plane is the one that normal is closest to, and the pair is oriented as a +// viewer standing outside the model sees it - u to their right, v *down* the image, which is the +// direction v runs in the texture (v = 0 is the image's first pixel row, see +// DecodedHeightTexture::sample()). So an image comes out the way it looks in a file manager rather +// than upside down, and it is not mirrored on the three faces facing the other way. +// +// Exposed because the GUI needs the same basis for the on-canvas "adjust texture placement" gizmo +// (its ring's zero angle and its offset handle's plane), and it must not be a second, drifting copy +// of the table. +void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis); + // Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any -// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather -// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas -// "adjust texture placement" gizmo - can map a dragged 3D point into the exact same 2D space -// tiling_scale/rotation_deg/offset operate in, without duplicating the axis-selection logic. +// layer), dropping the axis position that best aligns with `normal`. Equivalent to projecting onto +// box_projection_axes()' pair. Exposed on its own (rather than only inline inside +// project_texture_displacement_uv()) so GUI code - the on-canvas "adjust texture placement" gizmo - +// can map a dragged 3D point into the exact same 2D space tiling_scale/rotation_deg/offset operate +// in, without duplicating the axis-selection logic. Vec2f project_planar(const Vec3f &position, const Vec3f &normal); // Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..d39785ade7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1252,8 +1252,9 @@ std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in uv[vi] = Vec2f(-1e6f, -1e6f); continue; } + // Negated up, as sample_layer_height()'s affine branch has it: v runs down the image. const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right), - patch.vertices[vi].dot(layer.view_project_up)); + -patch.vertices[vi].dot(layer.view_project_up)); uv[vi] = apply_uv_transform(planar, layer, aspect); } return uv; @@ -3292,19 +3293,10 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const { - // Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and - // the offset handle's plane always agree with what project_texture_displacement_uv() does. - const Vec3f n = m_adjust_anchor_normal.cwiseAbs(); - if (n.x() >= n.y() && n.x() >= n.z()) { - u_axis = Vec3f::UnitY(); - v_axis = Vec3f::UnitZ(); - } else if (n.y() >= n.x() && n.y() >= n.z()) { - u_axis = Vec3f::UnitX(); - v_axis = Vec3f::UnitZ(); - } else { - u_axis = Vec3f::UnitX(); - v_axis = Vec3f::UnitY(); - } + // libslic3r's own basis, asked for rather than restated here, so the ring's "0 degrees" and the + // offset handle's plane always agree with what project_texture_displacement_uv() does - including + // v pointing *down* the texture and u flipping on the far side of each plane. + box_projection_axes(m_adjust_anchor_normal, u_axis, v_axis); } void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..4e2fcefccb 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -423,8 +423,8 @@ private: // obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's // foreground draw list, so it must be called from inside the gizmo's ImGui frame. void render_paint_cursor_hint(); - // Mesh-local tangent-plane basis at m_adjust_anchor_normal, matching project_planar()'s - // dominant-axis convention so dragging on-canvas maps consistently onto offset. + // Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging + // on-canvas maps consistently onto offset whichever face the anchor is on. void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const; // The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface. diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 485f4f2a0f..37745beaa0 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2174,3 +2174,318 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } + +// --------------------------------------------------------------------------------------------- +// UV orientation: which way up a texture lands on the model. +// +// v = 0 is the height map's *first* pixel row, i.e. its top (DecodedHeightTexture::sample(), and +// the UV editor negates Y on screen for the same reason). So "the image is the right way up" is the +// statement that the row a point samples gets *lower* as the point moves up the face, and "it is +// not left-right mirrored" is that the column gets higher as the point moves to the right of a +// viewer standing outside the model. Both are measured below by sampling a ramp whose value says +// which row (or column) it came from, which settles the question without having to reason about the +// uv transform at all. + +static constexpr int RAMP_N = 64; // the ramp texture's size, in texels +static constexpr float RAMP_TILING = 40.f; // mm per tile +static constexpr float CUBE_HALF = 5.f; // half extent of the cube the probes walk over +// How far a probe steps off a face centre. Short on purpose: the cylindrical and spherical u is an +// arc length (angle * radius), so a long step also changes the radius, and that term swamps the +// angular one. It is symmetric about the face centre, so at a short step it cancels between the two +// readings a check compares. +static constexpr float RAMP_STEP = 1.f; +// What one step's worth of travel is in ramp rows: 2 * RAMP_STEP mm of separation, RAMP_N texels per +// RAMP_TILING mm. Halved as the margin a check demands, so "flat" cannot pass as either direction. +static constexpr double RAMP_MARGIN = RAMP_N * RAMP_STEP / RAMP_TILING; + +// A height map whose rows (or columns) carry a linear ramp: row r holds 3 + 4r, so the sampled +// height identifies the row. Built directly rather than through a PNG because these tests are about +// the projection, not the decoder - decode_height_texture() has its own tests. +static DecodedHeightTexture ramp_texture(bool by_row) +{ + DecodedHeightTexture t; + t.width = t.height = RAMP_N; + t.pixels.resize(size_t(RAMP_N) * size_t(RAMP_N)); + for (int y = 0; y < RAMP_N; ++y) + for (int x = 0; x < RAMP_N; ++x) + t.pixels[size_t(y) * size_t(RAMP_N) + size_t(x)] = uint8_t(3 + 4 * (by_row ? y : x)); + return t; +} + +// uv 0.5 at the cube's centre, and a tile wide enough that every point the probes visit stays well +// inside the one tile - so no reading is a wrapped-around neighbour of the one it is compared with. +static TextureDisplacementLayer ramp_layer(TextureProjectionMethod method) +{ + TextureDisplacementLayer layer; + layer.projection_method = method; + layer.tiling_scale = RAMP_TILING; + layer.offset = Vec2f(0.5f, 0.5f); + layer.depth_mm = 1.f; + return layer; +} + +// Which row (or column) of `ramp` the layer reads at `p`. Fractional, because a point between two +// texels reads the ramp between them; only the direction it moves in matters here. +static double ramp_index(const DecodedHeightTexture &ramp, const TextureDisplacementLayer &layer, const Vec3f &p, + const Vec3f &n, const Vec3f ¢er = Vec3f(Vec3f::Zero()), + const Vec3f &axis = Vec3f(Vec3f::UnitZ())) +{ + return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0; +} + +// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on +// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y. +static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up) +{ + up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f); + right = Vec3f((-n).cross(up).normalized()); +} + +static const std::array, 6> CUBE_FACES = { { + { "+X", Vec3f(1.f, 0.f, 0.f) }, + { "-X", Vec3f(-1.f, 0.f, 0.f) }, + { "+Y", Vec3f(0.f, 1.f, 0.f) }, + { "-Y", Vec3f(0.f, -1.f, 0.f) }, + { "+Z", Vec3f(0.f, 0.f, 1.f) }, + { "-Z", Vec3f(0.f, 0.f, -1.f) }, +} }; + +TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube", + "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + const DecodedHeightTexture cols = ramp_texture(false); + const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar); + + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + + // Upright: the row read near the top of the face is nearer the image's first row. + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top of the face " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + + // Unmirrored: the column read to the viewer's right is further into the image. Without a + // per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which + // is the mirroring a bare v flip leaves behind. + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left of the face " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + // ViewProjected is one flat projection from the captured camera rather than a per-face map, so its + // "up" is the projector's own (view_project_up), which the default capture puts at +Y. + const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical, + TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its + // caps) and a sphere's is at an extremum (they are its poles), so neither says anything about + // which way up the image is. + if (std::abs(n.z()) > 0.9f) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up : + Vec3f(0.f, 0.f, 1.f); + const Vec3f centre = n * CUBE_HALF; + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + } + } +} + +TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]") +{ + const DecodedHeightTexture cols = ramp_texture(false); + const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // The caps and poles are out, as above - u is undefined on the axis. So is whichever face the + // atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both + // projections, and a reading taken across it compares two opposite sides of the model. + if (std::abs(n.z()) > 0.9f) + continue; + const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f); + if (on_the_cut) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]") +{ + indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF); + for (Vec3f &v : cube.vertices) + v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF); + + const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG); + REQUIRE_FALSE(unwrap.empty()); + REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges + + // Each chart carries one face. The four upright ones span world Z, and their v has to grow + // downwards like every other projection's - whatever the packing then did with the island. + int upright_charts = 0; + for (int c = 0; c < unwrap.chart_count; ++c) { + int top = -1, bottom = -1; + for (size_t i = 0; i < unwrap.uvs.size(); ++i) { + if (unwrap.vertex_chart[i] != c) + continue; + const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z(); + if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z()) + top = int(i); + if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z()) + bottom = int(i); + } + REQUIRE(top >= 0); + REQUIRE(bottom >= 0); + const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y(); + if (std::abs(v_top - v_bottom) < 1e-3f) + continue; // a cap: the whole chart is at one height, so it has no up + ++upright_charts; + DYNAMIC_SECTION("chart " << c) + { + INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom); + CHECK(v_bottom - v_top > 1.f); + } + } + CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above +} + +TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]") +{ + // The two express the same table in different shapes - one a pair of world axes for the GUI, the + // other the closed form the bake's hot path uses - so they are checked against each other here + // rather than left to drift apart. + const Vec3f p(2.f, -3.f, 7.f); + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f u_axis, v_axis; + box_projection_axes(n, u_axis, v_axis); + const Vec2f planar = project_planar(p, n); + CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f)); + CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f)); + // Right-handed for a viewer outside the model: u to their right, v down, so u x v points + // into the surface. Getting this wrong is exactly what mirrors a face. + CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f)); + } + } +} + +// An image with a different value in each quadrant, so one bake says both which way up it landed +// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the +// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0. +static std::shared_ptr> make_quadrant_marker_png(size_t w = 32, size_t h = 32) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0); + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]") +{ + // An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four + // quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex + // normal is then exactly the face normal, which is what makes the expected depth below an exact + // number instead of a tri-planar blend of three of them. + constexpr int STEPS = 4; // cells per side, so 5x5 vertices + constexpr float SPAN = 20.f; // mm, the wall's width and height + indexed_triangle_set wall; + for (int j = 0; j <= STEPS; ++j) + for (int i = 0; i <= STEPS; ++i) + wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f, + SPAN * float(j) / float(STEPS)); + const auto at = [](int i, int j) { return j * (STEPS + 1) + i; }; + for (int j = 0; j < STEPS; ++j) + for (int i = 0; i < STEPS; ++i) { + // Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin. + wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1)); + wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1)); + } + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(wall); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.projection_method = TextureProjectionMethod::Triplanar; + layer.image_data = make_quadrant_marker_png(); + layer.depth_mm = 2.f; + // The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in + // both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a + // whole number of texels away from both. + layer.tiling_scale = 40.f; + layer.offset = Vec2f(0.5f, 0.75f); + // A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by + // the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With + // tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way. + layer.tile_enabled = false; + + const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options()); + REQUIRE(result.vertices.size() == wall.vertices.size()); + + // Seen from outside this face the viewer's right is +X and their up is +Z, so the image's + // top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value + // whose quadrant it must have read. + const std::array, 4> expected = { { + { "top-left (255)", 1, 3, 255 }, + { "top-right (170)", 3, 3, 170 }, + { "bottom-left (85)", 1, 1, 85 }, + { "bottom-right (0)", 3, 1, 0 }, + } }; + + for (const auto &[name, i, j, value] : expected) { + DYNAMIC_SECTION("quadrant " << name) + { + const size_t vi = size_t(at(i, j)); + // The whole displacement is along -Y, so this is the relief's height at that vertex. + const float raised = wall.vertices[vi].y() - result.vertices[vi].y(); + INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z()); + CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f)); + // Nothing moved sideways or vertically: a mirrored or rotated basis would still have + // displaced along the normal, so this is here to catch the relief leaning instead. + CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f)); + CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f)); + } + } +} From 1abd6649c750375ff1c09401aa1ac2339da11b7a Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 13:58:28 +0200 Subject: [PATCH 3/9] Put the UV orientation tests beside the projection tests They were appended at the end of test_texture_displacement.cpp, where every other open texture-displacement branch also appends, so the four of them cannot be merged in any order without a conflict in a file none of them really disagree about. Same tests, moved next to the patch-frame test they belong with. --- tests/libslic3r/test_texture_displacement.cpp | 630 +++++++++--------- 1 file changed, 315 insertions(+), 315 deletions(-) diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 37745beaa0..03f27661bc 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -1652,6 +1652,321 @@ 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 ¢er = Vec3f(Vec3f::Zero()), + const Vec3f &axis = Vec3f(Vec3f::UnitZ())) +{ + return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0; +} + +// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on +// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y. +static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up) +{ + up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f); + right = Vec3f((-n).cross(up).normalized()); +} + +static const std::array, 6> CUBE_FACES = { { + { "+X", Vec3f(1.f, 0.f, 0.f) }, + { "-X", Vec3f(-1.f, 0.f, 0.f) }, + { "+Y", Vec3f(0.f, 1.f, 0.f) }, + { "-Y", Vec3f(0.f, -1.f, 0.f) }, + { "+Z", Vec3f(0.f, 0.f, 1.f) }, + { "-Z", Vec3f(0.f, 0.f, -1.f) }, +} }; + +TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube", + "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + const DecodedHeightTexture cols = ramp_texture(false); + const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar); + + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + + // Upright: the row read near the top of the face is nearer the image's first row. + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top of the face " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + + // Unmirrored: the column read to the viewer's right is further into the image. Without a + // per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which + // is the mirroring a bare v flip leaves behind. + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left of the face " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + // ViewProjected is one flat projection from the captured camera rather than a per-face map, so its + // "up" is the projector's own (view_project_up), which the default capture puts at +Y. + const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical, + TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its + // caps) and a sphere's is at an extremum (they are its poles), so neither says anything about + // which way up the image is. + if (std::abs(n.z()) > 0.9f) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up : + Vec3f(0.f, 0.f, 1.f); + const Vec3f centre = n * CUBE_HALF; + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + } + } +} + +TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]") +{ + const DecodedHeightTexture cols = ramp_texture(false); + const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // The caps and poles are out, as above - u is undefined on the axis. So is whichever face the + // atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both + // projections, and a reading taken across it compares two opposite sides of the model. + if (std::abs(n.z()) > 0.9f) + continue; + const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f); + if (on_the_cut) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]") +{ + indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF); + for (Vec3f &v : cube.vertices) + v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF); + + const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG); + REQUIRE_FALSE(unwrap.empty()); + REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges + + // Each chart carries one face. The four upright ones span world Z, and their v has to grow + // downwards like every other projection's - whatever the packing then did with the island. + int upright_charts = 0; + for (int c = 0; c < unwrap.chart_count; ++c) { + int top = -1, bottom = -1; + for (size_t i = 0; i < unwrap.uvs.size(); ++i) { + if (unwrap.vertex_chart[i] != c) + continue; + const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z(); + if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z()) + top = int(i); + if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z()) + bottom = int(i); + } + REQUIRE(top >= 0); + REQUIRE(bottom >= 0); + const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y(); + if (std::abs(v_top - v_bottom) < 1e-3f) + continue; // a cap: the whole chart is at one height, so it has no up + ++upright_charts; + DYNAMIC_SECTION("chart " << c) + { + INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom); + CHECK(v_bottom - v_top > 1.f); + } + } + CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above +} + +TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]") +{ + // The two express the same table in different shapes - one a pair of world axes for the GUI, the + // other the closed form the bake's hot path uses - so they are checked against each other here + // rather than left to drift apart. + const Vec3f p(2.f, -3.f, 7.f); + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f u_axis, v_axis; + box_projection_axes(n, u_axis, v_axis); + const Vec2f planar = project_planar(p, n); + CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f)); + CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f)); + // Right-handed for a viewer outside the model: u to their right, v down, so u x v points + // into the surface. Getting this wrong is exactly what mirrors a face. + CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f)); + } + } +} + +// An image with a different value in each quadrant, so one bake says both which way up it landed +// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the +// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0. +static std::shared_ptr> make_quadrant_marker_png(size_t w = 32, size_t h = 32) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0); + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]") +{ + // An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four + // quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex + // normal is then exactly the face normal, which is what makes the expected depth below an exact + // number instead of a tri-planar blend of three of them. + constexpr int STEPS = 4; // cells per side, so 5x5 vertices + constexpr float SPAN = 20.f; // mm, the wall's width and height + indexed_triangle_set wall; + for (int j = 0; j <= STEPS; ++j) + for (int i = 0; i <= STEPS; ++i) + wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f, + SPAN * float(j) / float(STEPS)); + const auto at = [](int i, int j) { return j * (STEPS + 1) + i; }; + for (int j = 0; j < STEPS; ++j) + for (int i = 0; i < STEPS; ++i) { + // Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin. + wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1)); + wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1)); + } + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(wall); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.projection_method = TextureProjectionMethod::Triplanar; + layer.image_data = make_quadrant_marker_png(); + layer.depth_mm = 2.f; + // The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in + // both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a + // whole number of texels away from both. + layer.tiling_scale = 40.f; + layer.offset = Vec2f(0.5f, 0.75f); + // A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by + // the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With + // tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way. + layer.tile_enabled = false; + + const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options()); + REQUIRE(result.vertices.size() == wall.vertices.size()); + + // Seen from outside this face the viewer's right is +X and their up is +Z, so the image's + // top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value + // whose quadrant it must have read. + const std::array, 4> expected = { { + { "top-left (255)", 1, 3, 255 }, + { "top-right (170)", 3, 3, 170 }, + { "bottom-left (85)", 1, 1, 85 }, + { "bottom-right (0)", 3, 1, 0 }, + } }; + + for (const auto &[name, i, j, value] : expected) { + DYNAMIC_SECTION("quadrant " << name) + { + const size_t vi = size_t(at(i, j)); + // The whole displacement is along -Y, so this is the relief's height at that vertex. + const float raised = wall.vertices[vi].y() - result.vertices[vi].y(); + INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z()); + CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f)); + // Nothing moved sideways or vertically: a mirrored or rotated basis would still have + // displaced along the normal, so this is here to catch the relief leaning instead. + CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f)); + CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f)); + } + } +} + 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 @@ -2174,318 +2489,3 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } - -// --------------------------------------------------------------------------------------------- -// UV orientation: which way up a texture lands on the model. -// -// v = 0 is the height map's *first* pixel row, i.e. its top (DecodedHeightTexture::sample(), and -// the UV editor negates Y on screen for the same reason). So "the image is the right way up" is the -// statement that the row a point samples gets *lower* as the point moves up the face, and "it is -// not left-right mirrored" is that the column gets higher as the point moves to the right of a -// viewer standing outside the model. Both are measured below by sampling a ramp whose value says -// which row (or column) it came from, which settles the question without having to reason about the -// uv transform at all. - -static constexpr int RAMP_N = 64; // the ramp texture's size, in texels -static constexpr float RAMP_TILING = 40.f; // mm per tile -static constexpr float CUBE_HALF = 5.f; // half extent of the cube the probes walk over -// How far a probe steps off a face centre. Short on purpose: the cylindrical and spherical u is an -// arc length (angle * radius), so a long step also changes the radius, and that term swamps the -// angular one. It is symmetric about the face centre, so at a short step it cancels between the two -// readings a check compares. -static constexpr float RAMP_STEP = 1.f; -// What one step's worth of travel is in ramp rows: 2 * RAMP_STEP mm of separation, RAMP_N texels per -// RAMP_TILING mm. Halved as the margin a check demands, so "flat" cannot pass as either direction. -static constexpr double RAMP_MARGIN = RAMP_N * RAMP_STEP / RAMP_TILING; - -// A height map whose rows (or columns) carry a linear ramp: row r holds 3 + 4r, so the sampled -// height identifies the row. Built directly rather than through a PNG because these tests are about -// the projection, not the decoder - decode_height_texture() has its own tests. -static DecodedHeightTexture ramp_texture(bool by_row) -{ - DecodedHeightTexture t; - t.width = t.height = RAMP_N; - t.pixels.resize(size_t(RAMP_N) * size_t(RAMP_N)); - for (int y = 0; y < RAMP_N; ++y) - for (int x = 0; x < RAMP_N; ++x) - t.pixels[size_t(y) * size_t(RAMP_N) + size_t(x)] = uint8_t(3 + 4 * (by_row ? y : x)); - return t; -} - -// uv 0.5 at the cube's centre, and a tile wide enough that every point the probes visit stays well -// inside the one tile - so no reading is a wrapped-around neighbour of the one it is compared with. -static TextureDisplacementLayer ramp_layer(TextureProjectionMethod method) -{ - TextureDisplacementLayer layer; - layer.projection_method = method; - layer.tiling_scale = RAMP_TILING; - layer.offset = Vec2f(0.5f, 0.5f); - layer.depth_mm = 1.f; - return layer; -} - -// Which row (or column) of `ramp` the layer reads at `p`. Fractional, because a point between two -// texels reads the ramp between them; only the direction it moves in matters here. -static double ramp_index(const DecodedHeightTexture &ramp, const TextureDisplacementLayer &layer, const Vec3f &p, - const Vec3f &n, const Vec3f ¢er = Vec3f(Vec3f::Zero()), - const Vec3f &axis = Vec3f(Vec3f::UnitZ())) -{ - return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0; -} - -// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on -// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y. -static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up) -{ - up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f); - right = Vec3f((-n).cross(up).normalized()); -} - -static const std::array, 6> CUBE_FACES = { { - { "+X", Vec3f(1.f, 0.f, 0.f) }, - { "-X", Vec3f(-1.f, 0.f, 0.f) }, - { "+Y", Vec3f(0.f, 1.f, 0.f) }, - { "-Y", Vec3f(0.f, -1.f, 0.f) }, - { "+Z", Vec3f(0.f, 0.f, 1.f) }, - { "-Z", Vec3f(0.f, 0.f, -1.f) }, -} }; - -TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube", - "[TextureDisplacement]") -{ - const DecodedHeightTexture rows = ramp_texture(true); - const DecodedHeightTexture cols = ramp_texture(false); - const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar); - - for (const auto &[name, n] : CUBE_FACES) { - DYNAMIC_SECTION("face " << name) - { - Vec3f right, up; - outside_viewer_frame(n, right, up); - const Vec3f centre = n * CUBE_HALF; - - // Upright: the row read near the top of the face is nearer the image's first row. - const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); - const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); - INFO("row at the top of the face " << row_up << ", at the bottom " << row_down); - CHECK(row_down - row_up > RAMP_MARGIN); - - // Unmirrored: the column read to the viewer's right is further into the image. Without a - // per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which - // is the mirroring a bare v flip leaves behind. - const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); - const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); - INFO("column at the left of the face " << col_left << ", at the right " << col_right); - CHECK(col_right - col_left > RAMP_MARGIN); - } - } -} - -TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]") -{ - const DecodedHeightTexture rows = ramp_texture(true); - // ViewProjected is one flat projection from the captured camera rather than a per-face map, so its - // "up" is the projector's own (view_project_up), which the default capture puts at +Y. - const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical, - TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected); - const TextureDisplacementLayer layer = ramp_layer(method); - - for (const auto &[name, n] : CUBE_FACES) { - // Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its - // caps) and a sphere's is at an extremum (they are its poles), so neither says anything about - // which way up the image is. - if (std::abs(n.z()) > 0.9f) - continue; - DYNAMIC_SECTION("method " << int(method) << ", face " << name) - { - const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up : - Vec3f(0.f, 0.f, 1.f); - const Vec3f centre = n * CUBE_HALF; - const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); - const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); - INFO("row at the top " << row_up << ", at the bottom " << row_down); - CHECK(row_down - row_up > RAMP_MARGIN); - } - } -} - -TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]") -{ - const DecodedHeightTexture cols = ramp_texture(false); - const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical); - const TextureDisplacementLayer layer = ramp_layer(method); - - for (const auto &[name, n] : CUBE_FACES) { - // The caps and poles are out, as above - u is undefined on the axis. So is whichever face the - // atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both - // projections, and a reading taken across it compares two opposite sides of the model. - if (std::abs(n.z()) > 0.9f) - continue; - const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f); - if (on_the_cut) - continue; - DYNAMIC_SECTION("method " << int(method) << ", face " << name) - { - Vec3f right, up; - outside_viewer_frame(n, right, up); - const Vec3f centre = n * CUBE_HALF; - const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); - const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); - INFO("column at the left " << col_left << ", at the right " << col_right); - CHECK(col_right - col_left > RAMP_MARGIN); - } - } -} - -TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]") -{ - indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF); - for (Vec3f &v : cube.vertices) - v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF); - - const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG); - REQUIRE_FALSE(unwrap.empty()); - REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges - - // Each chart carries one face. The four upright ones span world Z, and their v has to grow - // downwards like every other projection's - whatever the packing then did with the island. - int upright_charts = 0; - for (int c = 0; c < unwrap.chart_count; ++c) { - int top = -1, bottom = -1; - for (size_t i = 0; i < unwrap.uvs.size(); ++i) { - if (unwrap.vertex_chart[i] != c) - continue; - const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z(); - if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z()) - top = int(i); - if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z()) - bottom = int(i); - } - REQUIRE(top >= 0); - REQUIRE(bottom >= 0); - const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y(); - if (std::abs(v_top - v_bottom) < 1e-3f) - continue; // a cap: the whole chart is at one height, so it has no up - ++upright_charts; - DYNAMIC_SECTION("chart " << c) - { - INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom); - CHECK(v_bottom - v_top > 1.f); - } - } - CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above -} - -TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]") -{ - // The two express the same table in different shapes - one a pair of world axes for the GUI, the - // other the closed form the bake's hot path uses - so they are checked against each other here - // rather than left to drift apart. - const Vec3f p(2.f, -3.f, 7.f); - for (const auto &[name, n] : CUBE_FACES) { - DYNAMIC_SECTION("face " << name) - { - Vec3f u_axis, v_axis; - box_projection_axes(n, u_axis, v_axis); - const Vec2f planar = project_planar(p, n); - CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f)); - CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f)); - // Right-handed for a viewer outside the model: u to their right, v down, so u x v points - // into the surface. Getting this wrong is exactly what mirrors a face. - CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f)); - } - } -} - -// An image with a different value in each quadrant, so one bake says both which way up it landed -// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the -// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0. -static std::shared_ptr> make_quadrant_marker_png(size_t w = 32, size_t h = 32) -{ - std::vector pixels(w * h); - for (size_t y = 0; y < h; ++y) - for (size_t x = 0; x < w; ++x) - pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0); - const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() - / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); - REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); - - std::vector bytes; - { - std::ifstream ifs(tmp_path.string(), std::ios::binary); - bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); - } - boost::system::error_code ec; - boost::filesystem::remove(tmp_path, ec); - - REQUIRE_FALSE(bytes.empty()); - return std::make_shared>(std::move(bytes)); -} - -TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]") -{ - // An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four - // quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex - // normal is then exactly the face normal, which is what makes the expected depth below an exact - // number instead of a tri-planar blend of three of them. - constexpr int STEPS = 4; // cells per side, so 5x5 vertices - constexpr float SPAN = 20.f; // mm, the wall's width and height - indexed_triangle_set wall; - for (int j = 0; j <= STEPS; ++j) - for (int i = 0; i <= STEPS; ++i) - wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f, - SPAN * float(j) / float(STEPS)); - const auto at = [](int i, int j) { return j * (STEPS + 1) + i; }; - for (int j = 0; j < STEPS; ++j) - for (int i = 0; i < STEPS; ++i) { - // Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin. - wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1)); - wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1)); - } - - TextureDisplacementFacetsData facets{}; - facets[0] = paint_whole_mesh(wall); - - TextureDisplacementLayer layer; - layer.slot = 0; - layer.projection_method = TextureProjectionMethod::Triplanar; - layer.image_data = make_quadrant_marker_png(); - layer.depth_mm = 2.f; - // The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in - // both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a - // whole number of texels away from both. - layer.tiling_scale = 40.f; - layer.offset = Vec2f(0.5f, 0.75f); - // A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by - // the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With - // tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way. - layer.tile_enabled = false; - - const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options()); - REQUIRE(result.vertices.size() == wall.vertices.size()); - - // Seen from outside this face the viewer's right is +X and their up is +Z, so the image's - // top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value - // whose quadrant it must have read. - const std::array, 4> expected = { { - { "top-left (255)", 1, 3, 255 }, - { "top-right (170)", 3, 3, 170 }, - { "bottom-left (85)", 1, 1, 85 }, - { "bottom-right (0)", 3, 1, 0 }, - } }; - - for (const auto &[name, i, j, value] : expected) { - DYNAMIC_SECTION("quadrant " << name) - { - const size_t vi = size_t(at(i, j)); - // The whole displacement is along -Y, so this is the relief's height at that vertex. - const float raised = wall.vertices[vi].y() - result.vertices[vi].y(); - INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z()); - CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f)); - // Nothing moved sideways or vertically: a mirrored or rotated basis would still have - // displaced along the normal, so this is here to catch the relief leaning instead. - CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f)); - CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f)); - } - } -} From 01499e536ed68cad6d1eabe80bbc672bfb42201a Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 13 Oct 2026 15:00:18 +0800 Subject: [PATCH 4/9] fix(gui): stop the UV editor pane from hijacking the shared GL context The texture-displacement gizmo pushes pane state to the UV editor from the preview rebuild that runs on every frame of a slider drag. Tile size is the only one of the layer card's sliders whose value appears in that pane state, so dragging it changes the "N.N mm tile" label, the panel relays out, and the UV canvas receives a synchronous wxEVT_SIZE. Its on_size() flushes a paint with Update(), and render() then makes the app's single shared wxGLContext current on the UV drawable in the middle of the 3D canvas's frame. On X11/GLX that out-of-turn context activation is fatal: the drawable the 3D canvas later swaps no longer belongs to the current context, the server returns BadMatch, and GDK turns the unhandled X error into g_error(). EGL on Wayland reports only a status, so the same theft silently corrupts the frame instead of aborting - which is why the guard is not conditioned on the display backend. Add GLCanvas3D::is_frame_in_progress(), backed by a counter that _render_frame() maintains through its existing scope guard, and let the UV canvas skip the synchronous paint while a frame is on the stack, re-queueing it with Refresh() so nothing is dropped. Rebind the context with _set_current() before SwapBuffers() as well, so a surface that steals it by some other route - a modal GL dialog a gizmo opens from its panel - cannot turn the swap into the same fatal error. --- src/slic3r/GUI/GLCanvas3D.cpp | 25 ++++++++++++++++++++++--- src/slic3r/GUI/GLCanvas3D.hpp | 7 +++++++ src/slic3r/GUI/UVEditorCanvas.cpp | 18 +++++++++++++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index c509e5d6f8..2e429dda20 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1514,6 +1514,8 @@ wxDEFINE_EVENT(EVT_GLCANVAS_PRINTABLE, SimpleEvent); const double GLCanvas3D::DefaultCameraZoomToBoxMarginFactor = 1.25; const double GLCanvas3D::DefaultCameraZoomToBedMarginFactor = 2.00; const double GLCanvas3D::DefaultCameraZoomToPlateMarginFactor = 1.25; +// Main-thread only: _render_frame never runs off it, so no atomic is needed. +int GLCanvas3D::s_frames_in_progress = 0; void GLCanvas3D::load_arrange_settings() { @@ -2468,7 +2470,8 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init) } m_in_render = true; - Slic3r::ScopeGuard in_render_guard([this]() { m_in_render = false; }); + ++s_frames_in_progress; + Slic3r::ScopeGuard in_render_guard([this]() { m_in_render = false; --s_frames_in_progress; }); (void)in_render_guard; if (m_canvas == nullptr) @@ -2713,15 +2716,31 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init) // On Wayland, eglSwapBuffers blocks when the canvas is hidden or // occluded. Skip the swap to avoid stalling the render loop. - if (m_canvas->IsShownOnScreen()) { + // Code run from inside this frame - a modal dialog a gizmo opened from its ImGui panel, a + // synchronous paint of another canvas - may have made a different surface current. Bind this one + // again before presenting, so the swap is not issued against a window the context no longer + // belongs to: on Mesa/X11 that is a fatal BadMatch, not the no-op it is on other platforms. Draws + // already issued before this point cannot be redirected - only this swap is protected. + if (m_canvas->IsShownOnScreen() && _set_current()) { m_canvas->SwapBuffers(); m_frame_profiler.mark("swap"); if (!overlay_tick) m_render_stats.increment_fps_counter(); m_presented_signature = signature; } - else + else { + if (m_canvas->IsShownOnScreen()) { + // Logged once: if the rebind fails it does so every frame, and a warning per frame would + // bury everything else. + static bool rebind_failure_logged = false; + if (!rebind_failure_logged) { + rebind_failure_logged = true; + BOOST_LOG_TRIVIAL(warning) << "GLCanvas3D: could not rebind the GL context to this canvas before " + "SwapBuffers(); the frame is not presented"; + } + } m_presented_signature.reset(); + } } // Everything drawn into the 3D scene, from the clear to the post processing passes, ending in the diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index bb148234d1..2cb6bcea4f 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -643,6 +643,8 @@ private: #endif unsigned int m_last_w, m_last_h; bool m_in_render; + // How many canvases are inside _render_frame() right now - see is_frame_in_progress(). + static int s_frames_in_progress; wxTimer m_timer; wxTimer m_timer_set_color; // Armed by each frame that draws the FPS overlay; its tick requests an overlay-only frame. @@ -974,6 +976,11 @@ public: void on_change_color_mode(bool is_dark, bool reinit = true); const bool get_dark_mode_status() { return m_is_dark; } void set_as_dirty() { m_dirty = true; } + // True while any GLCanvas3D is inside _render_frame(). The other surfaces that share the app's one + // GL context (the UV editor canvas) check this before making themselves current: a frame interrupted + // by another surface finishes drawing into that surface and then presents a window the context no + // longer belongs to, which on Mesa/X11 is a fatal X error (BadMatch) rather than a no-op. + static bool is_frame_in_progress() { return s_frames_in_progress > 0; } void requires_check_outside_state() { m_requires_check_outside_state = true; } unsigned int get_volumes_count() const { return (unsigned int)m_volumes.volumes.size(); } diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index a4a9901822..87f629816f 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -36,6 +36,7 @@ #include "3DScene.hpp" #include "BitmapCache.hpp" +#include "GLCanvas3D.hpp" #include "GLShader.hpp" #include "GUI_App.hpp" #include "I18N.hpp" @@ -1324,8 +1325,13 @@ void UVEditorCanvas::on_size(wxSizeEvent &evt) // Update() flushes it immediately - still through the normal paint path (unlike calling // render() directly), which matters because this canvas shares the app's one GL context with // the 3D view and must not hijack it outside its own paint. + // Not while a 3D canvas frame is in progress, though: wxGTK delivers this size event synchronously + // when the texture displacement gizmo relays out the pane from its ImGui panel, i.e. from inside + // that frame, and flushing the paint there would take the shared context away from it. The Refresh() + // then runs the paint once the frame is over. Refresh(); - Update(); + if (!GLCanvas3D::is_frame_in_progress()) + Update(); } void UVEditorCanvas::on_leave(wxMouseEvent &evt) @@ -1346,6 +1352,16 @@ void UVEditorCanvas::render() { if (m_context == nullptr || !IsShownOnScreen()) return; + // Painted from inside a 3D canvas frame - a modal dialog the gizmo opened from its ImGui panel runs + // this paint, as does any other synchronous event raised there. Making this surface current now + // would take the shared context away from that canvas mid-frame: its remaining draws would land + // here and, on Mesa/X11, its SwapBuffers() would be a fatal BadMatch. Skip, but ask for another + // paint so the skipped one is not simply lost. The pane stays stale for as long as a nested modal + // loop keeps a frame on the stack, which is behind the dialog anyway. + if (GLCanvas3D::is_frame_in_progress()) { + Refresh(); + return; + } // wxGLCanvas::SetCurrent() genuinely fails (returns false) on a canvas that isn't shown on // screen, and can also fail on a pixel-format mismatch with the context - and every GL call From bf5a3784790d2819eca304f938f0301613e1becf Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 13 Oct 2026 18:05:51 +0800 Subject: [PATCH 5/9] Free G-code result buffers with clear_and_shrink --- src/libslic3r/GCode/GCodeProcessor.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 243aa514cd..9d166805a5 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2602,16 +2602,13 @@ void GCodeProcessorResult::reset() { //BBS: add mutex for protection of gcode result lock(); - // release rather than clear: these two are sized by the print - one entry per move and one - // per g-code line - and a reset is where the memory is expected to go back to the allocator - // (see BackgroundSlicingProcess::apply()). The capacity would not be reused anyway: the - // result is refilled by move-assigning the processor's own result. - moves = std::vector(); + // moves and lines_ends grow with the print: free them, as no reset result reuses the capacity. + clear_and_shrink(moves); plate_mass = {}; object_masses.clear(); body_masses.clear(); support_masses.clear(); - lines_ends = std::vector(); + clear_and_shrink(lines_ends); printable_area = Pointfs(); //BBS: add bed exclude area bed_exclude_area = Pointfs(); From af29e4d553860ad82f72cf43738ef946c1adad77 Mon Sep 17 00:00:00 2001 From: ExPikaPaka <112851715+ExPikaPaka@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:45:21 +0200 Subject: [PATCH 6/9] Image adjustment panel for texture displacement layers (#16391) --- src/libslic3r/TextureDisplacement.cpp | 255 +++++++++++- src/libslic3r/TextureDisplacement.hpp | 74 ++++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 373 +++++++++++++++++- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 67 ++++ src/slic3r/GUI/TextureLibrary.cpp | 107 +++-- src/slic3r/GUI/TextureLibrary.hpp | 10 + tests/libslic3r/test_texture_displacement.cpp | 289 ++++++++++++++ 7 files changed, 1141 insertions(+), 34 deletions(-) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index aba998bbbf..0153bb169b 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -158,12 +158,33 @@ struct SmoothedTextureCache { std::weak_ptr> source; float smoothing = 0.f; + // Part of the key, not of the result: smoothing is applied *after* the panel's adjustments, so a + // blur computed over one set of adjustments is not the blur another set would give. + TextureAdjustments adjust; DecodedHeightTexture texture; }; std::mutex mutex; std::unordered_map entries; }; SmoothedTextureCache g_smoothed_texture_cache; + +// The adjusted copy, cached between the raw decode and the smoothed one. Needed for the same reason the +// smoothed cache is: while a slider in the image-adjustment panel moves, the thumbnail, the shaded +// preview's height and colour textures, the UV editor backdrop and the displacement preview job all ask +// for the same (image, adjustments) pair within one frame, and each of them running a full-size resample +// of its own is what would make the panel unusable on a large texture. +struct AdjustedTextureCache +{ + struct Entry + { + std::weak_ptr> source; + TextureAdjustments adjust; + DecodedHeightTexture texture; + }; + std::mutex mutex; + std::unordered_map entries; +}; +AdjustedTextureCache g_adjusted_texture_cache; } // namespace namespace { @@ -339,6 +360,34 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer entries[key] = { std::weak_ptr>(layer.image_data), result }; } + // The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before, + // because `preview_adjust` is an edit to the image while `smoothing` is a live low-pass over whatever + // the image is - the same order the panel presents them in, and the same order a commit bakes in. + // + // Nothing applies here once the panel has closed: it resets preview_adjust to identity, and identity + // is a no-op that costs one comparison. + if (!layer.preview_adjust.identity()) { + bool have_adjusted = false; + { + std::lock_guard lock(g_adjusted_texture_cache.mutex); + auto it = g_adjusted_texture_cache.entries.find(key); + if (it != g_adjusted_texture_cache.entries.end() && it->second.adjust == layer.preview_adjust && + it->second.source.lock() == layer.image_data) { + result = it->second.texture; + have_adjusted = true; + } + } + if (!have_adjusted) { + result = apply_texture_adjustments(result, layer.preview_adjust); + std::lock_guard lock(g_adjusted_texture_cache.mutex); + auto &entries = g_adjusted_texture_cache.entries; + for (auto it = entries.begin(); it != entries.end();) + it = it->second.source.expired() ? entries.erase(it) : std::next(it); + entries[key] = { std::weak_ptr>(layer.image_data), + layer.preview_adjust, result }; + } + } + // Smoothing radius scales with the texture so the same slider value blurs the same *fraction* of // the image whatever resolution it came in at, and stays continuous in the slider - see // smooth_height_pixels(). The cap is a cost limit, not part of the mapping: the blur is @@ -349,7 +398,7 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer std::lock_guard lock(g_smoothed_texture_cache.mutex); auto it = g_smoothed_texture_cache.entries.find(key); if (it != g_smoothed_texture_cache.entries.end() && it->second.smoothing == layer.smoothing && - it->second.source.lock() == layer.image_data) + it->second.adjust == layer.preview_adjust && it->second.source.lock() == layer.image_data) return it->second.texture; } const float span = 0.05f * float(std::min(result.width, result.height)); @@ -373,12 +422,70 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer auto &entries = g_smoothed_texture_cache.entries; for (auto it = entries.begin(); it != entries.end();) it = it->second.source.expired() ? entries.erase(it) : std::next(it); - entries[key] = { std::weak_ptr>(layer.image_data), layer.smoothing, result }; + entries[key] = { std::weak_ptr>(layer.image_data), layer.smoothing, + layer.preview_adjust, result }; } return result; } namespace { +// Fraction of the short side that TextureAdjustments::blur == 1 blurs over, and the cost cap on it. +// Deliberately the same pair decode_height_texture() uses for the Smoothing slider - see +// adjustment_blur_radius(). +constexpr float SOURCE_BLUR_SPAN = 0.05f; +constexpr float SOURCE_BLUR_MAX_PX = 48.f; + +// Radius of the unsharp mask's blurred reference, in texels. Small and only mildly resolution-dependent: +// a sharpen is meant to act on the finest detail the image holds, so its radius tracks the texel grid +// rather than the picture's size. 1024x1024 gives ~2 texels, the usual photo-editor default. +float unsharp_radius(int width, int height) +{ + return std::max(1.f, 0.002f * float(std::min(width, height))); +} + +// Brightness as a *lift* toward white (or toward black) rather than an additive offset: positive b maps +// v to v + b*(1 - v), negative to v*(1 + b). Both reach the endpoint exactly at |b| == 1 while staying +// monotonic and non-clipping in between, which an additive shift does not - it drives a whole region to +// a flat 255 and, because this image is also the height map, that region bakes as flat relief rather +// than as bright relief. Keeping the curve injective keeps the detail in it. +float apply_brightness(float v, float b) +{ + return b >= 0.f ? v + b * (1.f - v) : v * (1.f + b); +} + +// Contrast about the 0.5 pivot. Gain is 1/(1-c) going up (c == 1 would be an infinite-slope threshold, so +// the caller's clamp keeps it finite) and 1+c going down, reaching a flat mid-grey at c == -1. +float apply_contrast(float v, float c) +{ + const float gain = c >= 0.f ? 1.f / std::max(1e-3f, 1.f - c) : 1.f + c; + return (v - 0.5f) * gain + 0.5f; +} + +// Blur then sharpen one 8-bit channel in place. Shared by the grayscale path and by each of a colour +// image's three channels, so every channel gets the identical spatial treatment and a colour image's +// hue cannot shift just because its channels were filtered differently. +void adjust_channel_spatial(std::vector &channel, int width, int height, float blur, float sharpness) +{ + if (blur > 0.f) + smooth_height_pixels(channel, width, height, adjustment_blur_radius(blur, width, height)); + + if (sharpness <= 0.f) + return; + + // Unsharp mask: push each texel away from its local average by `amount` times the difference. The + // blurred reference is a copy, so the sharpen reads the un-sharpened neighbourhood everywhere rather + // than its own partly-written output. + std::vector base = channel; + smooth_height_pixels(base, width, height, unsharp_radius(width, height)); + const float amount = 1.5f * std::clamp(sharpness, 0.f, 1.f); + tbb::parallel_for(tbb::blocked_range(0, channel.size()), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) { + const float v = float(channel[i]) + amount * (float(channel[i]) - float(base[i])); + channel[i] = uint8_t(std::lround(std::clamp(v, 0.f, 255.f))); + } + }); +} + struct TextureDetailCache { std::mutex mutex; @@ -387,6 +494,150 @@ struct TextureDetailCache TextureDetailCache g_texture_detail_cache; } // namespace +float adjustment_blur_radius(float blur, int width, int height) +{ + if (blur <= 0.f || width <= 0 || height <= 0) + return 0.f; + const float span = SOURCE_BLUR_SPAN * float(std::min(width, height)); + return std::clamp(blur, 0.f, 1.f) * std::min(span, SOURCE_BLUR_MAX_PX); +} + +DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px) +{ + if (src.empty() || max_px <= 0 || (src.width <= max_px && src.height <= max_px)) + return src; + + const int scale = std::max(1, (std::max(src.width, src.height) + max_px - 1) / max_px); + const int w = std::max(1, src.width / scale); + const int h = std::max(1, src.height / scale); + const bool color = src.has_color(); + + DecodedHeightTexture out; + out.width = w; + out.height = h; + out.pixels.resize(size_t(w) * size_t(h)); + if (color) + out.rgb.resize(size_t(w) * size_t(h) * 3); + + tbb::parallel_for(tbb::blocked_range(0, h), [&](const tbb::blocked_range &r) { + for (int y = r.begin(); y < r.end(); ++y) + for (int x = 0; x < w; ++x) { + // The source block this destination texel covers. The max() keeps the block non-empty + // when rounding would otherwise collapse it. + const int x0 = x * src.width / w, x1 = std::max(x0 + 1, (x + 1) * src.width / w); + const int y0 = y * src.height / h, y1 = std::max(y0 + 1, (y + 1) * src.height / h); + uint32_t sum_h = 0, sum_c[3] = { 0, 0, 0 }, n = 0; + for (int sy = y0; sy < y1 && sy < src.height; ++sy) + for (int sx = x0; sx < x1 && sx < src.width; ++sx, ++n) { + const size_t si = size_t(sy) * size_t(src.width) + size_t(sx); + sum_h += src.pixels[si]; + if (color) + for (int c = 0; c < 3; ++c) + sum_c[c] += src.rgb[si * 3 + size_t(c)]; + } + const size_t di = size_t(y) * size_t(w) + size_t(x); + out.pixels[di] = n > 0 ? uint8_t(sum_h / n) : 0; + if (color) + for (int c = 0; c < 3; ++c) + out.rgb[di * 3 + size_t(c)] = n > 0 ? uint8_t(sum_c[c] / n) : 0; + } + }); + return out; +} + +DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj) +{ + DecodedHeightTexture out = src; + if (src.empty() || adj.identity()) + return out; + + const int w = out.width; + const int h = out.height; + const size_t n = size_t(w) * size_t(h); + + const float brightness = std::clamp(adj.brightness, -1.f, 1.f); + // Short of 1, so apply_contrast()'s gain stays finite; at this value it is already a 1000x slope, + // i.e. a threshold in all but name. + const float contrast = std::clamp(adj.contrast, -1.f, 0.999f); + const float blur = std::clamp(adj.blur, 0.f, 1.f); + const float sharpness = std::clamp(adj.sharpness, 0.f, 1.f); + + // The tonal pass: a 256-entry lookup table, because brightness and contrast are the same scalar + // curve for every texel and every channel. Building it once beats evaluating two curves a few + // million times, and it is what keeps this pass off the panel's critical path. + std::array tone{}; + for (int i = 0; i < 256; ++i) { + float v = float(i) / 255.f; + v = apply_brightness(v, brightness); + v = apply_contrast(v, contrast); + tone[size_t(i)] = uint8_t(std::lround(std::clamp(v, 0.f, 1.f) * 255.f)); + } + + if (!out.has_color()) { + // Grayscale: one channel, and hue has nothing to turn. + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) + out.pixels[i] = tone[out.pixels[i]]; + }); + adjust_channel_spatial(out.pixels, w, h, blur, sharpness); + return out; + } + + // Colour: tone each channel through the same table, then rotate the hue about the luminance axis. + // + // The rotation is feColorMatrix's hueRotate matrix (SVG filters), which holds Rec.709 luma fixed. + // The height below is taken with decode_height_texture()'s Rec.601 coefficients instead, so a hue + // turn is not perfectly height-neutral - a few levels at full saturation. Reconciling the two would + // mean a different rotation than every other tool's, which is the worse trade: the user is watching + // the colour, and the height follows the colour by construction either way. + const float theta = adj.hue_deg * float(M_PI) / 180.f; + const float c = std::cos(theta); + const float s = std::sin(theta); + const float m[9] = { + 0.213f + c * 0.787f - s * 0.213f, 0.715f - c * 0.715f - s * 0.715f, 0.072f - c * 0.072f + s * 0.928f, + 0.213f - c * 0.213f + s * 0.143f, 0.715f + c * 0.285f + s * 0.140f, 0.072f - c * 0.072f - s * 0.283f, + 0.213f - c * 0.213f - s * 0.787f, 0.715f - c * 0.715f + s * 0.715f, 0.072f + c * 0.928f + s * 0.072f, + }; + const bool turn_hue = adj.hue_deg != 0.f; + + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) { + const float rr = float(tone[out.rgb[i * 3]]); + const float gg = float(tone[out.rgb[i * 3 + 1]]); + const float bb = float(tone[out.rgb[i * 3 + 2]]); + const float nr = turn_hue ? m[0] * rr + m[1] * gg + m[2] * bb : rr; + const float ng = turn_hue ? m[3] * rr + m[4] * gg + m[5] * bb : gg; + const float nb = turn_hue ? m[6] * rr + m[7] * gg + m[8] * bb : bb; + out.rgb[i * 3] = uint8_t(std::lround(std::clamp(nr, 0.f, 255.f))); + out.rgb[i * 3 + 1] = uint8_t(std::lround(std::clamp(ng, 0.f, 255.f))); + out.rgb[i * 3 + 2] = uint8_t(std::lround(std::clamp(nb, 0.f, 255.f))); + } + }); + + // The spatial pass wants one channel at a time (smooth_height_pixels() is a single-channel blur, and + // reusing it is what keeps this consistent with Smoothing), so de-interleave, filter, re-interleave. + if (blur > 0.f || sharpness > 0.f) { + std::vector channel(n); + for (int ch = 0; ch < 3; ++ch) { + for (size_t i = 0; i < n; ++i) + channel[i] = out.rgb[i * 3 + size_t(ch)]; + adjust_channel_spatial(channel, w, h, blur, sharpness); + for (size_t i = 0; i < n; ++i) + out.rgb[i * 3 + size_t(ch)] = channel[i]; + } + } + + // Height is the luminance of the colour that came out, not the toned luminance of the colour that + // went in: the two differ once hue rotation or a per-channel clamp has moved the channels around, + // and a height that disagrees with the colour it is stored next to is the bug this avoids. + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) + out.pixels[i] = uint8_t(std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] + + 0.114 * out.rgb[i * 3 + 2])); + }); + return out; +} + TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer) { TextureDetail out; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index f42e3c9c4c..6a121d19a8 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -154,6 +154,38 @@ struct TextureIsland // chart to its true mm area, so a freshly unwrapped patch has uniform texel density to begin with. void average_island_scales(std::vector &islands); +// Photo-style adjustments to a layer's source image, as the gizmo's image-adjustment panel sets them. +// +// These are *not* layer parameters: nothing here is stored on TextureDisplacementLayer and nothing here is +// re-applied at bake time. The panel applies them once, to the image, and the result becomes the layer's +// image - so the adjusted pixels feed the colour and the displacement together, and a brightness change +// really does change the relief. That is the intended behaviour: one image, one source of truth, rather +// than a second set of knobs the bake would have to replay in the right order. +// +// Contrast with TextureDisplacementLayer::smoothing, which *is* a layer parameter applied on every decode: +// `blur` here is a destructive edit of the image, `smoothing` is a live low-pass over whatever the image is. +struct TextureAdjustments +{ + float brightness = 0.f; // [-1, 1]; 0 unchanged, -1 black, +1 white + float contrast = 0.f; // [-1, 1]; 0 unchanged, -1 flat mid-grey, +1 hard threshold + float sharpness = 0.f; // [0, 1]; 0 unchanged. Unsharp-mask amount + float blur = 0.f; // [0, 1]; 0 unchanged. Fraction of SOURCE_BLUR_SPAN, as `smoothing` is + float hue_deg = 0.f; // [-180, 180]; 0 unchanged. No effect on a grayscale image - grey has no hue + + bool identity() const + { + return brightness == 0.f && contrast == 0.f && sharpness == 0.f && blur == 0.f && hue_deg == 0.f; + } + // Exact equality, which is what is wanted: these come straight off sliders, and the question asked of + // them is "is this still the value the preview on screen was computed from", not "are these close". + bool operator==(const TextureAdjustments &o) const + { + return brightness == o.brightness && contrast == o.contrast && sharpness == o.sharpness && + blur == o.blur && hue_deg == o.hue_deg; + } + bool operator!=(const TextureAdjustments &o) const { return !(*this == o); } +}; + // One texture asset plus its projection/displacement parameters. Several layers may be painted // onto overlapping areas of the same volume: their displacements are combined per vertex, in slot // order, each layer folding into the total via its own TextureBlendMode (see @@ -277,6 +309,18 @@ struct TextureDisplacementLayer // - zero or positive: a mesh vertex, every unwrapped copy of it (how edits were stored before). std::vector> lscm_uv_overrides; + // Live, unsaved image adjustments from the gizmo's image-adjustment panel, applied by + // decode_height_texture() on top of the decoded image - so while the panel is open the relief, the + // colour, the thumbnails and the bake all see the adjusted image, and moving a slider really does + // change the displacement. See TextureAdjustments. + // + // Deliberately *not* serialized, by either save()/load() here or the .3mf: it is the state of an open + // editor, not a property of the layer. The panel resets it when it closes, and "Save as copy" resolves + // it by writing a new image and pointing the layer at that - which is the only way an adjustment + // becomes permanent. A project saved mid-adjustment therefore stores the unadjusted image, which is + // the honest answer: nothing was committed. + TextureAdjustments preview_adjust; + // How this layer folds into the displacement accumulated by the layers below it. Ignored for // the lowest-slot painted layer, which has nothing beneath it to combine with (the GUI shows // it as the "Base" layer and hides the control). @@ -504,6 +548,36 @@ struct DecodedHeightTexture bool texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &out) const; }; +// Applies `adj` to `src` and returns the result; `src` is left alone. An empty `src`, or an identity +// `adj`, gives back a copy. +// +// Order is tonal, then spatial: brightness, contrast and hue per texel first, then blur, then sharpness. +// Sharpness last is what lets the two spatial knobs be used together - blur to kill speckle, then +// sharpness to put the real edges back - instead of the blur simply undoing the sharpen. +// +// Grayscale in, grayscale out (`rgb` stays empty): promoting a grey image to RGB would hand the layer's +// colour feature a row of identical greys to "colour" the model with, and triple the stored PNG for it. +// For a colour image the height is recomputed as the luminance of the *adjusted* colour, with +// decode_height_texture()'s own coefficients, so the relief and the colour can never drift apart. +DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj); + +// Blur radius, in texels, that TextureAdjustments::blur == 1 means on a `min(width, height)`-sized image: +// the same 5%-of-the-short-side span the Smoothing slider uses, under the same 48-texel cost cap, so the +// two controls bite at the same rate and a user moving between them is not surprised. +float adjustment_blur_radius(float blur, int width, int height); + +// Box-filters `src` down so neither side exceeds `max_px`, preserving aspect; never upscales, and +// returns a copy when it already fits. Colour and height are filtered the same way, and the height +// stays the height - it is not recomputed from the filtered colour, because a box filter is linear and +// luminance is linear in the channels, so the two agree already. +// +// This is what lets the adjustment panel's live preview cost depend on the size of the box it is drawn +// in rather than on the size of the image: adjusting a downscaled copy per slider frame, and the +// full-size image only once, on commit. A box filter (rather than a plain decimation) is the point - +// these textures are high-frequency by nature, and dropping texels would alias the preview into +// showing detail the adjustment is not actually producing. +DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px); + // Decode a layer's raw image bytes into sampleable height data, plus colour when the source has any. // Both 8-bit grayscale PNGs (the shipped library, and anything imported before colour was kept) and // colour PNGs are accepted; for a colour source the height is its luminance, using the same diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index c9b5a54370..14eb31b19c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -120,6 +121,14 @@ constexpr int THUMBNAIL_MAX_PX = 128; // blur of it - which is what made Fast look flatter and softer than the result it was previewing. constexpr int HEIGHT_TEX_MAX_PX = 2048; +// Size the image-adjustment panel's live preview is computed and uploaded at. The panel draws the image +// in a box about 240 px wide, so there is nothing to show above this - and the preview is recomputed on +// every slider move, which is what makes its cost worth bounding. Measured on a 1024x1024 with every +// knob engaged, a full-size pass is 13-19 ms grayscale / 40-60 ms in colour (130-190 ms at 2048 in +// colour); the same pass on a 384 px copy is 3-4 ms / 9-12 ms, and it runs off the UI thread besides. +// The full-size image is only ever adjusted once, on commit. +constexpr int ADJUST_PREVIEW_MAX_PX = 384; + // How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine // band (see collect_paint_region()). Two is enough to grade the size change without the band's own // cost growing to matter: it is a ring around a perimeter, not an area. @@ -535,6 +544,10 @@ void GLGizmoTextureDisplacement::on_shutdown() // progress poll, and its completion handler then finds nothing to do. m_preview_generation->fetch_add(1); m_preview_job_pending = false; + // The adjustment panel holds a full-size decode of its layer's image, which is the largest thing + // here; a closed gizmo should not keep it alive. Discards the edit, as closing the gizmo on an open + // panel implies. + close_image_adjust(); // The palette caches hold the last volume's images; a closed gizmo should not keep them alive. m_palette_cache.clear(); m_palette_quantizer = nullptr; @@ -2471,9 +2484,12 @@ void GLGizmoTextureDisplacement::update_uv_editor() // before, and independently of, the unwrap - the texture belongs on screen before anything is unwrapped - and // only when the choice, the layer's image or its smoothing changed, because the height image is large. const UVBackground desired_bg = (m_uv_check_mode == UVCheckMode::Checker) ? UVBackground::Checker : UVBackground::Height; - const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && (m_uv_editor_bg_smoothing != layer->smoothing); + const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && + (m_uv_editor_bg_smoothing != layer->smoothing || + !(m_uv_editor_bg_adjust == layer->preview_adjust)); if (desired_bg != m_uv_editor_bg || layer->image_data.get() != m_uv_editor_bg_image || bg_smoothing_changed) { m_uv_editor_bg_smoothing = layer->smoothing; + m_uv_editor_bg_adjust = layer->preview_adjust; m_uv_editor_bg_image = layer->image_data.get(); m_uv_editor_bg = desired_bg; if (desired_bg == UVBackground::Checker) { @@ -3744,6 +3760,7 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() "texture_displacement_checker.svg", "texture_displacement_distortion.svg", "texture_displacement_wireframe.svg", "texture_displacement_cross.svg", "texture_displacement_uv_select_island.svg", "texture_displacement_uv_select_edge.svg", + "cog.svg", // the layer card's "adjust image" button "texture_displacement_uv_select_vertex.svg", // Paint / Erase, brush cursor shape, mapping, tiling, placement and the dock toggle. "texture_displacement_add.svg", "texture_displacement_add_negative.svg", "circle_paint.svg", @@ -4056,6 +4073,338 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane m_picker_open_request = true; } } +bool GLGizmoTextureDisplacement::load_image_adjust_source(const TextureDisplacementLayer &layer) +{ + if (layer.empty()) + return false; + + // The *raw* image: neither the layer's Smoothing nor the panel's own preview_adjust, so what the + // sliders act on is always the image as it is actually stored rather than the last preview of it. + // Without clearing preview_adjust here the adjustment would compound on itself every reload. + TextureDisplacementLayer raw = layer; + raw.smoothing = 0.f; + raw.preview_adjust = TextureAdjustments{}; + + DecodedHeightTexture decoded = decode_height_texture(raw); + if (decoded.empty()) + return false; + + m_adjust_source = std::move(decoded); + m_adjust_preview_source = downscale_height_texture(m_adjust_source, ADJUST_PREVIEW_MAX_PX); + m_adjust_source_image = layer.image_data.get(); + // The unadjusted image, so the box is filled now rather than one background pass later. Pairing it + // with an identity `shown` is what makes update_image_adjust_preview() relaunch for the sliders' + // current values against the new source instead of deciding it is already showing them. + m_adjust_preview_shown = TextureAdjustments{}; + m_adjust_preview_tex = upload_height_thumbnail(m_adjust_preview_source, ADJUST_PREVIEW_MAX_PX); + m_adjust_preview_valid = m_adjust_preview_tex != nullptr; + return true; +} + +void GLGizmoTextureDisplacement::open_image_adjust(const TextureDisplacementLayer &layer) +{ + if (layer.empty() || layer.slot < 0) + return; + + close_image_adjust(); // reverts and clears any previous panel + + if (!load_image_adjust_source(layer)) + return; + + m_adjust = TextureAdjustments{}; + m_adjust_applied = TextureAdjustments{}; + m_adjust_slot = layer.slot; + const ModelVolume *mv = texture_volume(); + m_adjust_volume_id = mv != nullptr ? mv->id().id : 0; +} + +void GLGizmoTextureDisplacement::invalidate_adjust_textures() +{ + // The GPU copies of a layer's image - the list thumbnail, the preview shader's height and colour + // textures - are keyed on the image_data pointer and the layer's Smoothing, and a live adjustment + // changes neither: the pointer still names the same PNG. Rather than widen three cache keys for a + // panel that is open a few seconds at a time, the panel drops the copies itself whenever it moves + // the adjustment. Resetting the source is enough; the next get_layer_*() rebuilds from the decode, + // which does see the adjustment. + m_thumbnail_source.fill({}); + m_color_tex_source.reset(); + m_height_tex_source.reset(); +} + +void GLGizmoTextureDisplacement::close_image_adjust() +{ + // Revert the layer's live adjustment. Every exit that reaches here is a discard: a commit resets + // preview_adjust itself, before pointing the layer at the file that already carries the edit, so this + // cannot undo a committed adjustment. + // + // Found by the id recorded when the panel opened rather than through texture_volume(), which answers + // for the *current* selection. One of the ways to get here is the selection having moved on, and + // reverting the newly selected object's layer instead would both miss the adjustment that is still + // live on the original volume and clear one the user never touched. + bool reverted = false; + if (m_adjust_slot >= 0 && m_adjust_volume_id != 0) + for (ModelObject *mo : wxGetApp().model().objects) { + for (ModelVolume *mv : mo->volumes) { + if (mv->id().id != m_adjust_volume_id) + continue; + for (TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.slot == m_adjust_slot && !l.preview_adjust.identity()) { + l.preview_adjust = TextureAdjustments{}; + reverted = true; + } + } + } + + if (reverted) + invalidate_adjust_textures(); + + // std::async's future blocks here until the pass finishes. That is a few milliseconds on an image + // this small, and the alternative - a detached thread writing into the members of a closed panel - + // is worse. + if (m_adjust_preview_job.valid()) + m_adjust_preview_job.wait(); + m_adjust_preview_job = {}; + + m_adjust_slot = -1; + m_adjust = TextureAdjustments{}; + m_adjust_applied = TextureAdjustments{}; + m_adjust_source = DecodedHeightTexture{}; + m_adjust_preview_source = DecodedHeightTexture{}; + m_adjust_source_image = nullptr; + m_adjust_volume_id = 0; + m_adjust_preview_tex.reset(); + m_adjust_preview_valid = false; + + // Only while the gizmo is up: on_shutdown() also comes through here, and rebuilding a preview for a + // gizmo that is closing would be work for a model nobody is looking at. The reverted layer data is + // what matters there, and that is already done above. + if (reverted && m_state == On) { + rebuild_preview(); + m_parent.set_as_dirty(); + } +} + +void GLGizmoTextureDisplacement::update_image_adjust_preview() +{ + if (m_adjust_preview_source.empty()) + return; + + // Collect a finished pass. wait_for(0) rather than get() outright: this runs inside the frame, and + // blocking on the worker here would hand back to the UI thread the very stall the worker exists to + // spare it. + if (m_adjust_preview_job.valid() && + m_adjust_preview_job.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + const DecodedHeightTexture adjusted = m_adjust_preview_job.get(); + m_adjust_preview_job = {}; + if (std::unique_ptr tex = upload_height_thumbnail(adjusted, ADJUST_PREVIEW_MAX_PX)) { + m_adjust_preview_tex = std::move(tex); + m_adjust_preview_shown = m_adjust_preview_running; + m_adjust_preview_valid = true; + } + } + + // One pass at a time: whatever the sliders did while it ran is picked up by the next launch, so a + // drag collapses to the value the sliders ended on instead of queuing one pass per frame. + if (!m_adjust_preview_job.valid() && m_adjust != m_adjust_preview_shown) { + m_adjust_preview_running = m_adjust; + // Captured by value: the pass must not read members the UI thread goes on editing while it runs. + m_adjust_preview_job = std::async(std::launch::async, + [src = m_adjust_preview_source, adj = m_adjust_preview_running] { + return apply_texture_adjustments(src, adj); + }); + } + + // A pass in flight has no other way to get its result on screen: without a redraw request the canvas + // can sit idle, and the preview would only catch up on the next unrelated frame. + if (m_adjust_preview_job.valid()) + m_parent.set_as_dirty(); +} + +void GLGizmoTextureDisplacement::commit_image_adjust(TextureDisplacementLayer &layer) +{ + if (m_adjust_source.empty() || m_adjust.identity()) { + // Nothing was changed, so there is nothing to save. + close_image_adjust(); + return; + } + + // The full-size pass, once: 13-19 ms grayscale / 40-60 ms colour on a 1024x1024 with every knob + // engaged. Run on the UI thread deliberately, unlike the live preview: this is a single click, and the + // rebuild that follows it - a CPU mesh displacement of the painted patch - is already an order of + // magnitude more work, synchronously, so moving only this off the thread would buy nothing a user + // could perceive. + const DecodedHeightTexture adjusted = apply_texture_adjustments(m_adjust_source, m_adjust); + + std::string error; + const std::string stem = layer.name.empty() ? std::string("texture") : layer.name; + const std::optional entry = save_texture_to_library(adjusted, stem, error); + if (!entry) { + show_error(nullptr, error); + return; // the panel stays open, and the layer keeps the live adjustment: the edit is not lost + } + + // Cleared *before* the layer is pointed at the new file, and this ordering is the whole correctness of + // the commit: the saved image already has the adjustment baked into its pixels, so leaving + // preview_adjust set would apply brightness, contrast and the rest a second time on top of itself. + layer.preview_adjust = TextureAdjustments{}; + // set_layer_texture() does the rest - the name, the recently-used ordering, a fresh image_data buffer + // (which is what invalidates every cache keyed by the old pointer), the undo snapshot and the rebuild. + set_layer_texture(layer, *entry); + + close_image_adjust(); +} + +void GLGizmoTextureDisplacement::render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max) +{ + if (m_adjust_slot < 0) + return; + + ModelVolume *mv = texture_volume(); + // A different volume is selected than the one this panel was opened on. Slots are numbered per + // volume, so looking m_adjust_slot up here would find an unrelated layer rather than nothing. + if (mv == nullptr || mv->id().id != m_adjust_volume_id) { + close_image_adjust(); + return; + } + + TextureDisplacementLayer *layer = nullptr; + for (TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.slot == m_adjust_slot) + layer = &l; + if (layer == nullptr) { + // The layer went away under the panel (removed, or merged away by an undo). + close_image_adjust(); + return; + } + + // The layer's image can be swapped from the texture picker while this panel is open - the panel is a + // window now, so the rest of the gizmo stays usable behind it. Re-read the source when that happens, + // keeping the slider positions: the user is adjusting this layer, and the 3D view has already moved to + // the new image (decode_height_texture() reads layer.image_data, which changed), so re-reading here is + // also what keeps the panel's photo and the model showing the same thing. + if (layer->image_data.get() != m_adjust_source_image && !load_image_adjust_source(*layer)) { + close_image_adjust(); // the new image cannot be decoded; nothing left to adjust + return; + } + + const float img_w = std::round(m_imgui->scaled(16.f)); + const float gap = m_imgui->scaled(0.5f); + // Placed beside the panel on first appearance only, and left wherever the user drags it after that - + // it is a window, not a popup, so its position is theirs to keep. + const float win_w = img_w + 2.f * ImGui::GetStyle().WindowPadding.x; + const bool left = panel_min.x - gap - win_w >= 0.f; + ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap - win_w : panel_max.x + gap, + panel_min.y + m_imgui->scaled(4.f)), ImGuiCond_Appearing); + + bool keep_open = true; + if (!ImGui::Begin((_u8L("Adjust image") + "##texture_adjust").c_str(), &keep_open, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + if (!keep_open) + close_image_adjust(); + return; + } + + // Collect a finished pass and start one for the current slider values before the image is drawn, so + // the newest available result is what lands on screen this frame. + update_image_adjust_preview(); + + // The photo itself, at the image's own aspect so a non-square texture is not stretched. + const float aspect = m_adjust_source.height > 0 ? float(m_adjust_source.width) / float(m_adjust_source.height) : 1.f; + const float img_h = std::round(img_w / std::max(0.05f, aspect)); + if (m_adjust_preview_valid && m_adjust_preview_tex) + ImGui::Image((ImTextureID) (intptr_t) m_adjust_preview_tex->get_id(), ImVec2(img_w, img_h)); + else + ImGui::Dummy(ImVec2(img_w, img_h)); + + // The label column is sized off the widest of the five labels, so the tracks line up whatever the + // translation does to their lengths. + float label_w = 0.f; + for (const wxString &l : { _L("Brightness"), _L("Contrast"), _L("Sharpness"), _L("Blur"), _L("Hue") }) + label_w = std::max(label_w, ImGui::CalcTextSize(into_u8(l).c_str()).x); + label_w += m_imgui->scaled(1.2f); + // An explicit track width rather than a fill-to-the-right one: this window is AlwaysAutoResize, so its + // width comes from its content, and an item asking for "whatever is left" would make the width depend + // on itself. The image above sets the content width, and the sliders match it. + const float slider_w = std::max(m_imgui->scaled(4.f), img_w - label_w); + + const auto row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, + const char *format, bool enabled, const std::string &tip) { + const float x0 = ImGui::GetCursorPosX(); + ImGui::AlignTextToFramePadding(); + m_imgui->text(label); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + ImGui::SetNextItemWidth(slider_w); + m_imgui->disabled_begin(!enabled); + ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp); + m_imgui->disabled_end(); + // Only on an enabled row: a disabled item does not report hover, so a tooltip on one would never + // be shown. What has to be said about a disabled slider is written out below it instead. + if (enabled && !tip.empty() && ImGui::IsItemHovered()) + m_imgui->tooltip(tip, ImGui::GetFontSize() * 20.f); + }; + + row("##adj_brightness", _L("Brightness"), &m_adjust.brightness, -1.f, 1.f, "%.2f", true, {}); + row("##adj_contrast", _L("Contrast"), &m_adjust.contrast, -1.f, 1.f, "%.2f", true, {}); + row("##adj_sharpness", _L("Sharpness"), &m_adjust.sharpness, 0.f, 1.f, "%.2f", true, + _u8L("Brings out the finest detail in the image. Applied after Blur, so the two can be used " + "together: blur away the speckle, then sharpen the edges that remain.")); + row("##adj_blur", _L("Blur"), &m_adjust.blur, 0.f, 1.f, "%.2f", true, + _u8L("Blurs the image itself. The layer's own Smoothing slider softens the relief without altering " + "the image, and is the one to reach for first.")); + // Grey has no hue to turn, and the shipped library is grayscale - so on most textures this is a + // slider that would visibly do nothing. Disabled with the reason given, rather than hidden. + const bool has_color = m_adjust_source.has_color(); + row("##adj_hue", _L("Hue"), &m_adjust.hue_deg, -180.f, 180.f, "%.0f°", has_color, {}); + if (!has_color) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + img_w); + ImGui::TextDisabled("%s", _u8L("This texture is grayscale, so it has no hue to shift.").c_str()); + ImGui::PopTextWrapPos(); + } + + if (m_imgui->button(_L("Reset"))) + m_adjust = TextureAdjustments{}; + + // Hand the sliders to the layer, which is what puts the adjusted image in front of the displacement: + // decode_height_texture() applies preview_adjust, so the relief, the colour and the thumbnails all + // follow from here. Written every frame rather than only on a change, so an undo snapshot or a + // reselection that resets the layer cannot leave the model showing an adjustment the panel has moved + // on from. + layer->preview_adjust = m_adjust; + if (m_adjust != m_adjust_applied) { + m_adjust_applied = m_adjust; + invalidate_adjust_textures(); + // Marked dirty rather than rebuilt here: the panel's own flag is consumed by the main panel's + // "rebuild once the mouse is released" rule, so dragging a slider recomputes the displacement + // once, on release, instead of on every frame of the drag. That is what keeps the UI responsive + // while a full-size resample and a mesh displacement happen behind it. + m_preview_params_dirty = true; + // This window is drawn after the block that consumes that flag, so the rebuild happens on a later + // frame - which means there has to *be* one. Cheap insurance: a slider release that generated no + // further canvas event would otherwise leave the flag set and the model unchanged until something + // unrelated redrew. + m_parent.set_as_dirty(); + } + + ImGui::Separator(); + m_imgui->text_wrapped(_u8L("The model updates when you release a slider. Save as copy writes the " + "adjusted image to your texture library and points this layer at it."), img_w); + + const bool cancel = m_imgui->button(_L("Cancel")); + ImGui::SameLine(); + // Nothing to save until a slider has actually moved. + m_imgui->disabled_begin(m_adjust.identity()); + const bool as_copy = m_imgui->button(_L("Save as copy")); + m_imgui->disabled_end(); + + ImGui::End(); + + if (cancel || !keep_open) + close_image_adjust(); + else if (as_copy) + commit_image_adjust(*layer); +} void GLGizmoTextureDisplacement::remove_texture_layer(int slot) { @@ -6580,7 +6929,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); const std::string not_painted = _u8L("not painted"); - const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 3.f * frame_h + 2.f * gap_s; + const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 4.f * frame_h + 3.f * gap_s; const float name_w = std::max(frame_h, ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::GetCursorPosX() - gap_s - right_w); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(orca.x, orca.y, orca.z, 0.2f)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(orca.x, orca.y, orca.z, 0.35f)); @@ -6594,6 +6943,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); ImGui::TextDisabled("%s", not_painted.c_str()); } + // Between the name and the order arrows: it acts on the image the name refers to, so it + // belongs with the name rather than with the controls that reorder the stack. + ImGui::SameLine(); + m_imgui->disabled_begin(busy || layer.empty()); + if (icon_button(613 + layer.slot, "cog.svg", frame_h, _L("Adjust image"), + _u8L("Brightness, contrast, sharpness, blur and hue, shown on the image and on the " + "model as you set them. Save as copy writes the result to your texture library " + "and points this layer at it."))) + open_image_adjust(layer); + m_imgui->disabled_end(); ImGui::SameLine(); m_imgui->disabled_begin(busy || li == 0); if (icon_button(611, "texture_displacement_move_up.svg", frame_h, _L("Move up"), _L("Move up - applied earlier"))) { @@ -7391,10 +7750,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float set_active_layer(m_picker_slot); // In this window's scope, where the popup's open request and the popup itself share one ID stack. + ImVec2 panel_rect_min, panel_rect_max; { const ImVec2 win_min = ImGui::GetWindowPos(); const ImVec2 win_sz = ImGui::GetWindowSize(); render_texture_library_popup(win_min, ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y)); + // Only recorded here. The image-adjustment panel is a top-level window rather than a popup, so it + // cannot be begun inside this one; it is drawn after GizmoImguiEnd() below. + panel_rect_min = win_min; + panel_rect_max = ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y); } // ImGui sliders report "changed" continuously on every frame while being dragged, not just once on @@ -7632,6 +7996,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_parent.request_extra_frame(); } GizmoImguiEnd(); + + // Outside the gizmo's window, because it is a window of its own: a floating, non-modal panel that + // leaves the 3D view live so the relief can be watched while the sliders move. No-op when closed. + render_image_adjust_window(panel_rect_min, panel_rect_max); + ImGuiWrapper::pop_toolbar_style(); // Drawn last, over everything, via the foreground draw list: the +/- add-remove sign next to the 3D diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 927954dd76..6470743543 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -23,6 +23,7 @@ #include "libslic3r/Point.hpp" #include #include +#include #include #include #include "slic3r/GUI/Gizmos/GLGizmoBase.hpp" @@ -435,6 +436,71 @@ private: std::string m_slider_type_request; int m_slider_type_request_frame = 0; + // ---- The image-adjustment panel ---- + // + // Brightness/contrast/sharpness/blur/hue over one layer's image, shown on the image itself and + // updating as the sliders move. What it produces is not a set of layer parameters: it replaces the + // layer's *image*, so the adjusted pixels feed the colour and the displacement together and a + // brightness change moves the relief too (see TextureAdjustments). + // + // An ordinary floating window rather than a popup, and deliberately so. A popup dims the canvas + // behind it and closes on any click outside itself, which between them make it impossible to watch the + // relief change on the model - which is the whole point of adjusting the image here rather than in an + // image editor. A window leaves the 3D view live, clickable and orbitable while the panel is open. + void render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max); + // Opens the panel for `layer`, decoding its image into m_adjust_source. Sliders never re-decode + // the PNG. + void open_image_adjust(const TextureDisplacementLayer &layer); + // Reads `layer`'s stored image into the panel's source buffers and records which image it came from. + // Called when the panel opens and again whenever the layer's image is swapped under it, which is why + // it leaves the slider values alone. False when the image cannot be decoded. + bool load_image_adjust_source(const TextureDisplacementLayer &layer); + // Resolves the panel: writes the adjusted image into the user's texture library as a new file and + // points `layer` at it, which is the only way an adjustment becomes permanent. Closes the panel. + void commit_image_adjust(TextureDisplacementLayer &layer); + // Closes the panel and reverts the layer's live preview_adjust to identity, so every exit that is not + // a commit - Cancel, the window's close button, the gizmo closing - leaves the layer as it was found. + void close_image_adjust(); + // Collects a finished preview and starts one for the current slider values. One at a time, so a drag + // coalesces into "whatever the sliders last said" instead of a queue of superseded frames. + void update_image_adjust_preview(); + + int m_adjust_slot = -1; // the layer the panel is open for; -1 when closed + TextureAdjustments m_adjust; // what the sliders currently say + // What the layer's live preview_adjust was last set from, so a slider move can be told from a frame in + // which nothing happened and only the former asks the displacement to be recomputed. + TextureAdjustments m_adjust_applied; + + // The layer's image as it was when the panel opened, and a small copy of it. The sliders are applied + // to the small copy for the live preview and to the full-size one exactly once, on commit. Measured on + // a 1024x1024 with every knob engaged, a full-size pass costs 13-19 ms grayscale and 40-60 ms in + // colour (130-190 ms at 2048 in colour) - far too much to repeat per slider frame. The same pass on a + // 384 px copy is 3-4 ms / 9-12 ms, and that is all the preview box can show anyway. + DecodedHeightTexture m_adjust_source; + DecodedHeightTexture m_adjust_preview_source; + // Which image_data the two buffers above were decoded from, so the panel can notice the layer's image + // being swapped from the texture picker while it is open and re-read it. Compared, never dereferenced. + const void *m_adjust_source_image = nullptr; + // And which volume the panel was opened on. Slot numbers are per volume, so without this a change of + // selection would silently rebind the open panel to whichever layer happens to hold the same slot on + // the newly selected object - a different image, under a panel the user opened for this one. + size_t m_adjust_volume_id = 0; + // The preview image on screen, re-uploaded whenever a background pass finishes. + std::unique_ptr m_adjust_preview_tex; + + // The live preview runs on its own thread rather than on the UI thread or on the plater's job + // worker. Not the UI thread because even the small pass is milliseconds and this runs while a slider + // is being dragged; not the job worker because that is a single FIFO shared with Bake and the + // displacement preview (see queue_preview_job()), where a per-frame image filter would sit behind - + // and delay - multi-second mesh work. + std::future m_adjust_preview_job; + TextureAdjustments m_adjust_preview_running; // what the in-flight pass is computing + TextureAdjustments m_adjust_preview_shown; // what m_adjust_preview_tex holds + bool m_adjust_preview_valid = false; + // Drops the GPU copies of the layer's image so the next frame rebuilds them from the decode, which + // is where the live adjustment is applied. Called whenever the panel moves or reverts it. + void invalidate_adjust_textures(); + // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane // (a "pan" - drag anywhere on it for free 2D movement), plus two arrows along the patch's @@ -915,6 +981,7 @@ private: enum class UVBackground { None, Height, Checker }; UVBackground m_uv_editor_bg = UVBackground::None; float m_uv_editor_bg_smoothing = -1.f; // smoothing the height backdrop was uploaded at + TextureAdjustments m_uv_editor_bg_adjust; // and the live image adjustment it was uploaded at const void *m_uv_editor_bg_image = nullptr; // the layer image it was uploaded from // Per-chart distortion heatmap colour for the UV pane (#7/#14), computed once when the unwrap is // re-solved (relative stretch doesn't change when islands are merely moved), fed to the canvas only diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp index e3f73258df..69301b42d1 100644 --- a/src/slic3r/GUI/TextureLibrary.cpp +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -27,6 +27,7 @@ #include #include "libslic3r/PNGReadWrite.hpp" +#include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/AppConfig.hpp" @@ -158,6 +159,28 @@ bool encode_png_bytes(const wxImage &image, std::vector &out, std : encode_gray_png_bytes(image, out, error); } +// A wxImage over decoded pixels, so the encoders above can be reached from an in-memory image as well as +// from a file. Grayscale is expanded to the three equal channels wxImage always stores, which is what +// image_has_color() then reads as "no colour" - so a grey image still encodes as a grayscale PNG. +// +// `scratch` holds the pixel buffer: the wxImage is constructed over it without taking ownership, so it +// has to outlive every use of the returned image. +wxImage image_from_decoded(const DecodedHeightTexture &image, std::vector &scratch) +{ + if (image.empty()) + return wxImage(); + + const size_t n = size_t(image.width) * size_t(image.height); + scratch.resize(n * 3); + if (image.has_color()) + scratch.assign(image.rgb.begin(), image.rgb.end()); + else + for (size_t i = 0; i < n; ++i) + scratch[i * 3] = scratch[i * 3 + 1] = scratch[i * 3 + 2] = image.pixels[i]; + + return wxImage(image.width, image.height, scratch.data(), /* static_data */ true); +} + std::vector read_file_bytes(const std::string &path) { std::ifstream ifs(path, std::ios::binary); @@ -186,6 +209,42 @@ bool is_supported_height_map(const std::vector &bytes) std::vector g_library; bool g_library_scanned = false; +// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture - +// shipped-name collision, or a second edit of the same source - is never overwritten. Returns the new +// file's library entry. Shared by import_texture_to_library() and save_texture_to_library(), which +// differ only in where their bytes came from. +std::optional write_user_texture(const std::vector &bytes, const std::string &stem, + std::string &error) +{ + const std::string user_dir = user_texture_dir(); + if (user_dir.empty()) { + error = _u8L("Could not create the folder for imported textures."); + return std::nullopt; + } + + boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); + for (int i = 2; boost::filesystem::exists(dest); ++i) + dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); + + { + boost::nowide::ofstream ofs(dest.string(), std::ios::binary); + ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); + if (!ofs.good()) { + error = _u8L("Failed to save the imported texture."); + return std::nullopt; + } + } + + texture_library(true); // pick the new file up + const std::string dest_str = dest.string(); + for (const TextureLibraryEntry &e : g_library) + if (e.path == dest_str) + return e; + + error = _u8L("Failed to save the imported texture."); + return std::nullopt; +} + // The user's own textures, most recently used first, kept in the app config. File names are stored rather // than paths so the order survives a moved data directory; '/' separates them because no platform allows // it inside a file name. Bounded so a long-lived config does not grow a line without end. @@ -317,12 +376,6 @@ bool remove_user_texture(const std::string &path, std::string &error) std::optional import_texture_to_library(const std::string &source_path, std::string &error) { - const std::string user_dir = user_texture_dir(); - if (user_dir.empty()) { - error = _u8L("Could not create the folder for imported textures."); - return std::nullopt; - } - wxImage image; if (!image.LoadFile(from_u8(source_path)) || !image.IsOk()) { error = _u8L("Could not load the selected image."); @@ -333,30 +386,7 @@ std::optional import_texture_to_library(const std::string & if (!encode_png_bytes(image, bytes, error)) return std::nullopt; - // Never overwrite an existing texture (the user's or, if they picked the same name twice, their - // own earlier import) - uniquify instead. - const std::string stem = boost::filesystem::path(source_path).stem().string(); - boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); - for (int i = 2; boost::filesystem::exists(dest); ++i) - dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); - - { - boost::nowide::ofstream ofs(dest.string(), std::ios::binary); - ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); - if (!ofs.good()) { - error = _u8L("Failed to save the imported texture."); - return std::nullopt; - } - } - - texture_library(true); // pick the new file up - const std::string dest_str = dest.string(); - for (const TextureLibraryEntry &e : g_library) - if (e.path == dest_str) - return e; - - error = _u8L("Failed to save the imported texture."); - return std::nullopt; + return write_user_texture(bytes, boost::filesystem::path(source_path).stem().string(), error); } std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error) @@ -382,4 +412,21 @@ std::shared_ptr> load_texture_image_data(const std::s return std::make_shared>(std::move(converted)); } +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error) +{ + std::vector scratch; + const wxImage wx = image_from_decoded(image, scratch); + if (!wx.IsOk()) { + error = _u8L("The selected image is empty."); + return std::nullopt; + } + + std::vector bytes; + if (!encode_png_bytes(wx, bytes, error)) + return std::nullopt; + + return write_user_texture(bytes, stem.empty() ? std::string("texture") : stem, error); +} + } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureLibrary.hpp b/src/slic3r/GUI/TextureLibrary.hpp index 65b35bcea8..38ad277a84 100644 --- a/src/slic3r/GUI/TextureLibrary.hpp +++ b/src/slic3r/GUI/TextureLibrary.hpp @@ -6,6 +6,8 @@ #include #include +#include "libslic3r/TextureDisplacement.hpp" + namespace Slic3r::GUI { // One selectable height-map texture in the texture-displacement gizmo's texture picker. @@ -57,6 +59,14 @@ std::optional import_texture_to_library(const std::string & // Returns nullptr with `error` set if the file cannot be read or decoded at all. std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error); +// Writes `image` into user_texture_dir() as a new PNG named after `stem` (uniquified if taken, so an +// existing texture is never overwritten) and returns its library entry. The in-memory counterpart of +// import_texture_to_library(), for an image that was edited rather than read off disk. +// +// Returns nullopt with `error` set on failure. +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error); + } // namespace Slic3r::GUI #endif // slic3r_TextureLibrary_hpp_ diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index d0a417ccd0..f57be521ab 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -1301,6 +1302,294 @@ TEST_CASE("TextureDisplacement: the step cutter only cuts inside the region", "[ } } +// ---- Image adjustments (the gizmo's image-adjustment panel) ---------------------------------------- + +// A grayscale ramp 0..255 across the width, so every input level is present exactly once per row and a +// tonal curve can be read straight off the output. +static DecodedHeightTexture make_gray_ramp(int w = 256, int h = 4) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = uint8_t(x * 255 / std::max(1, w - 1)); + return t; +} + +// A one-texel checkerboard of 0 and 255 - the highest spatial frequency the grid can hold, which is what +// a blur has to remove and a downscale has to average rather than drop. +static DecodedHeightTexture make_gray_checkerboard(int w = 16, int h = 16) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = ((x + y) % 2 == 0) ? 0 : 255; + return t; +} + +static DecodedHeightTexture make_rgb_flat(uint8_t r, uint8_t g, uint8_t b, int w = 8, int h = 8) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + t.rgb.resize(size_t(w) * size_t(h) * 3); + for (size_t i = 0; i < size_t(w) * size_t(h); ++i) { + t.rgb[i * 3] = r; + t.rgb[i * 3 + 1] = g; + t.rgb[i * 3 + 2] = b; + t.pixels[i] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b)); + } + return t; +} + +TEST_CASE("an adjustment that moves no slider returns the image unchanged", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + const DecodedHeightTexture out = apply_texture_adjustments(src, TextureAdjustments{}); + CHECK(out.width == src.width); + CHECK(out.height == src.height); + CHECK(out.pixels == src.pixels); + CHECK(TextureAdjustments{}.identity()); +} + +TEST_CASE("brightness reaches pure white and pure black only at the ends of its range", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments up; + up.brightness = 1.f; + const DecodedHeightTexture white = apply_texture_adjustments(src, up); + CHECK(std::all_of(white.pixels.begin(), white.pixels.end(), [](uint8_t v) { return v == 255; })); + + TextureAdjustments down; + down.brightness = -1.f; + const DecodedHeightTexture black = apply_texture_adjustments(src, down); + CHECK(std::all_of(black.pixels.begin(), black.pixels.end(), [](uint8_t v) { return v == 0; })); +} + +TEST_CASE("brightness lifts the image without flattening it into a clipped plateau", "[TextureDisplacement]") +{ + // The reason brightness is a lift toward white rather than an additive offset: an offset would drive + // everything above mid-grey to a flat 255, and since this image is also the height map that region + // would bake as flat relief. Half a stop up must leave almost nothing at pure white. + const DecodedHeightTexture src = make_gray_ramp(); + TextureAdjustments adj; + adj.brightness = 0.5f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + + REQUIRE(out.pixels.size() == src.pixels.size()); + const size_t clipped = size_t(std::count(out.pixels.begin(), out.pixels.end(), uint8_t(255))); + CHECK(clipped * 100 < out.pixels.size()); // under 1%; an additive shift would clip half the ramp + + // Every texel got brighter, and the curve stays ordered: brighter in means brighter out. + for (size_t i = 0; i < out.pixels.size(); ++i) + REQUIRE(out.pixels[i] >= src.pixels[i]); + for (int x = 1; x < out.width; ++x) + REQUIRE(out.pixels[size_t(x)] >= out.pixels[size_t(x - 1)]); +} + +TEST_CASE("contrast at its lowest flattens the image to mid-grey and at its highest spreads it apart", + "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments flat; + flat.contrast = -1.f; + const DecodedHeightTexture grey = apply_texture_adjustments(src, flat); + // One value everywhere - the pivot. Which exact level that rounds to is not the point. + CHECK(std::adjacent_find(grey.pixels.begin(), grey.pixels.end(), std::not_equal_to()) == + grey.pixels.end()); + + TextureAdjustments hard; + hard.contrast = 1.f; + const DecodedHeightTexture split = apply_texture_adjustments(src, hard); + // A threshold about the pivot: the dark end of the ramp goes to black, the light end to white. + CHECK(split.pixels[0] == 0); + CHECK(split.pixels[size_t(split.width - 1)] == 255); +} + +TEST_CASE("blur removes the finest detail and sharpness exaggerates an edge", "[TextureDisplacement]") +{ + // Blur: a one-texel checkerboard is the highest frequency the grid holds, so a blur has to collapse it + // toward its own mean. Measured as mean absolute deviation, which needs no assumption about the mean. + const DecodedHeightTexture checker = make_gray_checkerboard(32, 32); + const auto deviation = [](const DecodedHeightTexture &t) { + double mean = 0.; + for (uint8_t v : t.pixels) + mean += double(v); + mean /= double(t.pixels.size()); + double dev = 0.; + for (uint8_t v : t.pixels) + dev += std::abs(double(v) - mean); + return dev / double(t.pixels.size()); + }; + TextureAdjustments blur; + blur.blur = 1.f; + CHECK(deviation(apply_texture_adjustments(checker, blur)) < 0.25 * deviation(checker)); + + // Sharpness: an unsharp mask pushes each side of a step further from the other, so the step + // overshoots - darker than the dark side just inside it, brighter than the bright side just inside. + DecodedHeightTexture step; + step.width = 64; + step.height = 8; + step.pixels.assign(size_t(step.width) * size_t(step.height), 100); + for (int y = 0; y < step.height; ++y) + for (int x = step.width / 2; x < step.width; ++x) + step.pixels[size_t(y) * size_t(step.width) + size_t(x)] = 150; + + TextureAdjustments sharp; + sharp.sharpness = 1.f; + const DecodedHeightTexture sharpened = apply_texture_adjustments(step, sharp); + const size_t row = size_t(step.height / 2) * size_t(step.width); + const size_t mid = size_t(step.width / 2); + CHECK(sharpened.pixels[row + mid - 1] < 100); // undershoot on the dark side of the step + CHECK(sharpened.pixels[row + mid] > 150); // overshoot on the light side +} + +TEST_CASE("a grayscale image stays grayscale and its hue slider does nothing", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + REQUIRE_FALSE(src.has_color()); + + TextureAdjustments hue; + hue.hue_deg = 120.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, hue); + // Not promoted to RGB: a grey image has no colour to offer the layer's colour feature, and storing it + // as RGB would triple the PNG for a row of identical greys. + CHECK_FALSE(out.has_color()); + CHECK(out.rgb.empty()); + CHECK(out.pixels == src.pixels); +} + +TEST_CASE("a colour image's height stays the luminance of its adjusted colour", "[TextureDisplacement]") +{ + // The invariant that keeps the relief and the colour from drifting apart: whatever the adjustment did + // to the channels, the height is recomputed from the channels that came out. + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + REQUIRE(src.has_color()); + + TextureAdjustments adj; + adj.brightness = 0.2f; + adj.contrast = 0.3f; + adj.hue_deg = 75.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + + for (size_t i = 0; i < out.pixels.size(); ++i) { + const long expect = std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] + + 0.114 * out.rgb[i * 3 + 2]); + REQUIRE(long(out.pixels[i]) == expect); + } + // The hue turn actually moved the colour, so the check above is not passing on an unchanged image. + CHECK(out.rgb != src.rgb); +} + +TEST_CASE("a full turn of the hue leaves a colour where it started", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + TextureAdjustments adj; + adj.hue_deg = 360.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + // Rounding through 8-bit channels, so a level or two of slack rather than exact equality. + for (size_t i = 0; i < out.rgb.size(); ++i) + REQUIRE(std::abs(int(out.rgb[i]) - int(src.rgb[i])) <= 2); +} + +TEST_CASE("the adjustment blur radius ramps from zero and stays under its cost cap", "[TextureDisplacement]") +{ + CHECK_THAT(adjustment_blur_radius(0.f, 512, 512), Catch::Matchers::WithinAbs(0.f, 1e-6f)); + // 5% of the short side, as the Smoothing slider uses. + CHECK_THAT(adjustment_blur_radius(1.f, 512, 512), Catch::Matchers::WithinAbs(25.6f, 1e-3f)); + CHECK_THAT(adjustment_blur_radius(0.5f, 512, 512), Catch::Matchers::WithinAbs(12.8f, 1e-3f)); + // Capped, so a large image at the top of the slider cannot cost without bound. + CHECK_THAT(adjustment_blur_radius(1.f, 8192, 8192), Catch::Matchers::WithinAbs(48.f, 1e-3f)); + // Sized off the short side, not the long one. + CHECK_THAT(adjustment_blur_radius(1.f, 4096, 200), Catch::Matchers::WithinAbs(10.f, 1e-3f)); +} + +TEST_CASE("downscaling a height texture averages the texels it drops and never upscales", + "[TextureDisplacement]") +{ + // Averaging, not decimation: a one-texel checkerboard halved must come out uniformly mid-grey. Picking + // one texel per block would instead come out all black or all white - detail the adjustment never + // produced, shown in the preview. + const DecodedHeightTexture small = downscale_height_texture(make_gray_checkerboard(16, 16), 8); + CHECK(small.width == 8); + CHECK(small.height == 8); + CHECK(std::all_of(small.pixels.begin(), small.pixels.end(), [](uint8_t v) { return v == 127; })); + + // Already within the limit: handed back as it is. + const DecodedHeightTexture ramp = make_gray_ramp(64, 4); + const DecodedHeightTexture same = downscale_height_texture(ramp, 256); + CHECK(same.width == ramp.width); + CHECK(same.height == ramp.height); + CHECK(same.pixels == ramp.pixels); + + // Aspect preserved, and a colour source keeps its colour. + const DecodedHeightTexture wide = downscale_height_texture(make_rgb_flat(200, 60, 30, 400, 100), 100); + CHECK(wide.width == 100); + CHECK(wide.height == 25); + CHECK(wide.has_color()); +} + +TEST_CASE("a layer's live image adjustment reaches its decode", "[TextureDisplacement]") +{ + // This is what makes the image-adjustment panel's sliders move the relief: decode_height_texture() + // applies the layer's preview_adjust, so everything downstream of the decode - the displacement, the + // colour, the thumbnails, the bake - sees the adjusted image without being told about the panel. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + // An untouched panel, and a closed one, cost nothing and change nothing. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); + + layer.preview_adjust.brightness = 0.5f; + const DecodedHeightTexture bright = decode_height_texture(layer); + CHECK(bright.pixels != raw.pixels); + CHECK(bright.pixels == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // Back to identity gives the stored image back, so closing the panel really does revert the layer. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); +} + +TEST_CASE("changing a live adjustment is not served the previous one from cache", "[TextureDisplacement]") +{ + // The decode caches by the image_data pointer, which does not change while a slider moves - so the + // adjustment has to be part of the key or every slider move past the first would redraw the same + // pixels. Same for the smoothed copy, which is computed on top of the adjusted one. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + layer.preview_adjust.brightness = 0.5f; + const std::vector bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector dark = decode_height_texture(layer).pixels; + CHECK(bright != dark); + CHECK(dark == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // The same question again with smoothing on, which is cached separately and keyed by both. + layer.smoothing = 0.5f; + layer.preview_adjust.brightness = 0.5f; + const std::vector smooth_bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector smooth_dark = decode_height_texture(layer).pixels; + CHECK(smooth_bright != smooth_dark); +} + // --------------------------------------------------------------------------------------------- // Texture smoothing // --------------------------------------------------------------------------------------------- From 39c77325ce050cb19cdae6469ea918d88720e573 Mon Sep 17 00:00:00 2001 From: ExPikaPaka <112851715+ExPikaPaka@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:05:05 +0200 Subject: [PATCH 7/9] Carry existing colour across a bake instead of reindexing it (#16392) * 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. * 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. --------- Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com> --- src/libslic3r/TextureDisplacement.cpp | 55 +++++++ src/libslic3r/TextureDisplacement.hpp | 25 +++ .../GUI/Jobs/TextureDisplacementBakeJob.cpp | 62 ++++++- tests/libslic3r/test_texture_displacement.cpp | 153 ++++++++++++++++++ 4 files changed, 292 insertions(+), 3 deletions(-) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 0153bb169b..27d3eb8311 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1897,6 +1897,61 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) return islands; } +std::vector 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 per_state; + selector.get_facets(per_state); + indexed_triangle_set soup; + std::vector 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 out(dst_mesh.indices.size(), 0); + tbb::parallel_for(tbb::blocked_range(0, dst_mesh.indices.size()), + [&](const tbb::blocked_range &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 &islands) { if (islands.empty()) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 6a121d19a8..2d5a759d61 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -692,6 +692,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 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 diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index e769800749..2ea468a76f 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -8,6 +8,7 @@ #include "libslic3r/TextureDisplacement.hpp" #include #include +#include #include "slic3r/GUI/Jobs/Worker.hpp" #include @@ -26,6 +27,7 @@ #include "slic3r/Utils/UndoRedo.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/TriangleMesh.hpp" +#include 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 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 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 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; }); diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index f57be521ab..2e4210189d 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -37,10 +37,12 @@ #include #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" @@ -2708,6 +2710,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 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::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 &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 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 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 committed(second.indices.size(), 0); + { + const TriangleMesh mesh(second); + TriangleSelector selector(mesh); + const std::vector 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 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 From ab42ddeef2549e169dcd1ca978153bcf4f501cac Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 13 Oct 2026 06:19:07 -0500 Subject: [PATCH 8/9] ci: replace deploy-nightly action with a local composite action (#16419) WebFreak001/deploy-nightly still targets Node.js 20, so every main build logged a deprecation warning per deploy step. The action has had no release since 2024 and its Node 24 PR is unmerged. The local action in .github/actions/deploy-nightly uploads the new file under a temporary name and renames it once the upload finishes, so a failed upload keeps the existing nightly asset. It also fails on a missing file before deleting anything, retries transient API errors, times out stalled transfers, handles concurrent runs, and finds the release by its nightly-builds tag. The macOS validator DMG deploy now runs only when that DMG was built. --- .github/actions/deploy-nightly/action.yml | 29 ++++++ .github/actions/deploy-nightly/deploy.sh | 104 ++++++++++++++++++++++ .github/workflows/build_all.yml | 6 +- .github/workflows/build_orca.yml | 60 +++---------- 4 files changed, 148 insertions(+), 51 deletions(-) create mode 100644 .github/actions/deploy-nightly/action.yml create mode 100644 .github/actions/deploy-nightly/deploy.sh diff --git a/.github/actions/deploy-nightly/action.yml b/.github/actions/deploy-nightly/action.yml new file mode 100644 index 0000000000..cfcd1c7fcb --- /dev/null +++ b/.github/actions/deploy-nightly/action.yml @@ -0,0 +1,29 @@ +name: 'Deploy Nightly' +description: 'Upload a file to a release, replacing the asset of the same name' +inputs: + asset_path: + description: 'Path of the file to upload' + required: true + asset_name: + description: 'Asset name on the release' + required: true + content_type: + description: 'Content type of the uploaded file' + required: false + default: 'application/octet-stream' + tag: + description: 'Tag of the release to upload to' + required: false + default: 'nightly-builds' +runs: + using: 'composite' + steps: + - name: Replace release asset + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ASSET_PATH: ${{ inputs.asset_path }} + ASSET_NAME: ${{ inputs.asset_name }} + CONTENT_TYPE: ${{ inputs.content_type }} + RELEASE_TAG: ${{ inputs.tag }} + run: bash "$GITHUB_ACTION_PATH/deploy.sh" diff --git a/.github/actions/deploy-nightly/deploy.sh b/.github/actions/deploy-nightly/deploy.sh new file mode 100644 index 0000000000..9d5b22b9a8 --- /dev/null +++ b/.github/actions/deploy-nightly/deploy.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Replaces one asset of a GitHub release, uploading under a temporary name +# first so a failed upload leaves the old asset in place. +# +# Environment: GH_TOKEN, ASSET_PATH, ASSET_NAME, CONTENT_TYPE, RELEASE_TAG and +# the GITHUB_* variables Actions sets. + +# The $names inside the single-quoted jq programs are jq variables. +# shellcheck disable=SC2016 +set -euo pipefail + +repo_api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY" + +if [ ! -f "$ASSET_PATH" ]; then + echo "::error::$ASSET_PATH does not exist" + exit 1 +fi + +# The speed limit turns a stalled transfer into an error that --retry handles. +curl_opts=(-sS --retry 3 --connect-timeout 30 --speed-limit 1024 --speed-time 60 + -H "Authorization: Bearer $GH_TOKEN") + +# Prints the response body, or reports GitHub's error body and fails. +api() { + local out + if ! out=$(curl "${curl_opts[@]}" --fail-with-body "$@"); then + # A workflow command ends at the first newline. + echo "::error::GitHub API request failed: ${out//[$'\r\n']/ }" >&2 + return 1 + fi + printf '%s\n' "$out" +} + +http_status() { + curl "${curl_opts[@]}" -o /dev/null -w '%{http_code}' "$@" +} + +# jq prints CRLF on Windows. +jqr() { + jq -r "$@" | tr -d '\r' +} + +delete_asset() { + local code + code=$(http_status -X DELETE "$repo_api/releases/assets/$1") + case $code in + # A 404 means a concurrent run already deleted the asset. + 204 | 404) ;; + *) + echo "::error::Deleting release asset $1 failed with HTTP $code" >&2 + return 1 + ;; + esac +} + +list_assets() { + local page=1 json + while :; do + # Callers run this inside $(...), where bash ignores set -e. + json=$(api "$repo_api/releases/$release_id/assets?per_page=100&page=$page") || exit 1 + printf '%s\n' "$json" + [ "$(jqr length <<<"$json")" -eq 100 ] || break + page=$((page + 1)) + done | jq -s add +} + +release=$(api "$repo_api/releases/tags/$RELEASE_TAG") +release_id=$(jqr .id <<<"$release") +upload_url=$(jqr .upload_url <<<"$release") +upload_url=${upload_url%%\{*} + +# Unique per run attempt, so concurrent runs never upload to the same name. +tmp_name="$ASSET_NAME.partial-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + +# Deletes leftover partials older than an hour, an age no upload in progress reaches. +stale_ids=$(list_assets | jqr --arg prefix "$ASSET_NAME.partial-" \ + '.[] | select((.name | startswith($prefix)) and (.created_at | fromdateiso8601) < now - 3600) | .id') +for id in $stale_ids; do + delete_asset "$id" +done + +name_query=$(jqr -n --arg name "$tmp_name" '$name | @uri') +# Uploads once, since a retry after an upload that succeeded fails on the taken name. +new_id=$(api --retry 0 -X POST -H "Content-Type: $CONTENT_TYPE" -T "$ASSET_PATH" \ + "$upload_url?name=$name_query" | jqr .id) +trap 'delete_asset "$new_id" || true' EXIT + +rename_body=$(jq -n --arg name "$ASSET_NAME" '{name: $name}') +# Retries on HTTP 422, which a concurrent run causes by renaming its own upload +# between this delete and this rename. +for _ in 1 2 3; do + old_ids=$(list_assets | jqr --arg name "$ASSET_NAME" '.[] | select(.name == $name) | .id') + for id in $old_ids; do + delete_asset "$id" + done + code=$(http_status -X PATCH -d "$rename_body" "$repo_api/releases/assets/$new_id") + if [ "$code" = 200 ]; then + trap - EXIT + exit 0 + fi + [ "$code" = 422 ] || break +done +echo "::error::Renaming $tmp_name to $ASSET_NAME failed with HTTP $code" >&2 +exit 1 diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index 22790fb1e8..8fa7901623 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -441,14 +441,10 @@ jobs: path: '/__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak' - name: Deploy Flatpak to nightly release if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: /__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak asset_name: OrcaSlicer-Linux-flatpak_nightly_${{ matrix.variant.arch }}.flatpak - asset_content_type: application/octet-stream - max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted # The asset is /app (the exes link it at runtime) plus the build tree # slimmed to what ctest needs. - name: Package flatpak test asset diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index e49e157358..4d85542c71 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -397,25 +397,18 @@ jobs: - name: Deploy Mac release if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ${{ github.workspace }}/OrcaSlicer_Mac_universal_${{ env.ver }}.dmg asset_name: OrcaSlicer_Mac_universal_nightly.dmg - asset_content_type: application/octet-stream - max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted + # The DMG is only built when the validator app was. - name: Deploy Mac OrcaSlicer_profile_validator DMG release - if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED - uses: WebFreak001/deploy-nightly@v3.2.0 + if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED && hashFiles(format('OrcaSlicer_profile_validator_Mac_universal_{0}.dmg', env.ver)) != '' + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ${{ github.workspace }}/OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg asset_name: OrcaSlicer_profile_validator_Mac_universal_nightly.dmg - asset_content_type: application/octet-stream - max_releases: 1 # Windows - name: Set Windows build variables @@ -542,36 +535,27 @@ jobs: - name: Deploy Windows release portable if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_${{ env.ver }}${{ env.ARCH_SUFFIX }}_portable.zip asset_name: OrcaSlicer_Windows${{ env.ARCH_SUFFIX }}_nightly_portable.zip - asset_content_type: application/x-zip-compressed - max_releases: 1 + content_type: application/x-zip-compressed - name: Deploy Windows release installer if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe asset_name: OrcaSlicer_Windows_Installer${{ env.ARCH_SUFFIX }}_nightly.exe - asset_content_type: application/x-msdownload - max_releases: 1 + content_type: application/x-msdownload - name: Deploy Windows OrcaSlicer_profile_validator release if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && inputs.arch != 'arm64' && !vars.SELF_HOSTED - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ${{ github.workspace }}/build/src/Release/OrcaSlicer_profile_validator.exe asset_name: OrcaSlicer_profile_validator_Windows_nightly.exe - asset_content_type: application/x-msdownload - max_releases: 1 + content_type: application/x-msdownload - name: Build MSIX Store package Win if: runner.os == 'Windows' && !vars.SELF_HOSTED @@ -700,14 +684,10 @@ jobs: - name: Deploy Ubuntu release if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED }} - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ./build/OrcaSlicer_Linux_AppImage${{ env.ubuntu-ver-str }}${{ env.arch_suffix }}_${{ env.ver }}.AppImage asset_name: OrcaSlicer_Linux_AppImage${{ env.ubuntu-ver-str }}${{ env.arch_suffix }}_nightly.AppImage - asset_content_type: application/octet-stream - max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted - name: Deploy Ubuntu release if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }} run: | @@ -716,36 +696,24 @@ jobs: - name: Deploy Ubuntu OrcaSlicer_profile_validator release if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }} - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ./build/src/Release/OrcaSlicer_profile_validator asset_name: OrcaSlicer_profile_validator_Linux${{ env.ubuntu-ver-str }}_nightly - asset_content_type: application/octet-stream - max_releases: 1 - name: Deploy Ubuntu generate_system_cache release if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }} - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ./build/src/dev-utils/Release/generate_system_cache asset_name: generate_system_cache_Linux${{ env.ubuntu-ver-str }}_nightly - asset_content_type: application/octet-stream - max_releases: 1 - name: Deploy orca_custom_preset_tests if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }} - uses: WebFreak001/deploy-nightly@v3.2.0 + uses: ./.github/actions/deploy-nightly with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 asset_path: ${{ github.workspace }}/resources/profiles/orca_custom_preset_tests.zip asset_name: orca_custom_preset_tests.zip - asset_content_type: application/octet-stream - max_releases: 1 # The build has just touched everything it can use, so an object # untouched for a week is dead, usually orphaned by a flag change. From f546551b3f7c039ecfe70639aa72e6010fe4c4ad Mon Sep 17 00:00:00 2001 From: ExPikaPaka <112851715+ExPikaPaka@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:51:07 +0200 Subject: [PATCH 9/9] Use a model's own UV channel for texture displacement, and keep its colours through a bake (#16394) --- .../texture_displacement_map_model_uv.svg | 1 + src/libslic3r/Model.cpp | 22 ++ src/libslic3r/Model.hpp | 25 +- src/libslic3r/TextureDisplacement.cpp | 270 +++++++++++--- src/libslic3r/TextureDisplacement.hpp | 93 ++++- src/libslic3r/TexturePainting.cpp | 21 ++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 149 ++++++-- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 29 +- .../GUI/Jobs/TextureDisplacementBakeJob.cpp | 13 +- .../GUI/Jobs/TextureDisplacementBakeJob.hpp | 3 + .../GUI/Jobs/TextureDisplacementDebugJob.cpp | 10 +- .../GUI/Jobs/TextureDisplacementDebugJob.hpp | 3 + .../Jobs/TextureDisplacementPrepareJob.cpp | 6 +- .../Jobs/TextureDisplacementPrepareJob.hpp | 5 + .../Jobs/TextureDisplacementPreviewJob.cpp | 3 +- .../Jobs/TextureDisplacementPreviewJob.hpp | 3 + tests/libslic3r/test_texture_displacement.cpp | 350 ++++++++++++++++++ 17 files changed, 904 insertions(+), 102 deletions(-) create mode 100644 resources/images/texture_displacement_map_model_uv.svg diff --git a/resources/images/texture_displacement_map_model_uv.svg b/resources/images/texture_displacement_map_model_uv.svg new file mode 100644 index 0000000000..c303d4e0ac --- /dev/null +++ b/resources/images/texture_displacement_map_model_uv.svg @@ -0,0 +1 @@ + diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index d096bd2158..2f065f09de 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -354,6 +354,24 @@ Model Model::read_from_file(const std::string& ObjInfo obj_info; ObjParser::MtlData mtl_data; result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data); + // Keep the model's own UV channel on the volume, whatever the colour import then does with + // the mesh: it is what TextureProjectionMethod::ModelUV maps a texture-displacement layer + // through, so a model that arrives with a UV layout is textured on that layout instead of on + // a fresh unwrap. load_obj() fills obj_info.uvs per face of the mesh it built, in the same + // order (and only when every face named a known material, hence the size check). V is flipped + // on the way in, to the convention DecodedHeightTexture samples in - the same flip + // obj_to_textured_mesh() does for the colour importer. + if (result && !obj_info.uvs.empty() && !model.objects.empty() && !model.objects.back()->volumes.empty()) { + ModelVolume &volume = *model.objects.back()->volumes[0]; + const indexed_triangle_set &its = volume.mesh().its; + if (obj_info.uvs.size() == its.indices.size()) { + volume.texture_uv.resize(its.indices.size() * 3); + for (size_t fi = 0; fi < obj_info.uvs.size(); ++fi) + for (int k = 0; k < 3; ++k) + volume.texture_uv[fi * 3 + size_t(k)] = + Vec2f(obj_info.uvs[fi][size_t(k)].x(), 1.f - obj_info.uvs[fi][size_t(k)].y()); + } + } if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) { // Textured OBJ: hand the mesh + materials to the texture-to-color importer // instead of the flat per-face colour dialog. @@ -2134,6 +2152,10 @@ void ModelVolume::reset_extra_facets() // than left referring to a mesh that no longer matches it. for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) this->texture_displacement_facet(i).reset(); + // Same reasoning for the model's own UV channel: it is indexed by triangle, so against a mesh + // this no longer matches it would map the texture by another mesh's triangle numbering. A caller + // that can carry it across - see transfer_uv_channel() - puts it back after this. + this->reset_texture_uv(); } std::optional ModelVolume::save_painting() const diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index dcaea5ee97..d7859446b5 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -975,6 +975,26 @@ public: // they are not a property of any single layer. TextureDisplacementOptions texture_displacement_options; + // The model's own texture coordinates, as the file it was imported from carried them: three per + // triangle of mesh().its, in its triangle order (corner k of triangle i at 3i+k), V already + // flipped to the sampling convention (V = 0 is the image's top row). Empty when the source had + // none, which is every STL and every 3mf - only a textured OBJ brings one in today. + // + // Per corner rather than per vertex because that is what a UV layout is: the two sides of a seam + // are the same vertex with two different coordinates, and collapsing them would smear the texture + // across every island boundary. TextureProjectionMethod::ModelUV maps through it, which is what + // lets a model that already carries a UV channel be textured on the artist's own layout instead + // of on a fresh unwrap. + // + // Indexed by triangle, so anything that *replaces* the mesh has to bring it across (or drop it): + // see transfer_uv_channel(), and reset_texture_uv() for the cases that cannot. + std::vector texture_uv; + + bool has_texture_uv() const { return !texture_uv.empty() && texture_uv.size() == mesh().its.indices.size() * 3; } + // Forgets the channel. For a mesh change that nothing can carry it across - a split, a boolean - + // where leaving it would map the texture by another mesh's triangle numbering. + void reset_texture_uv() { texture_uv.clear(); } + // Save painting data before reset_extra_facets() discards it. // Used for replacing mesh without losing painting data. // Only for model parts (not modifiers/connectors). @@ -1240,6 +1260,7 @@ private: texture_displacement_facets_6(other.texture_displacement_facets_6), texture_displacement_facets_7(other.texture_displacement_facets_7), texture_displacement_layers(other.texture_displacement_layers), texture_displacement_options(other.texture_displacement_options), + texture_uv(other.texture_uv), cut_info(other.cut_info), text_configuration(other.text_configuration), emboss_shape(other.emboss_shape) { assert(this->id().valid()); @@ -1338,7 +1359,7 @@ private: cereal::load_by_value(ar, f); mesh_changed |= tf != f.timestamp(); } - ar(texture_displacement_layers, texture_displacement_options); + ar(texture_displacement_layers, texture_displacement_options, texture_uv); cereal::load_by_value(ar, config); cereal::load(ar, text_configuration); cereal::load(ar, emboss_shape); @@ -1362,7 +1383,7 @@ private: cereal::save_by_value(ar, fuzzy_skin_facets); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) cereal::save_by_value(ar, texture_displacement_facet(i)); - ar(texture_displacement_layers, texture_displacement_options); + ar(texture_displacement_layers, texture_displacement_options, texture_uv); cereal::save_by_value(ar, config); cereal::save(ar, text_configuration); cereal::save(ar, emboss_shape); diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 27d3eb8311..ab63a75c70 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -2054,6 +2054,110 @@ std::vector compute_lscm_corner_uvs(const indexed_triangle_set &patch, co return corner; } +// Barycentric weights of `p` inside triangle (a, b, c), as the pair that belongs with the edges +// (b - a) and (c - a): p ~= (1 - w1 - w2) * a + w1 * b + w2 * c. `p` is projected onto the triangle's +// plane, so a point a hair off the surface still reports the weights of its foot point. A degenerate +// triangle reports its centroid, the only coordinate that means anything there. +static void barycentric_weights(const Vec3f &a, const Vec3f &b, const Vec3f &c, const Vec3f &p, float &w1, float &w2) +{ + const Vec3f e0 = b - a, e1 = c - a, ep = p - a; + const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1); + const float den = d00 * d11 - d01 * d01; + if (std::abs(den) <= 1e-20f) { + w1 = w2 = 1.f / 3.f; + return; + } + w1 = (d11 * dp0 - d01 * dp1) / den; + w2 = (d00 * dp1 - d01 * dp0) / den; +} + +std::vector compute_model_corner_uvs(const indexed_triangle_set &base_mesh, + const std::vector &model_uv, + const indexed_triangle_set &patch, + const std::vector &patch_source) +{ + if (patch.indices.empty() || model_uv.size() != base_mesh.indices.size() * 3 || + patch_source.size() != patch.indices.size()) + return {}; + + std::vector corner(patch.indices.size() * 3, Vec2f::Zero()); + for (size_t j = 0; j < patch.indices.size(); ++j) { + const int S = patch_source[j]; + if (S < 0 || size_t(S) >= base_mesh.indices.size()) + continue; // no source triangle: leave (0, 0), which the callers read as "no placement" + const stl_triangle_vertex_indices &src = base_mesh.indices[size_t(S)]; + const Vec3f &a = base_mesh.vertices[size_t(src[0])]; + const Vec3f &b = base_mesh.vertices[size_t(src[1])]; + const Vec3f &c = base_mesh.vertices[size_t(src[2])]; + const Vec2f *uv = &model_uv[size_t(S) * 3]; + const stl_triangle_vertex_indices &tri = patch.indices[j]; + for (int k = 0; k < 3; ++k) { + // An unsplit patch triangle is its source triangle, so its corners land exactly on a, b + // and c and this reproduces their uvs unchanged; a sub-triangle the brush cut gets the + // interpolated coordinate, which is the whole point of going through the barycentric. + float w1 = 0.f, w2 = 0.f; + barycentric_weights(a, b, c, patch.vertices[size_t(tri[k])], w1, w2); + corner[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2]; + } + } + return corner; +} + +std::vector collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector &corner) +{ + if (patch.indices.empty() || corner.size() != patch.indices.size() * 3) + return {}; + + std::vector per_vertex(patch.vertices.size(), Vec2f::Zero()); + std::vector seen(patch.vertices.size(), 0); + for (size_t j = 0; j < patch.indices.size(); ++j) + for (int k = 0; k < 3; ++k) { + const int vi = patch.indices[j][k]; + if (vi < 0 || size_t(vi) >= per_vertex.size() || seen[size_t(vi)]) + continue; + seen[size_t(vi)] = 1; + per_vertex[size_t(vi)] = corner[j * 3 + size_t(k)]; + } + return per_vertex; +} + +std::vector transfer_uv_channel(const indexed_triangle_set &src_mesh, + const std::vector &src_uv, + const indexed_triangle_set &dst_mesh, + float max_distance) +{ + if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_uv.size() != src_mesh.indices.size() * 3) + return {}; + + const AABBTreeIndirect::Tree3f tree = + AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(src_mesh.vertices, src_mesh.indices); + const float limit2 = max_distance >= 0.f ? max_distance * max_distance : std::numeric_limits::max(); + + std::vector out(dst_mesh.indices.size() * 3, Vec2f::Zero()); + tbb::parallel_for(tbb::blocked_range(0, dst_mesh.indices.size()), + [&](const tbb::blocked_range &range) { + for (size_t j = range.begin(); j < range.end(); ++j) { + const stl_triangle_vertex_indices &tri = dst_mesh.indices[j]; + for (int k = 0; k < 3; ++k) { + const Vec3f &p = dst_mesh.vertices[size_t(tri[k])]; + size_t hit = 0; + Vec3f foot; + const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + src_mesh.vertices, src_mesh.indices, tree, p, hit, foot); + if (d2 < 0.f || d2 > limit2 || hit >= src_mesh.indices.size()) + continue; // nowhere near the source surface: (0, 0), as documented + const stl_triangle_vertex_indices &st = src_mesh.indices[hit]; + float w1 = 0.f, w2 = 0.f; + barycentric_weights(src_mesh.vertices[size_t(st[0])], src_mesh.vertices[size_t(st[1])], + src_mesh.vertices[size_t(st[2])], foot, w1, w2); + const Vec2f *uv = &src_uv[hit * 3]; + out[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2]; + } + } + }); + return out; +} + namespace { // apply_uv_transform()'s per-layer constants, worked out once. Triplanar sampling runs the transform // three times per point, and recomputing the rotation's cos/sin and the tiling reciprocal on every one @@ -2063,13 +2167,16 @@ struct UVTransform float scale, cs, sn, aspect; Vec2f offset; - UVTransform(const TextureDisplacementLayer &layer, float aspect_) + // `normalized_uv` drops the two steps that only mean something for a coordinate measured on the + // model - the millimetre tiling scale and the non-square aspect correction - and keeps the + // rotation and offset. See apply_uv_transform(); TextureProjectionMethod::ModelUV is what needs it. + UVTransform(const TextureDisplacementLayer &layer, float aspect_, bool normalized_uv = false) { - scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; + scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale); const float rad = layer.rotation_deg * float(M_PI) / 180.f; cs = std::cos(rad); sn = std::sin(rad); - aspect = aspect_; + aspect = normalized_uv ? 1.f : aspect_; offset = layer.offset; } Vec2f operator()(const Vec2f &planar) const @@ -2094,9 +2201,11 @@ inline Vec3f triplanar_weights(const Vec3f &normal) } } // namespace -Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect) +Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv) { - const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; + // A uv that is already normalized has no millimetre scale to divide by and no aspect to correct - + // see the header. UVTransform does the same, and the two must stay in step. + const float scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale); const Vec2f scaled = planar * scale; const float rad = layer.rotation_deg * float(M_PI) / 180.f; @@ -2114,7 +2223,7 @@ Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &la // Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a // shear, and doing it the other way round would make "Rotation" skew the pattern instead of // turning it. - if (aspect > 0.f && aspect != 1.f) + if (!normalized_uv && aspect > 0.f && aspect != 1.f) rotated.y() *= aspect; return rotated + layer.offset; @@ -2139,6 +2248,9 @@ float blend_displacement(float accumulated, float value, TextureBlendMode mode) value; return accumulated / divisor; } + // Replaces instead of combining, so `accumulated` is deliberately unused: whatever the layers + // below put here is gone where this one is painted. + case TextureBlendMode::Override: return value; case TextureBlendMode::Add: default: return accumulated + value; } @@ -2168,14 +2280,21 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp // apply_uv_transform()). Every projection except the projective "from view" one funnels through // here, so this one line is what makes them all aspect-correct. const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; - const UVTransform xf(layer, aspect); + // A ModelUV layer's precomputed uv is the model's own, already normalized; every other coordinate + // reaching the transform - the unwrap's, and every analytic projection's - is in millimetres. The + // `lscm_uv` test is what keeps the two apart: a ModelUV layer with no channel gets none, falls + // through to the millimetre methods below, and must be scaled like them. + const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV && + lscm_uv != nullptr; + const UVTransform xf(layer, aspect, normalized_uv); auto sample_at = [&](const Vec2f &planar) { return texture.sample(xf(planar), layer.tile_enabled, layer.tile_method); }; - // Precomputed per-patch LSCM solve wins over the layer's own method (see the header): the - // caller only passes it when the patch actually parameterized successfully, so a patch that - // failed to unwrap falls through to the analytic methods below as its documented fallback. + // A precomputed per-patch uv - the LSCM solve, or the model's own channel - wins over the layer's + // own method (see the header): the caller only passes it when the patch actually has one, so a + // patch that failed to unwrap, or a volume with no UV channel, falls through to the analytic + // methods below as its documented fallback. if (lscm_uv != nullptr) return sample_at(*lscm_uv); @@ -2236,8 +2355,12 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla // the two differ in what "nothing here" means. Height returns 0, which is a perfectly good height // (no displacement); colour has no such neutral value - black is a colour - so every path that // returns 0 there has to report false here instead, and the caller leaves the triangle uncoloured. - const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; - const UVTransform xf(layer, aspect); + const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; + // Same reasoning as sample_layer_height(), and it has to match it exactly or a layer's colour and + // its relief would be read from different places in the image. + const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV && + lscm_uv != nullptr; + const UVTransform xf(layer, aspect, normalized_uv); auto sample_at = [&](const Vec2f &planar) { return texture.sample_color(xf(planar), layer.tile_enabled, layer.tile_method); }; @@ -2251,6 +2374,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla }; if (lscm_uv != nullptr) { + // The coverage test goes through the same transform, so an untiled ModelUV layer is "there" + // exactly where the model's own uv lands inside the image. if (!covered(*lscm_uv)) return false; out = sample_at(*lscm_uv); @@ -2596,9 +2721,10 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set const TextureColorRequest *color, bool flip_normals, BakeStageRecorder *debug, - TextureBakeStats *stats) + TextureBakeStats *stats, + const std::vector *model_uv) { - HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data); + HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data, model_uv); if (!combined) return mesh; // nothing decodable to displace with @@ -2768,7 +2894,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set // part of it (half a tile in the neighbour's colour, a tile edge that wanders). const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize); const ColorFieldSampler color_sampler = - want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure) : ColorFieldSampler{}; + want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure, model_uv) : + ColorFieldSampler{}; // // The *palette* index, not the printed filament. The decimation treats any edge whose two faces // differ as a crease (TextureBakeDecimate.cpp), so it must only ever see where the **perceived** @@ -2900,7 +3027,8 @@ static indexed_triangle_set build_texture_displacement_in_place( const TextureColorRequest *color, bool flip_normals, BakeStageRecorder *debug, - TextureBakeStats *stats) + TextureBakeStats *stats, + const std::vector *model_uv) { // The classic path moves the vertices the mesh already has, so there is no budget to report on. (void) stats; @@ -2937,7 +3065,7 @@ static indexed_triangle_set build_texture_displacement_in_place( if (options.pipeline_v2) return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, color, flip_normals, - debug, stats); + debug, stats, model_uv); // Layers are combined in slot order, like stacked layers in an image editor: each one folds its // own displacement into the running total via its blend mode (see TextureBlendMode). @@ -3055,13 +3183,19 @@ static indexed_triangle_set build_texture_displacement_in_place( selector_dirty = true; const bool color_this_layer = want_color && layer->color_enabled; + // The model's own channel is per base-mesh *triangle*, so reading it needs to know which base + // triangle each (possibly brush-split) patch triangle came from - the colour pass's map, asked + // for here as well. + const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV && + model_uv != nullptr && model_uv->size() == mesh.indices.size() * 3; // A flat-colour image is matched against the filaments alone (see TextureColorRequest). const ColorQuantizeFn &layer_quantize = (color_this_layer && color->quantize_pure && analyze_texture_detail(*layer).flat_colors) ? color->quantize_pure : color->quantize; - std::vector patch_source; // sub-triangle -> base mesh triangle, only built when colouring + std::vector patch_source; // sub-triangle -> base mesh triangle; built when colouring or mapping by ModelUV const indexed_triangle_set patch = - selector.get_facets_strict(EnforcerBlockerType::ENFORCER, color_this_layer ? &patch_source : nullptr); + selector.get_facets_strict(EnforcerBlockerType::ENFORCER, + (color_this_layer || model_uv_layer) ? &patch_source : nullptr); if (patch.indices.empty()) continue; // get_facets_strict() returns the same vertex array whichever state is asked for (only the @@ -3092,18 +3226,25 @@ static indexed_triangle_set build_texture_displacement_in_place( // A real unwrap of the whole patch, computed once here rather than per vertex - it is a // per-chart solve over the whole patch, not a per-point formula. Cached, so repeating this // for every slider tweak costs a hash rather than a re-solve (see compute_patch_unwrap()). - const std::vector lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? + // ModelUV takes its corners from the volume's own channel instead of solving anything, and + // collapses them to one per vertex the same way the unwrap does. + const std::vector model_corner_uvs = model_uv_layer ? + compute_model_corner_uvs(mesh, *model_uv, patch, patch_source) : + std::vector{}; + const std::vector patch_uvs = model_uv_layer ? collapse_corner_uvs(patch, model_corner_uvs) : + (layer->projection_method == TextureProjectionMethod::LSCM) ? compute_lscm_uvs(patch, *layer) : std::vector{}; // The colour pass below samples per *triangle*, so it takes the per-corner unwrap instead: the // per-vertex collapse above would hand a triangle at a seam the island layout did not join its // neighbour's placement, painting one triangle per face from the wrong part of the texture. - // (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.) - const std::vector lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? + // (The displacement itself stays on patch_uvs - a vertex has one position, so one height.) + const std::vector patch_corner_uvs = model_uv_layer ? model_corner_uvs : + (layer->projection_method == TextureProjectionMethod::LSCM) ? compute_lscm_corner_uvs(patch, *layer) : std::vector{}; - const bool corner_uv_ok = !lscm_corner_uvs.empty() && - lscm_corner_uvs.size() == patch.indices.size() * 3; + const bool corner_uv_ok = !patch_corner_uvs.empty() && + patch_corner_uvs.size() == patch.indices.size() * 3; // Colour, if this layer carries any. Area-weighted over each base triangle's *painted* part, // so a triangle the brush only clipped a corner off takes the colour of that corner rather @@ -3133,7 +3274,7 @@ static indexed_triangle_set build_texture_displacement_in_place( // the per-vertex height sampling uses, evaluated at the centroid instead. Vec3f n = Vec3f::Zero(); Vec2f uv = Vec2f::Zero(); - bool have_uv = !lscm_uvs.empty(); + bool have_uv = !patch_uvs.empty(); for (int k = 0; k < 3; ++k) { const int vi = t[k]; if (vi < int(vertex_normals.size())) @@ -3141,9 +3282,9 @@ static indexed_triangle_set build_texture_displacement_in_place( if (!have_uv) continue; if (corner_uv_ok) - uv += lscm_corner_uvs[j * 3 + size_t(k)]; - else if (size_t(vi) < lscm_uvs.size()) - uv += lscm_uvs[size_t(vi)]; + uv += patch_corner_uvs[j * 3 + size_t(k)]; + else if (size_t(vi) < patch_uvs.size()) + uv += patch_uvs[size_t(vi)]; else have_uv = false; } @@ -3217,7 +3358,7 @@ static indexed_triangle_set build_texture_displacement_in_place( [&](const tbb::blocked_range &range) { for (size_t k = range.begin(); k < range.end(); ++k) { const size_t vi = size_t(layer_vertices[k]); - const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[vi]; + const Vec2f *lscm_uv = patch_uvs.empty() ? nullptr : &patch_uvs[vi]; const float h = sample_layer_height(height, *layer, mesh.vertices[vi], vertex_normals[vi], patch_centroid, patch_axis, lscm_uv); // midlevel is the height that means "stay put", so anything below it displaces @@ -3350,7 +3491,8 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set const TextureColorRequest *color, const Transform3d &volume_to_world, BakeStageRecorder *debug, - TextureBakeStats *stats) + TextureBakeStats *stats, + const std::vector *model_uv) { // An untransformed volume on an untransformed instance is by far the common case, and the round // trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take @@ -3360,7 +3502,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set const Transform3d frame = texture_displacement_bake_frame(volume_to_world); if (frame.matrix().isApprox(Transform3d::Identity().matrix())) return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color, - false, debug, stats); + false, debug, stats, model_uv); const Transform3d to_local = frame.inverse(); // A mirroring placement leaves the positions correct but every winding-derived normal pointing @@ -3374,8 +3516,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set v = (frame * v.cast()).cast(); const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0; + // The UV channel rides along untouched: a uv is a coordinate in the texture, not on the model, so + // the frame the mesh is measured in does not reach it. indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options, - progress, color, mirrored, debug, stats); + progress, color, mirrored, debug, stats, model_uv); // Everything the bake recorded is in world millimetres, like `out` itself. The debug view draws in // the volume's local frame, so the stages are brought back the same way the result is. if (debug != nullptr) @@ -3447,7 +3591,8 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume) return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data, volume.texture_displacement_options, {}, nullptr, - texture_displacement_volume_to_world(volume)); + texture_displacement_volume_to_world(volume), nullptr, nullptr, + volume.has_texture_uv() ? &volume.texture_uv : nullptr); } void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, @@ -3511,16 +3656,18 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector namespace { // One decoded texture + placement per sampleable layer, in blend (slot) order. Held by shared_ptr so // the returned closure owns it for as long as the subdivider keeps calling back. -// An unwrap turned into something a *point* sampler can use. LSCM has no formula from position to -// uv - it is a per-triangle map - so a point is placed on the painted patch (the nearest patch -// triangle, and its barycentric coordinates there) and the uv is interpolated from that triangle's own -// per-corner uvs. Exact for a point on the base surface, which is where both samplers are queried: the -// displacement samples refined positions before moving them, and the colour pass samples the foot -// point on the painted pieces. +// A per-triangle uv map turned into something a *point* sampler can use. Neither of the two maps that +// are not a formula from position to uv - the LSCM unwrap and the model's own UV channel - can be +// evaluated at an arbitrary point directly, so a point is placed on the painted patch (the nearest +// patch triangle, and its barycentric coordinates there) and the uv is interpolated from that +// triangle's own per-corner uvs. Exact for a point on the base surface, which is where both samplers +// are queried: the displacement samples refined positions before moving them, and the colour pass +// samples the foot point on the painted pieces. struct LscmLookup { - indexed_triangle_set patch; // the layer's painted patch, as the unwrap was solved on + indexed_triangle_set patch; // the layer's painted patch, as the uvs were computed on AABBTreeIndirect::Tree3f tree; - std::vector corner; // compute_lscm_corner_uvs(patch, layer) + // compute_lscm_corner_uvs(patch, layer), or compute_model_corner_uvs() for a ModelUV layer + std::vector corner; // False when `pos` is not on this layer's patch (farther than `tol`): there is no uv there, so the // layer contributes nothing - the same as a non-tiled texture outside its placement. @@ -3533,15 +3680,9 @@ struct LscmLookup { if (d2 < 0.f || d2 > tol * tol || hit >= patch.indices.size()) return false; const stl_triangle_vertex_indices &t = patch.indices[hit]; - const Vec3f &a = patch.vertices[size_t(t[0])], &b = patch.vertices[size_t(t[1])], &c = patch.vertices[size_t(t[2])]; - const Vec3f e0 = b - a, e1 = c - a, ep = foot - a; - const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1); - const float den = d00 * d11 - d01 * d01; - float w1 = 1.f / 3.f, w2 = 1.f / 3.f; // a degenerate triangle takes its centroid's uv - if (std::abs(den) > 1e-20f) { - w1 = (d11 * dp0 - d01 * dp1) / den; - w2 = (d00 * dp1 - d01 * dp0) / den; - } + float w1 = 0.f, w2 = 0.f; // a degenerate triangle takes its centroid's uv + barycentric_weights(patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])], patch.vertices[size_t(t[2])], + foot, w1, w2); const Vec2f *c3 = &corner[hit * 3]; uv = (1.f - w1 - w2) * c3[0] + w1 * c3[1] + w2 * c3[2]; return true; @@ -3600,7 +3741,7 @@ struct PreparedLayer { // where a layer is placed. `need_color` additionally drops layers that cannot contribute colour. std::shared_ptr> prepare_sampleable_layers( const indexed_triangle_set &base_mesh, const std::vector &layers, - const TextureDisplacementFacetsData &facets_data, bool need_color) + const TextureDisplacementFacetsData &facets_data, bool need_color, const std::vector *model_uv) { auto prepared = std::make_shared>(); @@ -3631,9 +3772,14 @@ std::shared_ptr> prepare_sampleable_layers( if (tex.empty() || (need_color && !tex.has_color())) continue; + const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV && + model_uv != nullptr && model_uv->size() == base_mesh.indices.size() * 3; + TriangleSelector selector(selector_mesh); selector.deserialize(data, false); - const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER); + std::vector patch_source; // only the model's own channel needs it; see the classic path + const indexed_triangle_set patch = + selector.get_facets_strict(EnforcerBlockerType::ENFORCER, model_uv_layer ? &patch_source : nullptr); if (patch.indices.empty()) continue; @@ -3643,12 +3789,16 @@ std::shared_ptr> prepare_sampleable_layers( texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal); std::shared_ptr lscm; - if (layer->projection_method == TextureProjectionMethod::LSCM) { + if (model_uv_layer || layer->projection_method == TextureProjectionMethod::LSCM) { // Solved on the very patch the classic path and the GUI solve it on (same geometry, seam // angle and edges), so it hits the unwrap cache and lands exactly where the UV editor - // shows it, hand-placed islands and UV edits included. + // shows it, hand-placed islands and UV edits included. A ModelUV layer needs no solve at + // all - its corners are read straight out of the volume's channel - but it is looked up + // through the same per-triangle machinery, for the same reason: there is no formula from a + // point to its uv. auto l = std::make_shared(); - l->corner = compute_lscm_corner_uvs(patch, *layer); + l->corner = model_uv_layer ? compute_model_corner_uvs(base_mesh, *model_uv, patch, patch_source) : + compute_lscm_corner_uvs(patch, *layer); if (l->corner.size() == patch.indices.size() * 3) { l->patch = patch; l->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(l->patch.vertices, l->patch.indices); @@ -3674,11 +3824,12 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set const std::vector &layers, const TextureDisplacementFacetsData &facets_data, ColorQuantizeFn quantize, - ColorQuantizeFn quantize_pure) + ColorQuantizeFn quantize_pure, + const std::vector *model_uv) { if (!quantize) return nullptr; - auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true); + auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true, model_uv); if (prepared->empty()) return nullptr; // Per layer: a flat-colour image is matched against the filaments alone, when that quantizer @@ -3709,9 +3860,10 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, - const TextureDisplacementFacetsData &facets_data) + const TextureDisplacementFacetsData &facets_data, + const std::vector *model_uv) { - auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false); + auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false, model_uv); if (prepared->empty()) return nullptr; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 2d5a759d61..6bd5d1ea33 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -86,6 +86,17 @@ enum class TextureProjectionMethod : int // that is inherent to view projection and is the user's call, not a bug. The projector's two // in-plane axes live in TextureDisplacementLayer::view_project_right/up. ViewProjected = 4, + // The model's own texture coordinates, as the file it was imported from carried them (see + // ModelVolume::texture_uv). Nothing is unwrapped or projected: the artist's layout *is* the + // placement, so a texture authored against that layout - the model's own albedo map, say - lands + // exactly where it was painted. Automatically picked for a new layer on a volume that has a UV + // channel, since re-unwrapping a model that already carries one can only be worse than using it. + // + // The uv is already normalized, so unlike every method above it is not measured in millimetres: + // "Tile size" and the non-square aspect correction do not apply to it (the layer's rotation and + // offset still do). Falls back to Triplanar when the volume has no channel, exactly as LSCM falls + // back when a patch cannot be flattened. + ModelUV = 5, }; // Dihedral angle (degrees) above which an edge between two painted triangles becomes a chart seam @@ -121,6 +132,11 @@ enum class TextureBlendMode : int Subtract = 1, // acc - value (carve this layer's relief out of the layers below) Multiply = 2, // acc * (value / 1mm) (mask/modulate the layers below by this layer) Divide = 3, // acc / (value / 1mm) (inverse mask; guarded against a zero/near-zero divisor) + // acc = value: this layer replaces what is under it instead of combining with it, so where it is + // painted only its own relief remains. The image editor's "Normal" mode, under the name the panel + // uses. Note the stack's *colour* already behaves this way - a higher slot's colour overwrites a + // lower one's where they overlap - so this is what makes the displacement agree with it. + Override = 4, }; // Combines one layer's signed displacement `value` (mm) into `accumulated` (mm) per `mode`. @@ -655,7 +671,13 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal); // squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is // `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the // square case and leaves the coordinate exactly as it always was. -Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f); +// +// `normalized_uv` says the coordinate is already a uv rather than a millimetre measurement on the +// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tiling scale (mm +// per tile is meaningless for it) nor the aspect correction (the artist's layout already accounts for +// the image's proportions) applies, and only the rotation and offset are left. +Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f, + bool normalized_uv = false); // Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a // local-space point, writing the resulting texture uv. Returns false - and leaves `uv` untouched - @@ -830,6 +852,55 @@ std::vector compute_lscm_uvs(const indexed_triangle_set &patch, const Tex // Returns an empty vector if the patch has no triangles or the unwrap carries no source_face map. std::vector compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer); +// Three UVs per patch triangle, read out of the model's *own* UV channel - the +// TextureProjectionMethod::ModelUV counterpart of compute_lscm_corner_uvs(), and laid out the same way +// (corner k of triangle i at index 3i+k). +// +// `model_uv` is the volume's channel: three uvs per triangle of `base_mesh`, in its triangle order +// (see ModelVolume::texture_uv). `patch_source` maps each patch triangle back to the base-mesh +// triangle it was cut from - TriangleSelector::get_facets_strict()'s out_source. A patch triangle is +// always a piece of exactly one base triangle, so each of its corners is interpolated barycentrically +// inside that triangle's three uvs. That is what keeps a sub-triangle the brush split (whose corners +// are not base-mesh vertices at all) on the texture, and what keeps the two sides of a UV seam +// reading from their own island instead of from one averaged coordinate. +// +// Returns an empty vector when the channel does not match `base_mesh`, when `patch_source` does not +// match `patch`, or when either is empty - the callers treat that as "no placement" and fall back, +// exactly as they do for an unwrap that failed. +std::vector compute_model_corner_uvs(const indexed_triangle_set &base_mesh, + const std::vector &model_uv, + const indexed_triangle_set &patch, + const std::vector &patch_source); + +// One UV per patch vertex, collapsed from a per-corner array: the first corner to reach a vertex wins. +// This is the per-corner/per-vertex split compute_lscm_uvs() documents, applied to any corner array - +// displacement is per-vertex, so a vertex on a seam has to settle on one of its copies. +// Returns an empty vector if `corner` is not three per patch triangle. +std::vector collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector &corner); + +// Resamples a per-corner UV channel from `src_mesh` onto `dst_mesh`, for when something replaces a +// volume's mesh and the model's own UV channel has to come along: the colour import's painted mesh, +// the gizmo's remesh/subdivide, the default bake pipeline's rebuild. Each destination corner takes the +// uv of its closest point on the source surface, interpolated barycentrically inside the source +// triangle it lands on - the same transfer LscmLookup does per point, run over every corner. +// +// Exact wherever the two meshes describe the same surface, which is the case for every caller here +// (they subdivide, repair or displace it, they do not reshape it). A corner that lands nowhere near +// the source - farther than `max_distance` (negative: no limit) - takes (0, 0). +// +// One thing a positional transfer cannot reproduce: a *seam* vertex, where one position carries a +// different uv in each island that meets there. Position alone does not say which island a corner +// belongs to, so corners on a seam all take whichever island the nearest-triangle lookup lands on, +// and the texture can be off by up to one triangle in a band along the island borders. Doing better +// needs a triangle-to-triangle correspondence, which only some of the stages that replace a mesh +// have (a remesh has none at all). +// +// Returns an empty vector when `src_uv` is not three per source triangle, or when either mesh is empty. +std::vector transfer_uv_channel(const indexed_triangle_set &src_mesh, + const std::vector &src_uv, + const indexed_triangle_set &dst_mesh, + float max_distance = -1.f); + // The TextureDisplacementLayer::lscm_uv_overrides key for one unwrapped vertex (an index into PatchUnwrap::uvs). inline int lscm_uv_override_key(int unwrapped_vertex) { return -(unwrapped_vertex + 1); } @@ -888,6 +959,10 @@ using TextureDisplacementFacetsData = std::array palette index. Supplied by the GUI, which owns both the perceptual matching and the list @@ -962,7 +1037,9 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set // stage, already brought back into `base_mesh`'s frame. BakeStageRecorder *debug = nullptr, // When given, receives what the bake spent. - TextureBakeStats *stats = nullptr); + TextureBakeStats *stats = nullptr, + // The model's own UV channel; see above. + const std::vector *model_uv = nullptr); // `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own. // The mesh is shared by every instance, so a multi-instance object can only be baked for one of @@ -1037,11 +1114,15 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set const std::vector &layers, const TextureDisplacementFacetsData &facets_data, ColorQuantizeFn quantize, - ColorQuantizeFn quantize_pure = nullptr); + ColorQuantizeFn quantize_pure = nullptr, + // The model's own UV channel, for ModelUV layers; see + // build_texture_displacement(). + const std::vector *model_uv = nullptr); HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, - const TextureDisplacementFacetsData &facets_data); + const TextureDisplacementFacetsData &facets_data, + const std::vector *model_uv = nullptr); // Uniformly subdivides `mesh` (every triangle recursively split into 4 via edge midpoints, using a // shared cache so a midpoint is computed once and reused by both triangles on either side of that @@ -1246,6 +1327,10 @@ struct TextureDisplacementPrepareResult { indexed_triangle_set mesh; TextureDisplacementFacetsData masks; + // The model's own UV channel, resampled onto `mesh` (see transfer_uv_channel()). Empty when the + // volume had none. Like the masks it is handed back rather than left to the caller to recompute, + // because only the run itself knows which stages replaced the mesh. + std::vector uv; // The remesh landed but no layer's paint survived being carried onto it. Nothing is committed: // everything downstream is driven by that paint, so baking on would bake a flat mesh. bool paint_lost = false; diff --git a/src/libslic3r/TexturePainting.cpp b/src/libslic3r/TexturePainting.cpp index 1bcddfd3d2..2f217b1aa2 100644 --- a/src/libslic3r/TexturePainting.cpp +++ b/src/libslic3r/TexturePainting.cpp @@ -25,6 +25,7 @@ #include "TextureToColor/ColorUtils.hpp" #include "Model.hpp" +#include "TextureDisplacement.hpp" #include "TextureToColor/TriMesh.hpp" #include "TriangleMesh.hpp" #include "TriangleSelector.hpp" @@ -554,9 +555,29 @@ bool apply_painted_mesh_to_volume( << ", unit_scale=" << unit_scale << ")"; } + // The model's own UV channel is indexed by triangle, and this replaces the triangles, so it has + // to be resampled onto them. Kept rather than dropped because it is the layout the model was + // authored against: a texture-displacement layer set to ModelUV maps through it, and the colour + // import is exactly the path a model that carries one arrives by. + const indexed_triangle_set old_its = volume.has_texture_uv() ? volume.mesh().its : indexed_triangle_set{}; + const std::vector old_uv = volume.texture_uv; + volume.set_mesh(std::move(new_mesh)); volume.calculate_convex_hull(); + // Resampled here and not further down, while the two meshes still share a frame: the painted mesh + // was just shifted by the same source.mesh_offset the old one was centred by, so the only thing + // between them is whatever drift the repair introduced. The re-centering below translates the mesh + // again, and a translation is exactly what a closest-point transfer cannot see through - it would + // read every uv off a point that far away. A uv is a coordinate in the texture, so it does not care + // about either shift; it just has to be read before the second one happens. + if (!old_its.indices.empty()) + volume.texture_uv = transfer_uv_channel(old_its, old_uv, volume.mesh().its); + else + // No channel to carry, so make sure none is left behind either: a leftover of the right length + // would read as a valid channel against a mesh it was never measured on. + volume.reset_texture_uv(); + // Re-center the replaced mesh so its bbox center sits at the origin, // matching what center_geometry_after_creation did for the original mesh. // CGAL repair / subdivision may shift the bbox center (drift); without diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 14eb31b19c..bd3934c918 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1244,9 +1244,33 @@ indexed_triangle_set GLGizmoTextureDisplacement::patch_in_world(const indexed_tr return world; } -std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch, - const TextureDisplacementLayer &layer) const +std::vector GLGizmoTextureDisplacement::model_uv_corners(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const { + const ModelVolume *mv = texture_volume(); + if (layer.projection_method != TextureProjectionMethod::ModelUV || patch_source == nullptr || mv == nullptr || + !mv->has_texture_uv()) + return {}; + // The channel is indexed against the volume's *local* mesh, and a uv is a coordinate in the + // texture rather than on the model, so unlike every projected method this never goes through + // patch_in_world() - there is no millimetre measurement here for a world scale to change. + std::vector uv = compute_model_corner_uvs(mv->mesh().its, mv->texture_uv, local_patch, *patch_source); + for (Vec2f &p : uv) + // Already a uv: no tiling scale, no aspect correction - see apply_uv_transform(). + p = apply_uv_transform(p, layer, 1.f, /* normalized_uv */ true); + return uv; +} + +std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const +{ + if (layer.projection_method == TextureProjectionMethod::ModelUV) + // Collapsed to one per vertex the same way the unwrap is - a vertex has one position, so the + // displacement can only read one height for it. + return collapse_corner_uvs(local_patch, model_uv_corners(local_patch, layer, patch_source)); + // The bake maps the texture in world millimetres, so "Tile size (mm)" means the same thing on a // scaled instance as it does on an untouched one (see build_texture_displacement()). Everything // that has to agree with the bake - the fast preview's uvs, the checker/distortion overlays - @@ -1307,8 +1331,11 @@ int GLGizmoTextureDisplacement::layer_projection_frame(const indexed_triangle_se } std::vector GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch, - const TextureDisplacementLayer &layer) const + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const { + if (layer.projection_method == TextureProjectionMethod::ModelUV) + return model_uv_corners(local_patch, layer, patch_source); if (layer.projection_method == TextureProjectionMethod::LSCM) { const indexed_triangle_set patch = patch_in_world(local_patch); const float aspect = layer_texture_aspect(layer); @@ -1318,7 +1345,7 @@ std::vector GLGizmoTextureDisplacement::compute_layer_corner_uvs(const in return uv; } // Single-valued per point: fan the per-vertex result out over the corners. - const std::vector per_vertex = compute_layer_vertex_uvs(local_patch, layer); + const std::vector per_vertex = compute_layer_vertex_uvs(local_patch, layer, patch_source); if (per_vertex.size() != local_patch.vertices.size()) return {}; std::vector corner(local_patch.indices.size() * 3); @@ -1339,7 +1366,11 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh() // Uses the *live* selector (not the flushed model facet data), so this reflects an in-progress // stroke immediately rather than only once it ends - the point of this preview mode is to be // the fast, no-CPU-meshing one. - const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER); + // The source map comes along because a ModelUV layer's uvs are read per base-mesh triangle; every + // other mapping ignores it. + std::vector patch_source; + const indexed_triangle_set patch = + m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source); if (patch.indices.empty()) return; @@ -1363,7 +1394,8 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh() // Per *corner*, not per vertex: the mesh below is flat (unshared) anyway, so each triangle can // carry its own chart's UVs - see compute_layer_corner_uvs(). const TextureDisplacementLayer *active = active_layer(); - std::vector corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector{}; + std::vector corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active, &patch_source) : + std::vector{}; m_shaded_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3; if (!m_shaded_preview_uses_vertex_uv) corner_uv.clear(); @@ -2030,7 +2062,9 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() const ModelVolume *mv = texture_volume(); if (mv == nullptr || m_triangle_selectors.empty()) return; - const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER); + std::vector patch_source; // only ModelUV reads it; see compute_layer_vertex_uvs() + const indexed_triangle_set patch = + m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source); if (patch.indices.empty()) return; // Everything below works in the *patch's* vertex space, not the mesh's. Those agree only until a @@ -2044,7 +2078,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() // The checker samples wherever the projection puts it; the projections the shader can't // reconstruct (LSCM, ViewProjected) get a precomputed per-vertex uv, the rest project in-shader. - std::vector uv = compute_layer_vertex_uvs(patch, *layer); + std::vector uv = compute_layer_vertex_uvs(patch, *layer, &patch_source); const bool have_uvs = uv.size() == patch.vertices.size(); m_uvcheck_uses_vertex_uv = have_uvs; m_uvcheck_projection_mode = have_uvs ? 0 : @@ -2053,7 +2087,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() // different uv in each island it borders, so the shared-vertex form drew one triangle per face from // a neighbouring island's placement. Only the *drawing* needs this; the distortion metric below is // a per-vertex average by construction and keeps using `uv`. - const std::vector corner_uv = compute_layer_corner_uvs(patch, *layer); + const std::vector corner_uv = compute_layer_corner_uvs(patch, *layer, &patch_source); const bool have_corner_uvs = have_uvs && corner_uv.size() == patch.indices.size() * 3; // Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible. @@ -2329,6 +2363,8 @@ void GLGizmoTextureDisplacement::queue_preview_job() if (input.options.pipeline_v2) input.options.v2_max_triangles_k = 0; input.volume_to_world = texture_displacement_volume_to_world(*mv); + if (mv->has_texture_uv()) + input.model_uv = mv->texture_uv; for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); // Captured here rather than read in the handler: the palette is main-thread state, and the preview @@ -3765,7 +3801,8 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() // Paint / Erase, brush cursor shape, mapping, tiling, placement and the dock toggle. "texture_displacement_add.svg", "texture_displacement_add_negative.svg", "circle_paint.svg", "menu_obj_sphere.svg", "menu_obj_cube.svg", "menu_obj_cylinder.svg", "texture_displacement_map_unwrap.svg", - "texture_displacement_map_view.svg", "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg", + "texture_displacement_map_view.svg", "texture_displacement_map_model_uv.svg", + "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg", "texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg", "texture_displacement_move_down.svg", "texture_displacement_drag.svg", "texture_displacement_select_all.svg", "texture_displacement_erase_all.svg", @@ -3814,6 +3851,11 @@ void GLGizmoTextureDisplacement::add_texture_layer() TextureDisplacementLayer layer; layer.slot = free_slot; + // A model that came with a UV channel is mapped by it. Re-unwrapping such a model throws away the + // layout it was authored against and can only be worse than using it, so the default follows the + // model rather than the other way round; the Mapping row still offers every other method. + if (mv->has_texture_uv()) + layer.projection_method = TextureProjectionMethod::ModelUV; // Start the layer off on the first library texture rather than on nothing at all: a textureless // layer looks broken (painting on it appears to do nothing, because there is no height map to // displace by). The user swaps it for another from the layer's own picker. @@ -5291,7 +5333,8 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, - const std::vector &layers, const TextureDisplacementPrepareParams ¶ms, + const std::vector &layers, const std::vector &model_uv, + const TextureDisplacementPrepareParams ¶ms, const TextureColorSettings &color_settings, const DisplacementProgressFn &progress, BakeStageRecorder *debug) { @@ -5318,6 +5361,13 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( bool had_paint = false; for (const TriangleSelector::TriangleSplittingData &m : masks) had_paint = had_paint || TriangleSelector::has_facets(m, EnforcerBlockerType::ENFORCER); + // The model's own UV channel for whatever `mesh` currently is. Every stage below keeps the input's + // surface - it evens out, refines or cuts the triangles lying on it - so each corner reads its uv + // straight off `base`'s channel at its own position instead of being carried stage by stage, which + // would compound each stage's approximation. Untouched while nothing has replaced the mesh yet. + const auto uv_now = [&]() { + return (model_uv.empty() || !changed) ? model_uv : transfer_uv_channel(base, model_uv, mesh.its); + }; if (!report(1)) return out; @@ -5364,9 +5414,11 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( // Feature-adaptive: sample the combined displacement so refinement follows texture // curvature. A null sampler (only LSCM layers, or nothing decodable) falls back to the // length baseline alone. + const std::vector uv_stage = uv_now(); + const std::vector *uv_ptr = uv_stage.empty() ? nullptr : &uv_stage; HeightFieldSampler sampler; if (params.subdiv_feature) - sampler = make_combined_displacement_sampler(mesh.its, layers, current); + sampler = make_combined_displacement_sampler(mesh.its, layers, current, uv_ptr); // Colour boundaries need triangles of their own - the chord test cannot see them, since // the height field is perfectly smooth across a change of filament. ColorFieldSampler color; @@ -5374,7 +5426,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( // boundaries are refined where the bake will actually change filament. if (params.subdiv_color_edge_mm > 0.f && !color_settings.empty()) color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(color_settings.palette), - make_palette_quantizer(color_settings.palette_pure)); + make_palette_quantizer(color_settings.palette_pure), uv_ptr); // The refinement follows perceived colour: a mix is one colour here, however the slicer // interleaves its filaments layer by layer. // "Min edge" is a feature-mode control (it is the floor the curvature test refines down @@ -5433,8 +5485,10 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( std::vector cut_region(mesh.its.indices.size(), 0); for (size_t i = 0; i < cut_region.size() && i < source.size(); ++i) cut_region[i] = (region[size_t(source[i])] & REFINE_PAINTED) ? 1 : 0; + const std::vector uv_refined = uv_now(); const HeightFieldSampler refined_sampler = - make_combined_displacement_sampler(mesh.its, layers, current); + make_combined_displacement_sampler(mesh.its, layers, current, + uv_refined.empty() ? nullptr : &uv_refined); std::vector cut_source; size_t cuts = 0; indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler, @@ -5458,6 +5512,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( if (!report(100) || !changed) return out; // an empty result: nothing to commit, which is not a failure + out.uv = uv_now(); out.mesh = std::move(mesh.its); out.masks = std::move(current); return out; @@ -6109,6 +6164,8 @@ void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareP input.layers = mv->texture_displacement_layers; input.params = params; input.snapshot_name = snapshot_name; + if (mv->has_texture_uv()) + input.model_uv = mv->texture_uv; if (params.subdiv_color_edge_mm > 0.f) input.color = color_settings_for(*mv); @@ -6981,10 +7038,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad, _L("Height of the relief: how far white in the texture lifts the surface, in " "millimetres. Black does not move it at all, unless Midlevel says otherwise.")); + // Mapped by the model's own UV layout there is no millimetre scale to set: the layout is the + // placement (see TextureProjectionMethod::ModelUV), so the slider would move nothing. The row + // states why rather than hiding: float_row()'s tooltip is shown for a disabled slider too. + const bool tile_size_na = layer.projection_method == TextureProjectionMethod::ModelUV; + m_imgui->disabled_begin(tile_size_na); m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad, - _L("How wide one copy of the texture is on the model. Smaller repeats the pattern " - "more often and makes its detail finer; with Tile off, this is the size of the " - "single copy.")); + tile_size_na ? + _L("Mapped by the model's own UV layout, which is already the placement - one " + "copy of the texture covers it exactly, so there is no tile size to set.") : + _L("How wide one copy of the texture is on the model. Smaller repeats the pattern " + "more often and makes its detail finer; with Tile off, this is the size of the " + "single copy.")); + m_imgui->disabled_end(); m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad, _L("Turns the texture on the surface, in degrees - for lining a pattern up with an " "edge of the model.")); @@ -7090,7 +7156,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } } - // Mapping, as five icons in TextureProjectionMethod's own order. + // Mapping, as one icon per TextureProjectionMethod, in the enum's own order. { const float x0 = ImGui::GetCursorPosX(); ImGui::AlignTextToFramePadding(); @@ -7118,13 +7184,26 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float { "texture_displacement_map_view.svg", _L("From view"), _L("From view - projects straight onto the painted area from where you are looking, like a slide " "projector.") }, + { "texture_displacement_map_model_uv.svg", _L("Model UV"), + _L("Model UV - uses the texture coordinates the model itself came with, so a texture made for " + "that model lands exactly where it was painted. Nothing is unwrapped, and Tile size does " + "not apply: the model's own layout is the placement.") }, }; + // Only a volume that actually carries a UV channel can be mapped by it. Shown + // greyed out rather than hidden, so the reason is visible instead of the control + // simply being missing. + const wxString model_uv_na = + (mv != nullptr && mv->has_texture_uv()) ? + wxString() : + _L("This model carries no texture coordinates of its own. Only a model imported with a UV " + "channel - a textured OBJ, say - can be mapped by them."); for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) { if (mi > 0) ImGui::SameLine(0.f, gap_s); const auto method = static_cast(mi); + const wxString na = (method == TextureProjectionMethod::ModelUV) ? model_uv_na : wxString(); if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label, - mappings[mi].tip) && + mappings[mi].tip, na) && layer.projection_method != method) { // Capture the current view the moment "From view" is chosen, so it does something // sensible immediately rather than projecting from a stale/default direction. @@ -7254,18 +7333,33 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); const std::string blend_add = _u8L_CONTEXT("Add", "Texture Displacement"); + const std::string blend_override = _u8L_CONTEXT("Override", "Texture Displacement"); const std::string blend_subtract = _u8L_CONTEXT("Subtract", "Texture Displacement"); const std::string blend_multiply = _u8L_CONTEXT("Multiply", "Texture Displacement"); const std::string blend_divide = _u8L("Divide"); - const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(), blend_divide.c_str() }; + const char *blend_items[] = { blend_add.c_str(), blend_override.c_str(), blend_subtract.c_str(), + blend_multiply.c_str(), blend_divide.c_str() }; + // The combo's order is the panel's, not the enum's - Override reads as the sibling of Add and + // belongs next to it, while TextureBlendMode's numbering is fixed by what projects already + // store. Hence the explicit mapping rather than a cast of the index. + static const TextureBlendMode blend_modes[] = { TextureBlendMode::Add, TextureBlendMode::Override, + TextureBlendMode::Subtract, TextureBlendMode::Multiply, + TextureBlendMode::Divide }; + static_assert(IM_ARRAYSIZE(blend_items) == IM_ARRAYSIZE(blend_modes), + "every Blend entry needs its mode"); // The first layer has nothing before it to combine with - build_texture_displacement() makes it // add regardless - so its combo shows Add and cannot be changed. const bool base_layer = li == 0; - int blend_mode = base_layer ? 0 : static_cast(layer.blend_mode); + int blend_mode = 0; + if (!base_layer) + for (int bi = 0; bi < int(IM_ARRAYSIZE(blend_modes)); ++bi) + if (blend_modes[bi] == layer.blend_mode) + blend_mode = bi; ImGui::SetNextItemWidth(-(card_pad + gap_s + icon_sm)); m_imgui->disabled_begin(base_layer); - if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) { - layer.blend_mode = static_cast(blend_mode); + if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer && + blend_mode >= 0 && blend_mode < int(IM_ARRAYSIZE(blend_modes))) { + layer.blend_mode = blend_modes[blend_mode]; m_preview_params_dirty = true; } m_imgui->disabled_end(); @@ -7273,10 +7367,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->tooltip(base_layer ? _u8L("The base layer has nothing beneath it to combine with, so it always adds.") : _u8L("What this layer does where it overlaps the ones below. Add and " - "Subtract pile relief on or carve it away. Multiply and Divide scale " - "what is underneath, which turns this layer into a mask over it - " - "there, Depth acts as a strength, and 1 mm leaves white areas " - "untouched."), + "Subtract pile relief on or carve it away. Override replaces them, " + "so only this layer's relief is left where it is painted. Multiply " + "and Divide scale what is underneath, which turns this layer into a " + "mask over it - there, Depth acts as a strength, and 1 mm leaves " + "white areas untouched."), wrap_w); ImGui::SameLine(0.f, gap_s); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 6470743543..232f1c1729 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -62,6 +62,12 @@ public: static TextureDisplacementPrepareResult prepare_mesh(const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, const std::vector &layers, + // `base`'s own UV channel, when it has one + // (ModelVolume::texture_uv). Resampled onto + // the result and reported back in it, and fed + // to the samplers so a ModelUV layer drives + // the refinement through its own layout. + const std::vector &model_uv, const TextureDisplacementPrepareParams ¶ms, const TextureColorSettings &color_settings, const DisplacementProgressFn &progress, @@ -921,18 +927,31 @@ private: // Merges chart `b`'s join group into chart `a`'s (materialising `groups` to `chart_count` first). static void join_island_groups(std::vector &groups, int a, int b, int chart_count); // Final per-vertex texture uv for the projections the shader can't reconstruct itself - LSCM (an - // unwrap) and ViewProjected (a projector plane the shader doesn't know). One entry per patch/base - // vertex, already through apply_uv_transform(). Empty for Triplanar/Cylindrical/Spherical, which - // the shader projects on its own. Shared by the shaded preview and the UV-check overlay. + // unwrap), ModelUV (the model's own channel) and ViewProjected (a projector plane the shader + // doesn't know). One entry per patch/base vertex, already through apply_uv_transform(). Empty for + // Triplanar/Cylindrical/Spherical, which the shader projects on its own. Shared by the shaded + // preview and the UV-check overlay. + // + // `patch_source` maps each patch triangle back to its base-mesh triangle + // (TriangleSelector::get_facets_strict()'s out_source). Only ModelUV needs it - its channel is per + // base triangle - and without it a ModelUV layer reports no uvs and the preview falls back. std::vector compute_layer_vertex_uvs(const indexed_triangle_set &patch, - const TextureDisplacementLayer &layer) const; + const TextureDisplacementLayer &layer, + const std::vector *patch_source = nullptr) const; // The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what // the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different // UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined // seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is // single-valued per point, so there a corner's UV is just its vertex's. std::vector compute_layer_corner_uvs(const indexed_triangle_set &patch, - const TextureDisplacementLayer &layer) const; + const TextureDisplacementLayer &layer, + const std::vector *patch_source = nullptr) const; + // The model's own channel over `local_patch`, already through apply_uv_transform() - three per + // patch triangle. Empty when the volume has no channel, or when `patch_source` is missing. Shared + // by the two functions above so the per-vertex and per-corner forms can never disagree. + std::vector model_uv_corners(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const; // The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two // wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the // shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index 2ea468a76f..72363e4393 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -76,7 +76,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) } return true; }, - color, m_input.volume_to_world, nullptr, &m_stats)); + color, m_input.volume_to_world, nullptr, &m_stats, + m_input.model_uv.empty() ? nullptr : &m_input.model_uv)); // Always finish at 100: this is what closes the notification. Reported even on cancel, where // build_texture_displacement() returns an empty mesh and finalize() commits nothing. @@ -223,6 +224,14 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept volume->texture_displacement_facet(slot).reset(); } + // The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started + // from: a displaced vertex came from its foot point on that surface, which is the position its + // uv belongs to - so no distance limit, the relief depth is exactly how far it has moved. + if (!m_input.model_uv.empty()) + volume->texture_uv = topology_changed ? transfer_uv_channel(m_input.base_mesh, m_input.model_uv, + volume->mesh().its) : + m_input.model_uv; + ModelObject *object = volume->get_object(); if (object == nullptr) return; @@ -275,6 +284,8 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol input.take_snapshot = take_snapshot; input.volume_id = volume.id(); input.base_mesh = volume.mesh().its; + if (volume.has_texture_uv()) + input.model_uv = volume.texture_uv; input.layers = volume.texture_displacement_layers; input.options = volume.texture_displacement_options; input.volume_to_world = texture_displacement_volume_to_world(volume); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp index 40d7ab7b2b..5e1a586736 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp @@ -34,6 +34,9 @@ struct TextureDisplacementBakeInput // writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU // paint gizmo writes - alongside the displaced geometry. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV + // layer maps through, and what the commit puts back on the baked mesh. + std::vector model_uv; // Whether this job pushes its own undo step when it commits. False when the caller has already // taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes // and subdivides first and has to undo as a single action. diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp index 12701ef3c9..eb4eec036f 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp @@ -17,6 +17,7 @@ #include "slic3r/GUI/Jobs/Worker.hpp" #include #include "libslic3r/ObjectID.hpp" +#include "libslic3r/Point.hpp" #include "libslic3r/TriangleMesh.hpp" namespace Slic3r::GUI { @@ -52,13 +53,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl) indexed_triangle_set mesh = m_input.base_mesh; TextureDisplacementFacetsData masks = m_input.facets_data; + std::vector uv = m_input.model_uv; // The default pipeline can only move vertices the mesh already has, so its recipe starts with the // preparation - which is where "first it remeshes, then it refines" actually happens. The // experimental pipeline refines as part of the bake and has nothing to prepare. if (m_input.run_prepare && !m_input.options.pipeline_v2) { const TextureDisplacementPrepareResult prepared = - GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params, + GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_params, m_input.color, // Preparation is roughly half the run; the bake // takes the progress bar from there. @@ -77,12 +79,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl) if (!prepared.mesh.indices.empty()) { mesh = prepared.mesh; masks = prepared.masks; + uv = prepared.uv; // resampled onto the prepared mesh, as the prepare commit stores it } } build_texture_displacement(mesh, m_input.layers, masks, m_input.options, [&report](int pct) { return report(50 + pct / 2); }, - /* color */ nullptr, m_input.volume_to_world, &recorder); + /* color */ nullptr, m_input.volume_to_world, &recorder, /* stats */ nullptr, + uv.empty() ? nullptr : &uv); m_stages = recorder.take(); ctl.update_status(100, status); // always finish at 100: this is what closes the notification @@ -115,6 +119,8 @@ void queue_texture_displacement_debug(const ModelVolume &volume, const TextureCo input.check_topology = check_topology; input.color = color; input.volume_to_world = texture_displacement_volume_to_world(volume); + if (volume.has_texture_uv()) + input.model_uv = volume.texture_uv; for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp index f4f9f88f4e..981a17773f 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp @@ -47,6 +47,9 @@ struct TextureDisplacementDebugInput // Only the quantizer is used, and only by the refinement's colour criterion - the debug run // produces no colour of its own. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), so the recipe captured here is the one a + // ModelUV layer actually bakes through. Empty when the volume has none. + std::vector model_uv; // The edge scan behind the open / non-manifold counts is a sort over every half-edge, which on a // multi-million triangle stage costs more than the stage did. On by default because a stage that diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp index 8a956af98d..6c582d3746 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp @@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl) // idle loop. int last_reported = 1; m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers, - m_input.params, m_input.color, + m_input.model_uv, m_input.params, m_input.color, [&ctl, &status, &last_reported](int percent) { if (ctl.was_canceled()) return false; @@ -106,6 +106,10 @@ void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr & volume->restore_painting(saved_painting); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)])); + // The model's own UV channel, resampled onto the new mesh by the run. Also put back after + // restore_painting(), which resets it along with the extra facets for the same reason: it is + // indexed by triangle and the triangles have changed. + volume->texture_uv = std::move(m_result.uv); if (ObjectList *obj_list = wxGetApp().obj_list()) { const ModelObjectPtrs &objs = plater->model().objects; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp index 5fc173975f..a07bf63ae9 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp @@ -9,6 +9,7 @@ #include "Job.hpp" #include "libslic3r/ObjectID.hpp" +#include "libslic3r/Point.hpp" #include "libslic3r/TextureDisplacement.hpp" namespace Slic3r { @@ -47,6 +48,10 @@ struct TextureDisplacementPrepareInput // skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows // perceived colour, never the interleaving that realises a mix. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh() + // resamples it onto the mesh it produces, and the commit puts it back - like the masks, it is + // indexed by triangle and so cannot survive the stages on its own. + std::vector model_uv; // The undo step the commit opens. Standard mode's Bake names it after the bake, because the // displacement job that follows commits into this same step rather than pushing its own. std::string snapshot_name; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 4243cdd9d3..32b88aa951 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -48,7 +48,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) (!m_current_generation || m_current_generation->load() == m_generation); }, - color, m_input.volume_to_world); + color, m_input.volume_to_world, nullptr, nullptr, + m_input.model_uv.empty() ? nullptr : &m_input.model_uv); } void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index 07fcca8664..7f7941e828 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -29,6 +29,9 @@ struct TextureDisplacementPreviewInput // Empty unless a layer is colouring, in which case the preview reports the palette entry per // triangle alongside the mesh, so the Normal view shows the colours the bake will produce. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the + // same layout it will bake through. Empty when the volume has none. + std::vector model_uv; }; // A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 2e4210189d..4c3e74d705 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -3103,3 +3103,353 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } + +// --------------------------------------------------------------------------------------------- +// The model's own UV channel, and the Override blend mode +// --------------------------------------------------------------------------------------------- + +// A grayscale image that is black in its left half and white in its right half, so where a sample +// lands along u is readable straight off the height it comes back with. +static std::shared_ptr> make_left_right_split_png(size_t w = 16, size_t h = 16) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (x < w / 2) ? 0 : 255; + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +// Where a sheet coordinate lands in uv: across [UV_LO, UV_LO + UV_SPAN] rather than the full [0, 1]. +// The inset keeps the tests off the exact edge of an untiled placement, where there is deliberately no +// texture at all rather than its border texel (see DecodedHeightTexture::sample()) - a real model's +// channel does reach 1.0, and a vertex sitting exactly there displaces by nothing on purpose. +static constexpr float UV_LO = 0.1f; +static constexpr float UV_SPAN = 0.8f; + +static float sheet_uv_at(float coord, float size, bool mirror = false) +{ + const float t = coord / size; + return UV_LO + UV_SPAN * (mirror ? 1.f - t : t); +} + +// One UV per triangle corner: u follows x, v follows y. `mirror_u` mirrors u, which is what the tests +// use to show the bake is really reading the channel rather than projecting the position. +static std::vector sheet_uv_channel(const indexed_triangle_set &sheet, float size, bool mirror_u = false) +{ + std::vector uv(sheet.indices.size() * 3); + for (size_t f = 0; f < sheet.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = sheet.vertices[size_t(sheet.indices[f][k])]; + uv[f * 3 + size_t(k)] = Vec2f(sheet_uv_at(p.x(), size, mirror_u), sheet_uv_at(p.y(), size)); + } + return uv; +} + +TEST_CASE("Override blend replaces the displacement under it instead of combining with it", + "[TextureDisplacement]") +{ + CHECK_THAT(blend_displacement(3.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f)); + // Nothing underneath yet is still just the value, so an Override layer at the bottom of the + // stack behaves exactly like the Add the bake forces there. + CHECK_THAT(blend_displacement(0.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f)); + // The sign carries, and a zero sample really does flatten what is under it - that is the point. + CHECK_THAT(blend_displacement(3.f, -1.f, TextureBlendMode::Override), WithinAbs(-1.f, 1e-6f)); + CHECK_THAT(blend_displacement(3.f, 0.f, TextureBlendMode::Override), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("an Override layer leaves only its own relief where two layers overlap", "[TextureDisplacement]") +{ + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + + TextureDisplacementLayer base; + base.slot = 0; + base.depth_mm = 2.0f; + base.tiling_scale = 5.0f; + base.image_data = make_flat_gray_png(255); // full depth everywhere + + TextureDisplacementLayer second = base; + second.slot = 1; + second.depth_mm = 0.5f; + second.blend_mode = TextureBlendMode::Override; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(cube); + facets[1] = facets[0]; + + const indexed_triangle_set overridden = build_texture_displacement(cube, {base, second}, facets, classic_options()); + REQUIRE(overridden.vertices.size() == cube.vertices.size()); + + // Add would have piled the two up to 2.5 mm; Override keeps the upper layer's 0.5 mm alone. + TextureDisplacementLayer added = second; + added.blend_mode = TextureBlendMode::Add; + const indexed_triangle_set summed = build_texture_displacement(cube, {base, added}, facets, classic_options()); + REQUIRE(summed.vertices.size() == cube.vertices.size()); + + for (size_t i = 0; i < cube.vertices.size(); ++i) { + if (!above_resting_plane(cube, i)) + continue; + CHECK_THAT((overridden.vertices[i] - cube.vertices[i]).norm(), WithinAbs(0.5f, 1e-3f)); + CHECK_THAT((summed.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.5f, 1e-3f)); + } +} + +TEST_CASE("the ModelUV mapping displaces through the model's own UV channel", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 1.f); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.image_data = make_left_right_split_png(); + layer.tile_enabled = false; // the layout is the placement; nothing should repeat + layer.projection_method = TextureProjectionMethod::ModelUV; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(sheet); + + const std::vector uv = sheet_uv_channel(sheet, size); + const std::vector flipped = sheet_uv_channel(sheet, size, /* mirror_u */ true); + + const indexed_triangle_set out = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, nullptr, + Transform3d::Identity(), nullptr, nullptr, &uv); + REQUIRE(out.vertices.size() == sheet.vertices.size()); + + // The image is black for u < 0.5 and white above it, and the channel's u rises with x across the + // sheet, so it comes out flat on its left half and raised by the full depth on its right. + size_t low = 0, high = 0; + for (size_t i = 0; i < sheet.vertices.size(); ++i) { + const float x = sheet.vertices[i].x(); + const float dz = out.vertices[i].z() - sheet.vertices[i].z(); + if (x < 0.4f * size) { + CHECK_THAT(dz, WithinAbs(0.f, 1e-3f)); + ++low; + } else if (x > 0.6f * size) { + CHECK_THAT(dz, WithinAbs(layer.depth_mm, 1e-3f)); + ++high; + } + } + REQUIRE(low > 0); + REQUIRE(high > 0); + + // Mirroring the channel mirrors the relief: the mapping really is the channel's, not a planar + // projection of the position (which would be unchanged by it). + const indexed_triangle_set mirrored = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, + nullptr, Transform3d::Identity(), nullptr, nullptr, + &flipped); + REQUIRE(mirrored.vertices.size() == sheet.vertices.size()); + for (size_t i = 0; i < sheet.vertices.size(); ++i) { + const float x = sheet.vertices[i].x(); + if (x < 0.4f * size) + CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(layer.depth_mm, 1e-3f)); + else if (x > 0.6f * size) + CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(0.f, 1e-3f)); + } +} + +TEST_CASE("a ModelUV layer falls back to Triplanar when the volume carries no UV channel", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 2.f); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_gray_png(255); + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(sheet); + + layer.projection_method = TextureProjectionMethod::Triplanar; + const indexed_triangle_set as_triplanar = build_texture_displacement(sheet, {layer}, facets, classic_options()); + layer.projection_method = TextureProjectionMethod::ModelUV; + const indexed_triangle_set no_channel = build_texture_displacement(sheet, {layer}, facets, classic_options()); + + REQUIRE(no_channel.vertices.size() == as_triplanar.vertices.size()); + for (size_t i = 0; i < as_triplanar.vertices.size(); ++i) + CHECK_THAT((no_channel.vertices[i] - as_triplanar.vertices[i]).norm(), WithinAbs(0.f, 1e-5f)); +} + +TEST_CASE("a bake with colour off on every layer reports no colour at all", "[TextureDisplacement]") +{ + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_rgb_png(200, 40, 10); // has colour to give... + layer.color_enabled = false; // ...but is not asked for it + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(cube); + + std::vector triangle_color; + TextureColorRequest request; + request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked + request.out_triangle = &triangle_color; + + const indexed_triangle_set out = build_texture_displacement(cube, {layer}, facets, classic_options(), {}, &request); + REQUIRE_FALSE(out.vertices.empty()); + // Every entry 0 ("this triangle takes no colour from the texture"), so the commit leaves whatever + // the model was already painted with exactly as it was. + for (const uint8_t entry : triangle_color) + CHECK(entry == 0); +} + +TEST_CASE("transfer_uv_channel resamples a UV channel onto a subdivided copy of the same surface", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 5.f); + const indexed_triangle_set fine = make_flat_sheet(size, 1.f); + const std::vector uv = sheet_uv_channel(coarse, size); + + const std::vector moved = transfer_uv_channel(coarse, uv, fine); + REQUIRE(moved.size() == fine.indices.size() * 3); + + // The channel is affine in position here (u = x/size, v = y/size), so a correct transfer + // reproduces it exactly at every corner of the finer mesh. + for (size_t f = 0; f < fine.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = fine.vertices[size_t(fine.indices[f][k])]; + CHECK_THAT(moved[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f)); + CHECK_THAT(moved[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f)); + } + + // A mismatched channel is refused rather than read past its end. + CHECK(transfer_uv_channel(coarse, {Vec2f::Zero()}, fine).empty()); + CHECK(transfer_uv_channel(coarse, uv, indexed_triangle_set{}).empty()); +} + +TEST_CASE("the model's per-corner UVs survive a brush split and collapse to one per vertex", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 5.f); + const std::vector uv = sheet_uv_channel(sheet, size); + + // The whole sheet as a patch, with the source map the channel is read through. + const TriangleMesh tm(sheet); + TriangleSelector selector(tm); + for (int f = 0; f < int(sheet.indices.size()); ++f) + selector.set_facet(f, EnforcerBlockerType::ENFORCER); + std::vector source; + const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, &source); + REQUIRE(patch.indices.size() == source.size()); + + const std::vector corner = compute_model_corner_uvs(sheet, uv, patch, source); + REQUIRE(corner.size() == patch.indices.size() * 3); + // An unsplit patch triangle *is* its source triangle, so its corners reproduce the channel. + for (size_t f = 0; f < patch.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = patch.vertices[size_t(patch.indices[f][k])]; + CHECK_THAT(corner[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f)); + CHECK_THAT(corner[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f)); + } + + const std::vector per_vertex = collapse_corner_uvs(patch, corner); + REQUIRE(per_vertex.size() == patch.vertices.size()); + for (size_t i = 0; i < patch.vertices.size(); ++i) { + CHECK_THAT(per_vertex[i].x(), WithinAbs(sheet_uv_at(patch.vertices[i].x(), size), 1e-4f)); + CHECK_THAT(per_vertex[i].y(), WithinAbs(sheet_uv_at(patch.vertices[i].y(), size), 1e-4f)); + } + + // Mismatched inputs report nothing rather than guessing. + CHECK(compute_model_corner_uvs(sheet, {Vec2f::Zero()}, patch, source).empty()); + CHECK(compute_model_corner_uvs(sheet, uv, patch, {}).empty()); + CHECK(collapse_corner_uvs(patch, {Vec2f::Zero()}).empty()); +} + +TEST_CASE("facet paint is carried onto a rebuilt mesh by each facet's foot point", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f); + + // Two filaments, split down the middle - the pattern the transfer has to reproduce. + const TriangleMesh coarse_mesh(coarse); + TriangleSelector painter(coarse_mesh); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + painter.set_facet(int(t), x < 0.5f * size ? EnforcerBlockerType::Extruder1 : EnforcerBlockerType::Extruder2); + } + const TriangleSelector::TriangleSplittingData paint = painter.serialize(); + + // A finer mesh, lifted clear of the original surface the way a bake's relief lifts it. This is + // what ModelVolume::restore_painting()'s 0.01 mm overlap remap cannot follow. + indexed_triangle_set lifted = make_flat_sheet(size, 1.f); + for (Vec3f &v : lifted.vertices) + v.z() += 0.5f; + + const std::vector state = transfer_facet_paint(coarse, paint, lifted); + REQUIRE(state.size() == lifted.indices.size()); + + size_t left = 0, right = 0; + for (size_t t = 0; t < lifted.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = lifted.indices[t]; + const float x = (lifted.vertices[size_t(tri[0])].x() + lifted.vertices[size_t(tri[1])].x() + + lifted.vertices[size_t(tri[2])].x()) / 3.f; + // Only away from the split: a facet straddling it may legitimately land on either side. + if (x < 0.4f * size) { + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder1)); + ++left; + } else if (x > 0.6f * size) { + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder2)); + ++right; + } + } + REQUIRE(left > 0); + REQUIRE(right > 0); + + // An unpainted source has nothing to carry, which is not the same as carrying NONE everywhere: + // the caller has to tell those apart to decide between resetting the annotation and writing one. + CHECK(transfer_facet_paint(coarse, TriangleSelector::TriangleSplittingData{}, lifted).empty()); + CHECK(transfer_facet_paint(coarse, paint, indexed_triangle_set{}).empty()); +} + +TEST_CASE("an unpainted part of the source carries no paint onto the rebuilt mesh", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f); + + // Only the left half is painted at all; the right half must come back NONE rather than borrowing + // the nearest painted colour from across the model. + const TriangleMesh coarse_mesh(coarse); + TriangleSelector painter(coarse_mesh); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + if (x < 0.5f * size) + painter.set_facet(int(t), EnforcerBlockerType::Extruder3); + } + + const std::vector state = transfer_facet_paint(coarse, painter.serialize(), coarse); + REQUIRE(state.size() == coarse.indices.size()); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + if (x < 0.4f * size) + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder3)); + else if (x > 0.6f * size) + CHECK(state[t] == uint8_t(EnforcerBlockerType::NONE)); + } +}