mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-15 13:07:33 +00:00
Tests for the step cutter, edge flips, auto resolution and v2 colours
This commit is contained in:
@@ -5,9 +5,12 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <set>
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
|
|
||||||
#include "libslic3r/TextureDisplacement.hpp"
|
#include "libslic3r/TextureDisplacement.hpp"
|
||||||
|
#include "libslic3r/TextureBake/TextureBakeFlip.hpp"
|
||||||
|
#include "libslic3r/TextureBake/TextureBakeMesh.hpp"
|
||||||
#include "libslic3r/TriangleMesh.hpp"
|
#include "libslic3r/TriangleMesh.hpp"
|
||||||
#include "libslic3r/TriangleSelector.hpp"
|
#include "libslic3r/TriangleSelector.hpp"
|
||||||
#include "libslic3r/PNGReadWrite.hpp"
|
#include "libslic3r/PNGReadWrite.hpp"
|
||||||
@@ -61,6 +64,29 @@ static std::shared_ptr<std::vector<unsigned char>> make_checkerboard_png(size_t
|
|||||||
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
|
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bake never drives relief below the model's own resting plane (see build_texture_displacement()),
|
||||||
|
// so on a fully painted closed solid the vertices already sitting on that plane - a cube's four bottom
|
||||||
|
// corners, whose normals point downwards - are clamped in Z and do not move by the full depth. The
|
||||||
|
// tests below are about the displacement maths, so they check the vertices the clamp cannot touch;
|
||||||
|
// the clamp itself has its own test.
|
||||||
|
|
||||||
|
// The tests below check the classic, topology-preserving bake vertex by vertex, so they select it
|
||||||
|
// explicitly: the default pipeline rebuilds the topology and has no vertex correspondence to check.
|
||||||
|
static TextureDisplacementOptions classic_options()
|
||||||
|
{
|
||||||
|
TextureDisplacementOptions o;
|
||||||
|
o.pipeline_v2 = false;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool above_resting_plane(const indexed_triangle_set &mesh, size_t vi)
|
||||||
|
{
|
||||||
|
float bottom = std::numeric_limits<float>::max();
|
||||||
|
for (const Vec3f &v : mesh.vertices)
|
||||||
|
bottom = std::min(bottom, v.z());
|
||||||
|
return mesh.vertices[vi].z() > bottom + 1e-4f;
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("TextureDisplacement: decode_height_texture round-trips an 8-bit grayscale PNG", "[TextureDisplacement]")
|
TEST_CASE("TextureDisplacement: decode_height_texture round-trips an 8-bit grayscale PNG", "[TextureDisplacement]")
|
||||||
{
|
{
|
||||||
TextureDisplacementLayer layer;
|
TextureDisplacementLayer layer;
|
||||||
@@ -79,7 +105,7 @@ TEST_CASE("TextureDisplacement: an empty layer list leaves the mesh unchanged",
|
|||||||
const std::vector<TextureDisplacementLayer> layers; // none
|
const std::vector<TextureDisplacementLayer> layers; // none
|
||||||
TextureDisplacementFacetsData facets{}; // all empty
|
TextureDisplacementFacetsData facets{}; // all empty
|
||||||
|
|
||||||
const indexed_triangle_set result = build_texture_displacement(cube, layers, facets);
|
const indexed_triangle_set result = build_texture_displacement(cube, layers, facets, classic_options());
|
||||||
|
|
||||||
REQUIRE(result.vertices.size() == cube.vertices.size());
|
REQUIRE(result.vertices.size() == cube.vertices.size());
|
||||||
REQUIRE(result.indices.size() == cube.indices.size());
|
REQUIRE(result.indices.size() == cube.indices.size());
|
||||||
@@ -106,10 +132,12 @@ TEST_CASE("TextureDisplacement: fully painting a mesh displaces every vertex alo
|
|||||||
layer.tiling_scale = 5.0f;
|
layer.tiling_scale = 5.0f;
|
||||||
layer.image_data = make_flat_gray_png(255); // sample() == 1.0 everywhere -> full depth_mm displacement
|
layer.image_data = make_flat_gray_png(255); // sample() == 1.0 everywhere -> full depth_mm displacement
|
||||||
|
|
||||||
const indexed_triangle_set result = build_texture_displacement(cube, {layer}, facets);
|
const indexed_triangle_set result = build_texture_displacement(cube, {layer}, facets, classic_options());
|
||||||
|
|
||||||
REQUIRE(result.vertices.size() == cube.vertices.size());
|
REQUIRE(result.vertices.size() == cube.vertices.size());
|
||||||
for (size_t i = 0; i < cube.vertices.size(); ++i) {
|
for (size_t i = 0; i < cube.vertices.size(); ++i) {
|
||||||
|
if (!above_resting_plane(cube, i))
|
||||||
|
continue;
|
||||||
const float moved = (result.vertices[i] - cube.vertices[i]).norm();
|
const float moved = (result.vertices[i] - cube.vertices[i]).norm();
|
||||||
CHECK_THAT(moved, WithinAbs(layer.depth_mm, 1e-3f));
|
CHECK_THAT(moved, WithinAbs(layer.depth_mm, 1e-3f));
|
||||||
}
|
}
|
||||||
@@ -148,13 +176,14 @@ TEST_CASE("TextureDisplacement: a second layer over the same area is applied too
|
|||||||
second.depth_mm = 0.5f;
|
second.depth_mm = 0.5f;
|
||||||
second.blend_mode = TextureBlendMode::Add;
|
second.blend_mode = TextureBlendMode::Add;
|
||||||
|
|
||||||
const indexed_triangle_set result = build_texture_displacement(cube, {base, second}, facets);
|
const indexed_triangle_set result = build_texture_displacement(cube, {base, second}, facets, classic_options());
|
||||||
|
|
||||||
// Topology is preserved exactly, so vertices can be compared 1:1 with the input.
|
// Topology is preserved exactly, so vertices can be compared 1:1 with the input.
|
||||||
REQUIRE(result.vertices.size() == cube.vertices.size());
|
REQUIRE(result.vertices.size() == cube.vertices.size());
|
||||||
REQUIRE(result.indices.size() == cube.indices.size());
|
REQUIRE(result.indices.size() == cube.indices.size());
|
||||||
for (size_t i = 0; i < cube.vertices.size(); ++i)
|
for (size_t i = 0; i < cube.vertices.size(); ++i)
|
||||||
CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(1.5f, 1e-3f)); // 1.0 + 0.5, not just 1.0
|
if (above_resting_plane(cube, i))
|
||||||
|
CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(1.5f, 1e-3f)); // 1.0 + 0.5, not just 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("TextureDisplacement: blend modes combine a layer with the ones below it", "[TextureDisplacement]")
|
TEST_CASE("TextureDisplacement: blend modes combine a layer with the ones below it", "[TextureDisplacement]")
|
||||||
@@ -185,11 +214,12 @@ TEST_CASE("TextureDisplacement: blend modes combine a layer with the ones below
|
|||||||
}));
|
}));
|
||||||
second.blend_mode = std::get<0>(expected);
|
second.blend_mode = std::get<0>(expected);
|
||||||
|
|
||||||
const indexed_triangle_set result = build_texture_displacement(cube, {base, second}, facets);
|
const indexed_triangle_set result = build_texture_displacement(cube, {base, second}, facets, classic_options());
|
||||||
|
|
||||||
REQUIRE(result.vertices.size() == cube.vertices.size());
|
REQUIRE(result.vertices.size() == cube.vertices.size());
|
||||||
for (size_t i = 0; i < cube.vertices.size(); ++i)
|
for (size_t i = 0; i < cube.vertices.size(); ++i)
|
||||||
CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(std::get<1>(expected), 1e-3f));
|
if (above_resting_plane(cube, i))
|
||||||
|
CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(std::get<1>(expected), 1e-3f));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("TextureDisplacement: the lowest layer ignores its blend mode", "[TextureDisplacement]")
|
TEST_CASE("TextureDisplacement: the lowest layer ignores its blend mode", "[TextureDisplacement]")
|
||||||
@@ -208,10 +238,107 @@ TEST_CASE("TextureDisplacement: the lowest layer ignores its blend mode", "[Text
|
|||||||
layer.blend_mode = TextureBlendMode::Multiply;
|
layer.blend_mode = TextureBlendMode::Multiply;
|
||||||
layer.image_data = make_flat_gray_png(255);
|
layer.image_data = make_flat_gray_png(255);
|
||||||
|
|
||||||
const indexed_triangle_set result = build_texture_displacement(cube, {layer}, facets);
|
const indexed_triangle_set result = build_texture_displacement(cube, {layer}, facets, classic_options());
|
||||||
|
|
||||||
for (size_t i = 0; i < cube.vertices.size(); ++i)
|
for (size_t i = 0; i < cube.vertices.size(); ++i)
|
||||||
CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.0f, 1e-3f));
|
if (above_resting_plane(cube, i))
|
||||||
|
CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.0f, 1e-3f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: relief is never driven below the model's resting plane", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// A fully painted cube displaces outward everywhere, which on the bottom face means straight
|
||||||
|
// down - through the build plate. That geometry cannot be printed, so it is clamped back up.
|
||||||
|
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
|
||||||
|
|
||||||
|
TextureDisplacementFacetsData facets{};
|
||||||
|
facets[0] = paint_whole_mesh(cube);
|
||||||
|
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.depth_mm = 2.0f;
|
||||||
|
layer.tiling_scale = 5.0f;
|
||||||
|
layer.image_data = make_flat_gray_png(255); // full depth everywhere
|
||||||
|
|
||||||
|
float bottom = std::numeric_limits<float>::max();
|
||||||
|
for (const Vec3f &v : cube.vertices)
|
||||||
|
bottom = std::min(bottom, v.z());
|
||||||
|
|
||||||
|
const indexed_triangle_set result = build_texture_displacement(cube, {layer}, facets, classic_options());
|
||||||
|
|
||||||
|
REQUIRE(result.vertices.size() == cube.vertices.size());
|
||||||
|
for (const Vec3f &v : result.vertices)
|
||||||
|
CHECK(v.z() >= bottom - 1e-4f);
|
||||||
|
|
||||||
|
// ...and the clamp is confined to Z: a bottom corner still moves outwards in X and Y by the same
|
||||||
|
// amount it would have, rather than being pinned wholesale.
|
||||||
|
bool any_bottom_moved_sideways = false;
|
||||||
|
for (size_t i = 0; i < cube.vertices.size(); ++i)
|
||||||
|
if (!above_resting_plane(cube, i) &&
|
||||||
|
(result.vertices[i].head<2>() - cube.vertices[i].head<2>()).norm() > 1e-3f)
|
||||||
|
any_bottom_moved_sideways = true;
|
||||||
|
CHECK(any_bottom_moved_sideways);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: depth is measured in world millimetres, not the volume's own", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// The same painted patch, baked once untransformed and once through a 3x scale. "Depth (mm)" is
|
||||||
|
// a millimetre on the printed part, so the *world* relief must come out the same height either
|
||||||
|
// way - which means the vertices of the scaled volume move by a third as much in its own
|
||||||
|
// coordinates. Baking both in volume space instead gave a 3x deeper relief on the scaled one.
|
||||||
|
indexed_triangle_set fan;
|
||||||
|
fan.vertices = { {0.f, 0.f, 1.f}, {1.f, 0.f, 1.f}, {0.f, 1.f, 1.f}, {-1.f, 0.f, 1.f}, {0.f, -1.f, 1.f} };
|
||||||
|
fan.indices = { {0, 1, 2}, {0, 2, 3}, {0, 3, 4}, {0, 4, 1} };
|
||||||
|
|
||||||
|
TextureDisplacementFacetsData facets{};
|
||||||
|
facets[0] = paint_whole_mesh(fan);
|
||||||
|
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.depth_mm = 1.0f;
|
||||||
|
layer.tiling_scale = 5.0f;
|
||||||
|
layer.image_data = make_flat_gray_png(255);
|
||||||
|
|
||||||
|
const indexed_triangle_set plain = build_texture_displacement(fan, {layer}, facets, classic_options());
|
||||||
|
Transform3d scale3 = Transform3d::Identity();
|
||||||
|
scale3.scale(Vec3d(3.0, 3.0, 3.0));
|
||||||
|
const indexed_triangle_set scaled = build_texture_displacement(fan, {layer}, facets, classic_options(), {}, nullptr, scale3);
|
||||||
|
|
||||||
|
REQUIRE(plain.vertices.size() == fan.vertices.size());
|
||||||
|
REQUIRE(scaled.vertices.size() == fan.vertices.size());
|
||||||
|
for (size_t i = 0; i < fan.vertices.size(); ++i) {
|
||||||
|
CHECK_THAT(plain.vertices[i].z() - fan.vertices[i].z(), WithinAbs(1.0f, 1e-3f));
|
||||||
|
// A third of the movement locally is the same movement once the 3x scale is applied.
|
||||||
|
CHECK_THAT(scaled.vertices[i].z() - fan.vertices[i].z(), WithinAbs(1.0f / 3.0f, 1e-3f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: a mirrored placement still raises the relief outwards", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// Mirroring reverses the winding, and every normal in the bake is derived from the winding - so
|
||||||
|
// without correcting for it the whole relief is carved into the surface instead of raised off it.
|
||||||
|
indexed_triangle_set fan;
|
||||||
|
fan.vertices = { {0.f, 0.f, 1.f}, {1.f, 0.f, 1.f}, {0.f, 1.f, 1.f}, {-1.f, 0.f, 1.f}, {0.f, -1.f, 1.f} };
|
||||||
|
fan.indices = { {0, 1, 2}, {0, 2, 3}, {0, 3, 4}, {0, 4, 1} };
|
||||||
|
|
||||||
|
TextureDisplacementFacetsData facets{};
|
||||||
|
facets[0] = paint_whole_mesh(fan);
|
||||||
|
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.depth_mm = 1.0f;
|
||||||
|
layer.tiling_scale = 5.0f;
|
||||||
|
layer.image_data = make_flat_gray_png(255);
|
||||||
|
|
||||||
|
// Mirrored in X: the patch's outward direction in world space is still +Z, so in the volume's own
|
||||||
|
// coordinates the vertices must still move +Z.
|
||||||
|
Transform3d mirror_x = Transform3d::Identity();
|
||||||
|
mirror_x.scale(Vec3d(-1.0, 1.0, 1.0));
|
||||||
|
const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets, classic_options(), {}, nullptr, mirror_x);
|
||||||
|
|
||||||
|
REQUIRE(result.vertices.size() == fan.vertices.size());
|
||||||
|
for (size_t i = 0; i < fan.vertices.size(); ++i)
|
||||||
|
CHECK_THAT(result.vertices[i].z() - fan.vertices[i].z(), WithinAbs(1.0f, 1e-3f));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("TextureDisplacement: the patch border is displaced by default and pinned on request", "[TextureDisplacement]")
|
TEST_CASE("TextureDisplacement: the patch border is displaced by default and pinned on request", "[TextureDisplacement]")
|
||||||
@@ -250,7 +377,7 @@ TEST_CASE("TextureDisplacement: the patch border is displaced by default and pin
|
|||||||
|
|
||||||
SECTION("by default the whole painted patch moves, border included")
|
SECTION("by default the whole painted patch moves, border included")
|
||||||
{
|
{
|
||||||
const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets);
|
const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets, classic_options());
|
||||||
REQUIRE(result.vertices.size() == fan.vertices.size());
|
REQUIRE(result.vertices.size() == fan.vertices.size());
|
||||||
for (size_t i = 0; i < fan.vertices.size(); ++i)
|
for (size_t i = 0; i < fan.vertices.size(); ++i)
|
||||||
CHECK(moved(result, i));
|
CHECK(moved(result, i));
|
||||||
@@ -263,7 +390,7 @@ TEST_CASE("TextureDisplacement: the patch border is displaced by default and pin
|
|||||||
|
|
||||||
SECTION("pinning the border holds exactly the vertices an unpainted triangle also uses")
|
SECTION("pinning the border holds exactly the vertices an unpainted triangle also uses")
|
||||||
{
|
{
|
||||||
TextureDisplacementOptions options;
|
TextureDisplacementOptions options = classic_options();
|
||||||
options.displace_border = false;
|
options.displace_border = false;
|
||||||
const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets, options);
|
const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets, options);
|
||||||
CHECK_FALSE(moved(result, 0)); // O: border
|
CHECK_FALSE(moved(result, 0)); // O: border
|
||||||
@@ -322,7 +449,7 @@ TEST_CASE("TextureDisplacement: post-process smoothing relaxes only what moved",
|
|||||||
}
|
}
|
||||||
REQUIRE(rim_count > 8);
|
REQUIRE(rim_count > 8);
|
||||||
|
|
||||||
TextureDisplacementOptions options;
|
TextureDisplacementOptions options = classic_options();
|
||||||
options.smooth_enabled = true;
|
options.smooth_enabled = true;
|
||||||
options.smooth_strength = 0.5f;
|
options.smooth_strength = 0.5f;
|
||||||
options.smooth_iterations = 4;
|
options.smooth_iterations = 4;
|
||||||
@@ -345,7 +472,7 @@ TEST_CASE("TextureDisplacement: post-process smoothing relaxes only what moved",
|
|||||||
return e;
|
return e;
|
||||||
};
|
};
|
||||||
|
|
||||||
const indexed_triangle_set raw = build_texture_displacement(grid, { layer }, facets);
|
const indexed_triangle_set raw = build_texture_displacement(grid, { layer }, facets, classic_options());
|
||||||
REQUIRE(roughness(raw) > 0.0); // the checkerboard really did produce relief to smooth
|
REQUIRE(roughness(raw) > 0.0); // the checkerboard really did produce relief to smooth
|
||||||
|
|
||||||
const indexed_triangle_set smoothed = build_texture_displacement(grid, { layer }, facets, options);
|
const indexed_triangle_set smoothed = build_texture_displacement(grid, { layer }, facets, options);
|
||||||
@@ -364,7 +491,7 @@ TEST_CASE("TextureDisplacement: post-process smoothing relaxes only what moved",
|
|||||||
// its own height and cannot move, while every rim vertex has at least one neighbour outside the
|
// its own height and cannot move, while every rim vertex has at least one neighbour outside the
|
||||||
// paint pinned at zero and so must come down the moment it is allowed to.
|
// paint pinned at zero and so must come down the moment it is allowed to.
|
||||||
layer.image_data = make_flat_gray_png(255);
|
layer.image_data = make_flat_gray_png(255);
|
||||||
const indexed_triangle_set raw = build_texture_displacement(grid, { layer }, facets);
|
const indexed_triangle_set raw = build_texture_displacement(grid, { layer }, facets, classic_options());
|
||||||
|
|
||||||
options.smooth_skip_border = true;
|
options.smooth_skip_border = true;
|
||||||
const indexed_triangle_set kept = build_texture_displacement(grid, { layer }, facets, options);
|
const indexed_triangle_set kept = build_texture_displacement(grid, { layer }, facets, options);
|
||||||
@@ -742,7 +869,7 @@ TEST_CASE("TextureDisplacement: colour is reported per triangle and only where p
|
|||||||
return best;
|
return best;
|
||||||
};
|
};
|
||||||
|
|
||||||
const indexed_triangle_set out = build_texture_displacement(quad, { layer }, facets, {}, {}, &request);
|
const indexed_triangle_set out = build_texture_displacement(quad, { layer }, facets, classic_options(), {}, &request);
|
||||||
|
|
||||||
REQUIRE_FALSE(out.indices.empty());
|
REQUIRE_FALSE(out.indices.empty());
|
||||||
REQUIRE(triangle_color.size() == quad.indices.size());
|
REQUIRE(triangle_color.size() == quad.indices.size());
|
||||||
@@ -776,7 +903,7 @@ TEST_CASE("TextureDisplacement: a layer that is not colouring reports no colours
|
|||||||
request.out_triangle = &triangle_color;
|
request.out_triangle = &triangle_color;
|
||||||
request.quantize = [](const Vec3f &) { return 0; };
|
request.quantize = [](const Vec3f &) { return 0; };
|
||||||
|
|
||||||
build_texture_displacement(quad, { layer }, facets, {}, {}, &request);
|
build_texture_displacement(quad, { layer }, facets, classic_options(), {}, &request);
|
||||||
|
|
||||||
REQUIRE(triangle_color.size() == quad.indices.size());
|
REQUIRE(triangle_color.size() == quad.indices.size());
|
||||||
CHECK(triangle_color[0] == 0);
|
CHECK(triangle_color[0] == 0);
|
||||||
@@ -847,3 +974,624 @@ TEST_CASE("TextureDisplacement: subdivision refines a colour boundary a flat hei
|
|||||||
CHECK(out.indices.size() == cube.indices.size());
|
CHECK(out.indices.size() == cube.indices.size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Step cutter
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// A flat square sheet in the (x, y) plane split into right triangles of the given edge, with the
|
||||||
|
// diagonal alternated so the mesh has no preferred direction. Normals +z.
|
||||||
|
static indexed_triangle_set make_flat_sheet(float size, float edge)
|
||||||
|
{
|
||||||
|
indexed_triangle_set its;
|
||||||
|
const int n = std::max(1, int(std::lround(size / edge)));
|
||||||
|
for (int j = 0; j <= n; ++j)
|
||||||
|
for (int i = 0; i <= n; ++i)
|
||||||
|
its.vertices.emplace_back(size * float(i) / float(n), size * float(j) / float(n), 0.f);
|
||||||
|
const auto id = [n](int i, int j) { return j * (n + 1) + i; };
|
||||||
|
for (int j = 0; j < n; ++j)
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
if ((i + j) % 2 == 0) {
|
||||||
|
its.indices.emplace_back(id(i, j), id(i + 1, j), id(i + 1, j + 1));
|
||||||
|
its.indices.emplace_back(id(i, j), id(i + 1, j + 1), id(i, j + 1));
|
||||||
|
} else {
|
||||||
|
its.indices.emplace_back(id(i, j), id(i + 1, j), id(i, j + 1));
|
||||||
|
its.indices.emplace_back(id(i + 1, j), id(i + 1, j + 1), id(i, j + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return its;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every interior edge of a sheet is shared by exactly two triangles; only the sheet's own border may
|
||||||
|
// be used once.
|
||||||
|
static bool sheet_is_manifold(const indexed_triangle_set &its, float size)
|
||||||
|
{
|
||||||
|
std::map<std::pair<int, int>, int> uses;
|
||||||
|
for (const auto &t : its.indices)
|
||||||
|
for (int e = 0; e < 3; ++e) {
|
||||||
|
int a = t[e], b = t[(e + 1) % 3];
|
||||||
|
if (a > b)
|
||||||
|
std::swap(a, b);
|
||||||
|
++uses[{ a, b }];
|
||||||
|
}
|
||||||
|
for (const auto &[edge, n] : uses) {
|
||||||
|
const Vec3f &pa = its.vertices[size_t(edge.first)], &pb = its.vertices[size_t(edge.second)];
|
||||||
|
const bool border = (pa.x() == pb.x() && (pa.x() == 0.f || pa.x() == size)) ||
|
||||||
|
(pa.y() == pb.y() && (pa.y() == 0.f || pa.y() == size));
|
||||||
|
if (n > 2 || (n == 1 && !border))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: the step cutter turns a stepped field into walls", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// Square posts 1.2 mm wide on a 2 mm pitch, as a binary field: a step everywhere along the post
|
||||||
|
// edges, flat everywhere else. 1 mm triangles, so every post edge crosses several of them.
|
||||||
|
constexpr float RELIEF = 0.4f, SIZE = 6.f, STEP_W = 0.05f, GAP = 0.075f;
|
||||||
|
const auto posts = [](float x, float y) {
|
||||||
|
const float fx = std::fmod(std::fmod(x, 2.f) + 2.f, 2.f), fy = std::fmod(std::fmod(y, 2.f) + 2.f, 2.f);
|
||||||
|
return (fx > 0.4f && fx < 1.6f && fy > 0.4f && fy < 1.6f) ? RELIEF : 0.f;
|
||||||
|
};
|
||||||
|
const HeightFieldSampler sampler = [&](const Vec3f &p, const Vec3f &) { return posts(p.x(), p.y()); };
|
||||||
|
const indexed_triangle_set sheet = make_flat_sheet(SIZE, 1.f);
|
||||||
|
const std::vector<uint8_t> region(sheet.indices.size(), 1);
|
||||||
|
|
||||||
|
std::vector<int> source;
|
||||||
|
size_t cuts = 0;
|
||||||
|
const indexed_triangle_set cut = cut_mesh_at_steps(sheet, region, sampler, STEP_W, GAP, 0.f, &source, &cuts);
|
||||||
|
|
||||||
|
CHECK(cuts > 0);
|
||||||
|
CHECK(cut.indices.size() > sheet.indices.size());
|
||||||
|
REQUIRE(source.size() == cut.indices.size());
|
||||||
|
CHECK(sheet_is_manifold(cut, SIZE));
|
||||||
|
|
||||||
|
// Displace along the cut mesh's own area-weighted vertex normals, as the bake does.
|
||||||
|
std::vector<Vec3f> normal(cut.vertices.size(), Vec3f::Zero());
|
||||||
|
for (const auto &t : cut.indices) {
|
||||||
|
const Vec3f &a = cut.vertices[size_t(t[0])], &b = cut.vertices[size_t(t[1])], &c = cut.vertices[size_t(t[2])];
|
||||||
|
const Vec3f n = (b - a).cross(c - a);
|
||||||
|
CHECK(n.z() > 0.f); // nothing inverted, nothing degenerate
|
||||||
|
for (int k = 0; k < 3; ++k)
|
||||||
|
normal[size_t(t[k])] += n;
|
||||||
|
}
|
||||||
|
indexed_triangle_set displaced = cut;
|
||||||
|
for (size_t v = 0; v < displaced.vertices.size(); ++v) {
|
||||||
|
const Vec3f n = normal[v].normalized();
|
||||||
|
displaced.vertices[v] += n * sampler(cut.vertices[v], n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The defining property of the cut: a triangle that spans both heights is a wall, and a wall stands
|
||||||
|
// within the seam gap of a step. Anywhere else a mixed triangle would be a ramp - exactly what
|
||||||
|
// refinement leaves and the cutter is there to remove.
|
||||||
|
const auto near_step = [&](const Vec3f &p) {
|
||||||
|
const float h = posts(p.x(), p.y());
|
||||||
|
for (int k = 0; k < 8; ++k) {
|
||||||
|
const float ang = float(k) * float(M_PI) / 4.f;
|
||||||
|
if (posts(p.x() + GAP * std::cos(ang), p.y() + GAP * std::sin(ang)) != h)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
size_t walls = 0, ramps = 0;
|
||||||
|
for (const auto &t : displaced.indices) {
|
||||||
|
const float z0 = displaced.vertices[size_t(t[0])].z(), z1 = displaced.vertices[size_t(t[1])].z(),
|
||||||
|
z2 = displaced.vertices[size_t(t[2])].z();
|
||||||
|
if (std::abs(z0 - z1) < 1e-4f && std::abs(z1 - z2) < 1e-4f)
|
||||||
|
continue; // flat: on one level
|
||||||
|
bool wall = true;
|
||||||
|
for (int k = 0; k < 3; ++k)
|
||||||
|
wall = wall && near_step(cut.vertices[size_t(t[k])]);
|
||||||
|
(wall ? walls : ramps)++;
|
||||||
|
}
|
||||||
|
CHECK(walls > 0);
|
||||||
|
CHECK(ramps == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: the step cutter passes a smooth field through untouched", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// A wide bump: its mid-level contour runs through the sheet, but nowhere is it a step, so there is
|
||||||
|
// nothing to cut - refinement is the right tool for it.
|
||||||
|
const HeightFieldSampler bump = [](const Vec3f &p, const Vec3f &) {
|
||||||
|
const float r2 = (p.x() - 3.f) * (p.x() - 3.f) + (p.y() - 3.f) * (p.y() - 3.f);
|
||||||
|
return 0.4f * std::exp(-r2 / 3.f);
|
||||||
|
};
|
||||||
|
const indexed_triangle_set sheet = make_flat_sheet(6.f, 1.f);
|
||||||
|
const std::vector<uint8_t> region(sheet.indices.size(), 1);
|
||||||
|
|
||||||
|
std::vector<int> source;
|
||||||
|
size_t cuts = 0;
|
||||||
|
const indexed_triangle_set out = cut_mesh_at_steps(sheet, region, bump, 0.05f, 0.075f, 0.f, &source, &cuts);
|
||||||
|
|
||||||
|
CHECK(cuts == 0);
|
||||||
|
CHECK(out.indices.size() == sheet.indices.size());
|
||||||
|
CHECK(out.vertices.size() == sheet.vertices.size());
|
||||||
|
REQUIRE(source.size() == sheet.indices.size());
|
||||||
|
for (size_t i = 0; i < source.size(); ++i)
|
||||||
|
CHECK(source[i] == int(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: the step cutter leaves features at the step's own scale to refinement",
|
||||||
|
"[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// Square posts filling the middle half of each cell, binary and sharp at every crossing - the only
|
||||||
|
// difference between the two is the pitch (offset so no post edge lies along a mesh edge). Posts a
|
||||||
|
// step width or so across have no pure interior for a seam copy to land in, and cutting them would
|
||||||
|
// double the triangles for walls no bigger than the blur.
|
||||||
|
constexpr float STEP_W = 0.05f;
|
||||||
|
const float pitch = GENERATE(0.12f, 0.6f);
|
||||||
|
const auto posts = [pitch](const Vec3f &p, const Vec3f &) {
|
||||||
|
const float fx = std::fmod(p.x() + 0.17f, pitch) / pitch, fy = std::fmod(p.y() + 0.31f, pitch) / pitch;
|
||||||
|
return (fx > 0.25f && fx < 0.75f && fy > 0.25f && fy < 0.75f) ? 0.4f : 0.f;
|
||||||
|
};
|
||||||
|
const indexed_triangle_set sheet = make_flat_sheet(6.f, 1.f);
|
||||||
|
const std::vector<uint8_t> region(sheet.indices.size(), 1);
|
||||||
|
|
||||||
|
size_t cuts = 0;
|
||||||
|
const indexed_triangle_set out = cut_mesh_at_steps(sheet, region, posts, STEP_W, STEP_W, 0.f, nullptr, &cuts);
|
||||||
|
|
||||||
|
if (0.5f * pitch < 2.25f * STEP_W) {
|
||||||
|
CHECK(cuts == 0);
|
||||||
|
CHECK(out.indices.size() == sheet.indices.size());
|
||||||
|
} else {
|
||||||
|
CHECK(cuts > 0);
|
||||||
|
CHECK(sheet_is_manifold(out, 6.f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: the step cutter only cuts inside the region", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// A single step at x = 3 across the whole sheet, but only the left half is painted.
|
||||||
|
const HeightFieldSampler stripe = [](const Vec3f &p, const Vec3f &) { return p.x() > 3.f ? 0.4f : 0.f; };
|
||||||
|
const indexed_triangle_set sheet = make_flat_sheet(6.f, 1.f);
|
||||||
|
|
||||||
|
SECTION("an empty region is a no-op")
|
||||||
|
{
|
||||||
|
const std::vector<uint8_t> none(sheet.indices.size(), 0);
|
||||||
|
size_t cuts = 0;
|
||||||
|
const indexed_triangle_set out = cut_mesh_at_steps(sheet, none, stripe, 0.05f, 0.075f, 0.f, nullptr, &cuts);
|
||||||
|
CHECK(cuts == 0);
|
||||||
|
CHECK(out.indices.size() == sheet.indices.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("unpainted triangles away from the paint are untouched")
|
||||||
|
{
|
||||||
|
std::vector<uint8_t> region(sheet.indices.size(), 0);
|
||||||
|
for (size_t t = 0; t < sheet.indices.size(); ++t) {
|
||||||
|
const auto &f = sheet.indices[t];
|
||||||
|
const float cy = (sheet.vertices[size_t(f[0])].y() + sheet.vertices[size_t(f[1])].y() +
|
||||||
|
sheet.vertices[size_t(f[2])].y()) / 3.f;
|
||||||
|
region[t] = cy < 3.f ? 1 : 0; // paint the lower half
|
||||||
|
}
|
||||||
|
std::vector<int> source;
|
||||||
|
size_t cuts = 0;
|
||||||
|
const indexed_triangle_set out = cut_mesh_at_steps(sheet, region, stripe, 0.05f, 0.075f, 0.f, &source, &cuts);
|
||||||
|
CHECK(cuts > 0);
|
||||||
|
CHECK(sheet_is_manifold(out, 6.f));
|
||||||
|
// An unpainted triangle that shares no edge with a painted one comes out exactly as it went in.
|
||||||
|
std::vector<int> descendants(sheet.indices.size(), 0);
|
||||||
|
for (int s : source)
|
||||||
|
++descendants[size_t(s)];
|
||||||
|
for (size_t t = 0; t < sheet.indices.size(); ++t) {
|
||||||
|
const auto &f = sheet.indices[t];
|
||||||
|
float ymin = 6.f;
|
||||||
|
for (int k = 0; k < 3; ++k)
|
||||||
|
ymin = std::min(ymin, sheet.vertices[size_t(f[k])].y());
|
||||||
|
if (ymin > 3.f) // strictly above the painted half, so no shared edge with it
|
||||||
|
CHECK(descendants[t] == 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Texture smoothing
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: texture smoothing is a wrapped box blur whatever the radius", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// A small pseudo-random grey image, encoded through the PNG writer so decode_height_texture() takes
|
||||||
|
// its normal path. The expected result is the plain definition of the blur - two passes of a
|
||||||
|
// (2r+1) box, horizontal then vertical, wrapping at the edges - which the sliding-window
|
||||||
|
// implementation must reproduce byte for byte.
|
||||||
|
const size_t w = 37, h = 23;
|
||||||
|
std::vector<uint8_t> src(w * h);
|
||||||
|
uint32_t seed = 12345;
|
||||||
|
for (uint8_t &p : src) { seed = seed * 1664525u + 1013904223u; p = uint8_t(seed >> 24); }
|
||||||
|
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, src));
|
||||||
|
std::vector<unsigned char> bytes;
|
||||||
|
{
|
||||||
|
std::ifstream ifs(tmp_path.string(), std::ios::binary);
|
||||||
|
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
boost::system::error_code ec;
|
||||||
|
boost::filesystem::remove(tmp_path, ec);
|
||||||
|
REQUIRE_FALSE(bytes.empty());
|
||||||
|
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.image_data = std::make_shared<std::vector<unsigned char>>(std::move(bytes));
|
||||||
|
// radius = smoothing * 0.05 * min(w, h) = 0.05 * 23 * smoothing; 1.0 gives 1.15 -> whole radius 2,
|
||||||
|
// cross-faded 57.5 % toward the blurred image (see smooth_height_pixels()).
|
||||||
|
layer.smoothing = 1.f;
|
||||||
|
const DecodedHeightTexture tex = decode_height_texture(layer);
|
||||||
|
REQUIRE(tex.width == int(w));
|
||||||
|
REQUIRE(tex.height == int(h));
|
||||||
|
|
||||||
|
const int radius = 2;
|
||||||
|
const float mixf = std::clamp((0.05f * 23.f) / 2.f, 0.f, 1.f);
|
||||||
|
auto box = [&](std::vector<uint8_t> px) {
|
||||||
|
const int window = 2 * radius + 1;
|
||||||
|
const float inv = 1.f / float(window);
|
||||||
|
std::vector<uint8_t> t2(px.size());
|
||||||
|
for (int pass = 0; pass < 2; ++pass) {
|
||||||
|
for (int y = 0; y < int(h); ++y)
|
||||||
|
for (int x = 0; x < int(w); ++x) {
|
||||||
|
float s = 0.f;
|
||||||
|
for (int k = -radius; k <= radius; ++k) s += float(px[size_t(y) * w + size_t(((x + k) % int(w) + int(w)) % int(w))]);
|
||||||
|
t2[size_t(y) * w + size_t(x)] = uint8_t(std::lround(s * inv));
|
||||||
|
}
|
||||||
|
for (int x = 0; x < int(w); ++x)
|
||||||
|
for (int y = 0; y < int(h); ++y) {
|
||||||
|
float s = 0.f;
|
||||||
|
for (int k = -radius; k <= radius; ++k) s += float(t2[size_t(((y + k) % int(h) + int(h)) % int(h)) * w + size_t(x)]);
|
||||||
|
px[size_t(y) * w + size_t(x)] = uint8_t(std::lround(s * inv));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return px;
|
||||||
|
};
|
||||||
|
const std::vector<uint8_t> blurred = box(src);
|
||||||
|
size_t mismatches = 0;
|
||||||
|
for (size_t i = 0; i < w * h; ++i) {
|
||||||
|
const uint8_t expected = uint8_t(std::lround(float(src[i]) + (float(blurred[i]) - float(src[i])) * mixf));
|
||||||
|
mismatches += tex.pixels[i] != expected;
|
||||||
|
}
|
||||||
|
CHECK(mismatches == 0);
|
||||||
|
|
||||||
|
// Asking again with the same smoothing is served from the cache and must be identical.
|
||||||
|
const DecodedHeightTexture again = decode_height_texture(layer);
|
||||||
|
CHECK(again.pixels == tex.pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Edge flips along the height field (v2 pipeline)
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: edge flips lay a stepped field's wall along the grid's diagonals", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// A regular grid crossed by a step at 30 degrees. Before flipping, the step's wall zigzags: many
|
||||||
|
// triangles have corners on both sides of it. Flipping each quad's diagonal to follow the step must
|
||||||
|
// cut that count down, without changing the triangle count, the winding, or the manifoldness.
|
||||||
|
const indexed_triangle_set sheet = make_flat_sheet(6.f, 0.2f);
|
||||||
|
const float c = std::cos(0.5236f), s = std::sin(0.5236f);
|
||||||
|
const auto side_of = [&](const Vec3f &p) { return c * p.x() + s * p.y() > 3.5f; };
|
||||||
|
const TextureBake::HeightSampleFn field = [&](const Vec3f &p, const Vec3f &, const Vec3f &) {
|
||||||
|
return side_of(p) ? 0.4f : 0.f;
|
||||||
|
};
|
||||||
|
const auto mixed = [&](const TextureBake::TriSoup &g) {
|
||||||
|
size_t n = 0;
|
||||||
|
for (size_t t = 0; t < g.triangle_count(); ++t) {
|
||||||
|
const bool a = side_of(g.pos[t * 3]), b = side_of(g.pos[t * 3 + 1]), d = side_of(g.pos[t * 3 + 2]);
|
||||||
|
n += (a != b || b != d);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextureBake::TriSoup before = TextureBake::to_soup(sheet);
|
||||||
|
const TextureBake::FlipResult after = TextureBake::flip_edges_to_height(before, {}, field, TextureBake::FlipSettings{}, {});
|
||||||
|
|
||||||
|
CHECK(after.flipped > 0);
|
||||||
|
REQUIRE(after.geometry.triangle_count() == before.triangle_count());
|
||||||
|
const size_t mixed_before = mixed(before), mixed_after = mixed(after.geometry);
|
||||||
|
CHECK(mixed_after < mixed_before);
|
||||||
|
// Every triangle still faces up, and none collapsed.
|
||||||
|
for (size_t t = 0; t < after.geometry.triangle_count(); ++t) {
|
||||||
|
const Vec3f n = (after.geometry.pos[t * 3 + 1] - after.geometry.pos[t * 3]).cross(after.geometry.pos[t * 3 + 2] - after.geometry.pos[t * 3]);
|
||||||
|
CHECK(n.z() > 1e-6f);
|
||||||
|
}
|
||||||
|
// Manifold: rebuild an indexed mesh from the soup and count edge uses.
|
||||||
|
indexed_triangle_set rebuilt;
|
||||||
|
std::map<std::tuple<int, int, int>, int> ids;
|
||||||
|
for (const Vec3f &p : after.geometry.pos) {
|
||||||
|
const auto key = std::make_tuple(int(std::lround(p.x() * 1e4)), int(std::lround(p.y() * 1e4)), int(std::lround(p.z() * 1e4)));
|
||||||
|
auto it = ids.find(key);
|
||||||
|
if (it == ids.end()) { it = ids.emplace(key, int(rebuilt.vertices.size())).first; rebuilt.vertices.push_back(p); }
|
||||||
|
(void) it;
|
||||||
|
}
|
||||||
|
for (size_t t = 0; t < after.geometry.triangle_count(); ++t) {
|
||||||
|
int idx[3];
|
||||||
|
for (int k = 0; k < 3; ++k) {
|
||||||
|
const Vec3f &p = after.geometry.pos[t * 3 + size_t(k)];
|
||||||
|
idx[k] = ids.at(std::make_tuple(int(std::lround(p.x() * 1e4)), int(std::lround(p.y() * 1e4)), int(std::lround(p.z() * 1e4))));
|
||||||
|
}
|
||||||
|
rebuilt.indices.emplace_back(idx[0], idx[1], idx[2]);
|
||||||
|
}
|
||||||
|
CHECK(sheet_is_manifold(rebuilt, 6.f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Automatic resolution (v2 pipeline)
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: automatic resolution follows the texture's texel size and sharpness", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// A 20 mm cube (diagonal 34.6 mm, so the edge may go up to 0.69 mm) with an 8 mm tile.
|
||||||
|
const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f);
|
||||||
|
|
||||||
|
SECTION("a hard-edged texture gets one texel per edge")
|
||||||
|
{
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.image_data = make_checkerboard_png(16, 16); // 2x2 texel checks: every other texel is a step
|
||||||
|
layer.tiling_scale = 8.f; // texel = 0.5 mm
|
||||||
|
const TextureDetail detail = analyze_texture_detail(layer);
|
||||||
|
CHECK(detail.sharp_fraction > 0.15f);
|
||||||
|
CHECK_THAT(detail.pixels_per_edge, WithinAbs(1.f, 1e-6f));
|
||||||
|
const V2Resolution rec = recommend_v2_resolution(cube, { layer });
|
||||||
|
CHECK_THAT(rec.texel_mm, WithinAbs(0.5f, 1e-4f));
|
||||||
|
CHECK_THAT(rec.edge_mm, WithinAbs(0.5f, 1e-4f));
|
||||||
|
CHECK(rec.budget_k >= 10);
|
||||||
|
CHECK(rec.budget_k <= 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a flat texture gets four texels per edge, within the model's clamp")
|
||||||
|
{
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.image_data = make_flat_gray_png(128, 16, 16);
|
||||||
|
layer.tiling_scale = 8.f; // texel 0.5 mm x 4 = 2 mm, clamped to diagonal / 50
|
||||||
|
const TextureDetail detail = analyze_texture_detail(layer);
|
||||||
|
CHECK_THAT(detail.pixels_per_edge, WithinAbs(4.f, 1e-6f));
|
||||||
|
const V2Resolution rec = recommend_v2_resolution(cube, { layer });
|
||||||
|
CHECK_THAT(rec.edge_mm, WithinAbs(0.70f, 0.011f)); // ceil(34.64 / 50 = 0.693) at 0.01
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("the world transform scales the tile against the model")
|
||||||
|
{
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.image_data = make_checkerboard_png(16, 16);
|
||||||
|
layer.tiling_scale = 8.f;
|
||||||
|
// Scaled up 3x the cube is 60 mm; the texel is still 0.5 mm in world terms, so the edge holds.
|
||||||
|
const V2Resolution rec = recommend_v2_resolution(cube, { layer }, Transform3d(Eigen::Scaling(3.0)));
|
||||||
|
CHECK_THAT(rec.edge_mm, WithinAbs(0.5f, 1e-4f));
|
||||||
|
const V2Resolution plain = recommend_v2_resolution(cube, { layer });
|
||||||
|
CHECK(rec.budget_k > plain.budget_k); // nine times the area wants more triangles
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("no usable texture gives no recommendation")
|
||||||
|
{
|
||||||
|
TextureDisplacementLayer empty;
|
||||||
|
const V2Resolution rec = recommend_v2_resolution(cube, { empty });
|
||||||
|
CHECK(rec.edge_mm == 0.f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: the default pipeline colours its rebuilt triangles where painted", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// The same quad and 2x2 colour texture as the classic-path colour test, but baked through the
|
||||||
|
// one-run pipeline, which rebuilds the topology: every output triangle has to be coloured from the
|
||||||
|
// texture at its own position, and only those over the painted half of the quad.
|
||||||
|
const indexed_triangle_set quad = color_test_quad();
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.image_data = make_rgb_png_2x2();
|
||||||
|
layer.color_enabled = true;
|
||||||
|
layer.depth_mm = 0.f;
|
||||||
|
layer.tiling_scale = 10.f; // one tile over the whole quad, so all four texels show
|
||||||
|
layer.projection_method = TextureProjectionMethod::Triplanar;
|
||||||
|
|
||||||
|
TriangleMesh mesh(quad);
|
||||||
|
TriangleSelector selector(mesh);
|
||||||
|
selector.set_facet(0, EnforcerBlockerType::ENFORCER); // the lower-right half only
|
||||||
|
TextureDisplacementFacetsData facets;
|
||||||
|
facets[0] = selector.serialize();
|
||||||
|
|
||||||
|
const std::array<Vec3f, 3> palette = { Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(0, 0, 1) };
|
||||||
|
TextureColorRequest request;
|
||||||
|
std::vector<uint8_t> triangle_color;
|
||||||
|
request.out_triangle = &triangle_color;
|
||||||
|
request.quantize = [&palette](const Vec3f &rgb) {
|
||||||
|
int best = 0;
|
||||||
|
float bd = std::numeric_limits<float>::max();
|
||||||
|
for (int i = 0; i < 3; ++i)
|
||||||
|
if (const float d = (palette[size_t(i)] - rgb).squaredNorm(); d < bd) { bd = d; best = i; }
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
TextureDisplacementOptions options;
|
||||||
|
options.pipeline_v2 = true;
|
||||||
|
options.v2_refine_mm = 1.f;
|
||||||
|
options.v2_max_triangles_k = 0; // no simplification: a plain refined quad
|
||||||
|
const indexed_triangle_set out = build_texture_displacement(quad, { layer }, facets, options, {}, &request);
|
||||||
|
|
||||||
|
REQUIRE(out.indices.size() > 2);
|
||||||
|
REQUIRE(triangle_color.size() == out.indices.size());
|
||||||
|
size_t coloured = 0, plain = 0;
|
||||||
|
std::set<uint8_t> distinct;
|
||||||
|
for (size_t i = 0; i < out.indices.size(); ++i) {
|
||||||
|
const stl_triangle_vertex_indices &t = out.indices[i];
|
||||||
|
const Vec3f c = (out.vertices[size_t(t[0])] + out.vertices[size_t(t[1])] + out.vertices[size_t(t[2])]) / 3.f;
|
||||||
|
const bool painted_side = c.y() < c.x(); // below the diagonal: triangle 0 of the quad
|
||||||
|
if (triangle_color[i] > 0) { ++coloured; distinct.insert(triangle_color[i]); CHECK(painted_side); }
|
||||||
|
else { ++plain; CHECK_FALSE(painted_side); }
|
||||||
|
}
|
||||||
|
CHECK(coloured > 0);
|
||||||
|
CHECK(plain > 0);
|
||||||
|
CHECK(distinct.size() >= 2); // the texture has four colours across the quad, so one side sees several
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per chart of an unwrap: whether it is a topological disk (V - E + F = 1, one boundary loop), the only shape a
|
||||||
|
// single island can be laid flat from.
|
||||||
|
static std::vector<bool> unwrap_charts_are_disks(const PatchUnwrap &u)
|
||||||
|
{
|
||||||
|
std::vector<std::set<int>> verts(size_t(u.chart_count));
|
||||||
|
std::vector<std::set<std::pair<int, int>>> edges(size_t(u.chart_count));
|
||||||
|
std::vector<int> faces(size_t(u.chart_count), 0);
|
||||||
|
for (const stl_triangle_vertex_indices &tri : u.indices) {
|
||||||
|
const int c = u.vertex_chart[size_t(tri[0])];
|
||||||
|
++faces[size_t(c)];
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
verts[size_t(c)].insert(tri[i]);
|
||||||
|
edges[size_t(c)].insert({ std::min(tri[i], tri[(i + 1) % 3]), std::max(tri[i], tri[(i + 1) % 3]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Boundary loops per chart: flood the boundary edges' vertices.
|
||||||
|
std::vector<std::vector<int>> boundary_adj(u.uvs.size());
|
||||||
|
for (const auto &[a, b] : u.boundary_edges) {
|
||||||
|
boundary_adj[size_t(a)].push_back(b);
|
||||||
|
boundary_adj[size_t(b)].push_back(a);
|
||||||
|
}
|
||||||
|
std::vector<int> loops(size_t(u.chart_count), 0);
|
||||||
|
std::vector<bool> seen(u.uvs.size(), false);
|
||||||
|
for (size_t v = 0; v < u.uvs.size(); ++v) {
|
||||||
|
if (seen[v] || boundary_adj[v].empty())
|
||||||
|
continue;
|
||||||
|
++loops[size_t(u.vertex_chart[v])];
|
||||||
|
std::vector<int> stack{ int(v) };
|
||||||
|
seen[v] = true;
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const int x = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
for (const int y : boundary_adj[size_t(x)])
|
||||||
|
if (!seen[size_t(y)]) {
|
||||||
|
seen[size_t(y)] = true;
|
||||||
|
stack.push_back(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<bool> disks(size_t(u.chart_count));
|
||||||
|
for (int c = 0; c < u.chart_count; ++c)
|
||||||
|
disks[size_t(c)] = int(verts[size_t(c)].size()) - int(edges[size_t(c)].size()) + faces[size_t(c)] == 1 &&
|
||||||
|
loops[size_t(c)] == 1;
|
||||||
|
return disks;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A cube unwraps into one island per face, laid out as a connected net", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
|
||||||
|
const PatchUnwrap unwrap = compute_patch_unwrap(cube, 30.f, 0.f);
|
||||||
|
// Two triangles per face, and every face edge but the diagonals is a 90 degree crease.
|
||||||
|
REQUIRE(unwrap.chart_count == 6);
|
||||||
|
|
||||||
|
const std::vector<TextureIsland> islands = compute_connected_net(unwrap);
|
||||||
|
REQUIRE(islands.size() == 6);
|
||||||
|
|
||||||
|
std::vector<std::array<Vec2f, 3>> placed;
|
||||||
|
std::vector<int> placed_chart;
|
||||||
|
for (const stl_triangle_vertex_indices &tri : unwrap.indices) {
|
||||||
|
const int c = unwrap.vertex_chart[size_t(tri[0])];
|
||||||
|
std::array<Vec2f, 3> t;
|
||||||
|
for (int k = 0; k < 3; ++k)
|
||||||
|
t[size_t(k)] = apply_island_transform(unwrap.uvs[size_t(tri[k])], c, unwrap, islands);
|
||||||
|
placed.push_back(t);
|
||||||
|
placed_chart.push_back(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("no two faces overlap")
|
||||||
|
{
|
||||||
|
// A 10 mm cube: the six 100 mm2 faces laid flat without overlap cover 600 mm2, which triangles overlapping
|
||||||
|
// anywhere could not add up to within their joint bounding box... so test it directly, by sampling.
|
||||||
|
float lo_x = 1e9f, lo_y = 1e9f, hi_x = -1e9f, hi_y = -1e9f;
|
||||||
|
for (const auto &t : placed)
|
||||||
|
for (const Vec2f &p : t) {
|
||||||
|
lo_x = std::min(lo_x, p.x());
|
||||||
|
lo_y = std::min(lo_y, p.y());
|
||||||
|
hi_x = std::max(hi_x, p.x());
|
||||||
|
hi_y = std::max(hi_y, p.y());
|
||||||
|
}
|
||||||
|
const auto inside = [](const std::array<Vec2f, 3> &t, const Vec2f &p) {
|
||||||
|
const auto cross = [](const Vec2f &a, const Vec2f &b) { return a.x() * b.y() - a.y() * b.x(); };
|
||||||
|
const float d0 = cross(t[1] - t[0], p - t[0]), d1 = cross(t[2] - t[1], p - t[1]), d2 = cross(t[0] - t[2], p - t[2]);
|
||||||
|
return (d0 > 1e-3f && d1 > 1e-3f && d2 > 1e-3f) || (d0 < -1e-3f && d1 < -1e-3f && d2 < -1e-3f);
|
||||||
|
};
|
||||||
|
int overlapping = 0;
|
||||||
|
for (float x = lo_x + 0.13f; x < hi_x; x += 0.5f)
|
||||||
|
for (float y = lo_y + 0.17f; y < hi_y; y += 0.5f) {
|
||||||
|
int covering = 0;
|
||||||
|
for (const auto &t : placed)
|
||||||
|
covering += inside(t, Vec2f(x, y)) ? 1 : 0;
|
||||||
|
overlapping += covering > 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
CHECK(overlapping == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("every face shares an edge with another face of the net")
|
||||||
|
{
|
||||||
|
std::vector<bool> joined(6, false);
|
||||||
|
for (size_t i = 0; i < placed.size(); ++i)
|
||||||
|
for (size_t j = 0; j < placed.size(); ++j) {
|
||||||
|
if (placed_chart[i] == placed_chart[j])
|
||||||
|
continue;
|
||||||
|
int shared_corners = 0;
|
||||||
|
for (const Vec2f &p : placed[i])
|
||||||
|
for (const Vec2f &q : placed[j])
|
||||||
|
shared_corners += (p - q).norm() < 1e-3f ? 1 : 0;
|
||||||
|
if (shared_corners >= 2)
|
||||||
|
joined[size_t(placed_chart[i])] = true;
|
||||||
|
}
|
||||||
|
for (int c = 0; c < 6; ++c)
|
||||||
|
CHECK(joined[size_t(c)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Unwrapping a closed cylinder cuts its side until every island is a disk", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// The side is smooth, so the seam angle alone leaves it as one ring-shaped chart, which cannot be laid flat.
|
||||||
|
const indexed_triangle_set cylinder = its_make_cylinder(5., 20., 2. * PI / 36.);
|
||||||
|
const PatchUnwrap unwrap = compute_patch_unwrap(cylinder, 30.f, 0.f);
|
||||||
|
REQUIRE(unwrap.chart_count >= 4); // two caps and at least two side pieces
|
||||||
|
|
||||||
|
const std::vector<bool> disks = unwrap_charts_are_disks(unwrap);
|
||||||
|
for (int c = 0; c < unwrap.chart_count; ++c)
|
||||||
|
CHECK(disks[size_t(c)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A UV edit on one copy of a seam vertex leaves its other copies alone", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
|
||||||
|
PatchUnwrap unwrap = compute_patch_unwrap(cube, 30.f, 0.f);
|
||||||
|
|
||||||
|
// A cube corner has a copy in each of the three faces around it.
|
||||||
|
const int edited_copy = 0;
|
||||||
|
const int mesh_vertex = unwrap.source_vertex[size_t(edited_copy)];
|
||||||
|
std::vector<int> other_copies;
|
||||||
|
for (size_t i = 0; i < unwrap.uvs.size(); ++i)
|
||||||
|
if (int(i) != edited_copy && unwrap.source_vertex[i] == mesh_vertex)
|
||||||
|
other_copies.push_back(int(i));
|
||||||
|
REQUIRE_FALSE(other_copies.empty());
|
||||||
|
|
||||||
|
const std::vector<Vec2f> before = unwrap.uvs;
|
||||||
|
const Vec2f target = before[size_t(edited_copy)] + Vec2f(3.f, -2.f);
|
||||||
|
const std::vector<bool> edited = apply_lscm_uv_overrides(unwrap, { { lscm_uv_override_key(edited_copy), target } });
|
||||||
|
|
||||||
|
CHECK_THAT(unwrap.uvs[size_t(edited_copy)].x(), WithinAbs(target.x(), 1e-6));
|
||||||
|
CHECK_THAT(unwrap.uvs[size_t(edited_copy)].y(), WithinAbs(target.y(), 1e-6));
|
||||||
|
CHECK(edited[size_t(edited_copy)]);
|
||||||
|
for (const int i : other_copies) {
|
||||||
|
CHECK_THAT(unwrap.uvs[size_t(i)].x(), WithinAbs(before[size_t(i)].x(), 1e-6));
|
||||||
|
CHECK_THAT(unwrap.uvs[size_t(i)].y(), WithinAbs(before[size_t(i)].y(), 1e-6));
|
||||||
|
CHECK_FALSE(edited[size_t(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("TextureDisplacement: moving the model about the plate does not move the texture on it", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// The bake projects in world millimetres but anchored at the volume's origin, so an instance
|
||||||
|
// translated across the plate bakes exactly the relief the same instance bakes at the origin. The
|
||||||
|
// classic path keeps the topology, so the comparison is vertex by vertex.
|
||||||
|
const indexed_triangle_set cube = subdivide_mesh_uniform(its_make_cube(10.f, 10.f, 10.f), 1.f, 6);
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.image_data = make_checkerboard_png(16, 16);
|
||||||
|
layer.tiling_scale = 4.f;
|
||||||
|
layer.depth_mm = 0.5f;
|
||||||
|
TextureDisplacementFacetsData facets;
|
||||||
|
facets[0] = paint_whole_mesh(cube);
|
||||||
|
|
||||||
|
const indexed_triangle_set at_origin = build_texture_displacement(cube, { layer }, facets, classic_options());
|
||||||
|
const indexed_triangle_set moved = build_texture_displacement(cube, { layer }, facets, classic_options(), {}, nullptr,
|
||||||
|
Transform3d(Eigen::Translation3d(123.4, -56.7, 0.0)));
|
||||||
|
REQUIRE(moved.vertices.size() == at_origin.vertices.size());
|
||||||
|
float max_diff = 0.f;
|
||||||
|
for (size_t i = 0; i < moved.vertices.size(); ++i)
|
||||||
|
max_diff = std::max(max_diff, (moved.vertices[i] - at_origin.vertices[i]).norm());
|
||||||
|
CHECK(max_diff < 1e-3f);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user