Compare commits

..
Author SHA1 Message Date
Hanif Koh 7c71ac0870 Clamp Ironing Line Spacing to a Usable Minimum
An ironing line spacing of 0 reached the fillers from a 3MF, the CLI or
the per-filament override, which has no GUI guard. Concentric ironing
then never finished slicing, because a zero inset never shrinks the
region, and rectilinear ironing was silently dropped. Tiny positive
values produced an unprintable number of lines.

Top surface and support ironing now clamp the spacing to the 0.05 mm
floor the process GUI guard already enforces, so these configurations
iron at that spacing. Spacings at or above the floor, including every
shipped profile, are unchanged. The concentric filler also returns early
on a non-positive step so no other caller can hang it, and the filament
settings page now resets a too-small override the same way the process
page does.
2026-09-28 13:00:25 +08:00
15 changed files with 114 additions and 239 deletions
+2 -2
View File
@@ -1692,9 +1692,9 @@ void Layer::make_ironing()
ironing_params.just_infill = false;
// ORCA: Get filament-specific overrides if configured, otherwise use process values
size_t extruder_idx = ironing_params.extruder - 1;
ironing_params.line_spacing = (!config.filament_ironing_spacing.is_nil(extruder_idx)
ironing_params.line_spacing = std::max(IRONING_SPACING_MIN, !config.filament_ironing_spacing.is_nil(extruder_idx)
? config.filament_ironing_spacing.get_at(extruder_idx)
: config.ironing_spacing);
: config.ironing_spacing.value);
ironing_params.inset = (!config.filament_ironing_inset.is_nil(extruder_idx)
? config.filament_ironing_inset.get_at(extruder_idx)
: config.ironing_inset);
+5
View File
@@ -22,6 +22,9 @@ void FillConcentric::_fill_surface_single(
coord_t min_spacing = scale_(this->spacing) * params.multiline;
coord_t distance = coord_t(min_spacing / params.density);
// A non-positive step never shrinks the region, so the inset loop below would not end.
if (min_spacing <= 0 || distance <= 0)
return;
if (params.density > 0.9999f && !params.dont_adjust) {
distance = this->_adjust_solid_spacing(bounding_box.size()(0), distance);
@@ -108,6 +111,8 @@ void FillConcentric::_fill_surface_single(const FillParams& params,
// no rotation is supported for this infill pattern
Point bbox_size = expolygon.contour.bounding_box().size();
coord_t min_spacing = scaled<coord_t>(this->spacing);
if (min_spacing <= 0)
return;
if (params.density > 0.9999f && !params.dont_adjust) {
coord_t loops_count = std::max(bbox_size.x(), bbox_size.y()) / min_spacing + 1;
+8 -21
View File
@@ -48,37 +48,24 @@ bool load_drc(const char *path, TriangleMesh *meshptr)
indexed_triangle_set its;
const PointAttribute *const positions = dracoMesh.GetNamedAttribute(GeometryAttribute::POSITION);
if (positions == nullptr) {
BOOST_LOG_TRIVIAL(error) << "load_drc: the mesh has no POSITION attribute";
return false;
}
size_t num_vertices = positions->size();
its.vertices.reserve(num_vertices);
for (AttributeValueIndex i(0); i < num_vertices; ++ i) {
float pos[3];
if (!positions->ConvertValue<float>(i, 3, pos)) {
BOOST_LOG_TRIVIAL(error) << "load_drc: invalid vertex position";
return false;
}
positions->ConvertValue<float>(i, 3, pos);
its.vertices.emplace_back(pos[0], pos[1], pos[2]);
}
// The Draco decoder does not check face indices against the point count.
const uint32_t num_points = dracoMesh.num_points();
size_t num_faces = dracoMesh.num_faces();
its.indices.reserve(num_faces);
for (FaceIndex i(0); i < num_faces; ++ i) {
const Mesh::Face &face = dracoMesh.face(i);
stl_triangle_vertex_indices facet;
for (int k = 0; k < 3; ++ k) {
const size_t vertex_idx = face[k].value() < num_points ? positions->mapped_index(face[k]).value() : num_vertices;
if (vertex_idx >= num_vertices) {
BOOST_LOG_TRIVIAL(error) << "load_drc: invalid vertex index";
return false;
}
facet[k] = static_cast<int>(vertex_idx);
}
its.indices.emplace_back(facet);
Mesh::Face face = dracoMesh.face(i);
its.indices.emplace_back(
positions->mapped_index(face[0]).value(),
positions->mapped_index(face[1]).value(),
positions->mapped_index(face[2]).value()
);
}
*meshptr = TriangleMesh(std::move(its));
+4 -9
View File
@@ -166,15 +166,10 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s
obj_info.uv_map_pngs[face_index] = png_name;
}
if (data.textureCoordinates.size() > 0) {
// A face vertex may omit vt or reference a missing one. Fall back to (0, 0) rather than
// skipping the face, so obj_info.uvs stays aligned with the face indices.
const int uv_count = static_cast<int>(data.textureCoordinates.size() / OBJ_TEXCOORD_LENGTH);
auto uv_at = [&data, uv_count](int idx) -> Vec2f {
if (idx < 0 || idx >= uv_count)
return Vec2f::Zero();
return Vec2f(data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH], data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH + 1]);
};
std::array<Vec2f, 3> uv_array{uv_at(uvs[0]), uv_at(uvs[1]), uv_at(uvs[2])};
Vec2f uv0(data.textureCoordinates[uvs[0] * 2], data.textureCoordinates[uvs[0] * 2 + 1]);
Vec2f uv1(data.textureCoordinates[uvs[1] * 2], data.textureCoordinates[uvs[1] * 2 + 1]);
Vec2f uv2(data.textureCoordinates[uvs[2] * 2], data.textureCoordinates[uvs[2] * 2 + 1]);
std::array<Vec2f, 3> uv_array{uv0, uv1, uv2};
obj_info.uvs.emplace_back(uv_array);
}
obj_info.face_colors.emplace_back(face_color);
+5 -4
View File
@@ -51,18 +51,19 @@ static bool obj_parseline(const char *line, ObjData &data)
line = endptr;
EATWS();
}
// The optional w is accepted but not stored: only u and v are used.
/*double w = 0;
if (*line != 0) {
strtod(line, &endptr);
w = strtod(line, &endptr);
if (endptr == 0 || (*endptr != ' ' && *endptr != '\t' && *endptr != 0))
return false;
line = endptr;
EATWS();
}
}*/
if (*line != 0)
return false;
data.textureCoordinates.push_back((float)u);
data.textureCoordinates.push_back((float)v);
//data.textureCoordinates.push_back((float)w);
break;
}
case 'n':
@@ -244,7 +245,7 @@ static bool obj_parseline(const char *line, ObjData &data)
else
-- vertex.normalIdx;
if (vertex.textureCoordIdx < 0)
vertex.textureCoordIdx += (int)data.textureCoordinates.size() / OBJ_TEXCOORD_LENGTH;
vertex.textureCoordIdx += (int)data.textureCoordinates.size() / 3;
else
-- vertex.textureCoordIdx;
data.vertices.push_back(vertex);
+1 -2
View File
@@ -92,7 +92,6 @@ inline bool operator==(const ObjSmoothingGroup &v1, const ObjSmoothingGroup &v2)
}
#define OBJ_VERTEX_COLOR_ALPHA 6
#define OBJ_VERTEX_LENGTH 7 // x, y, z, color_x,color_y,color_z,color_w
#define OBJ_TEXCOORD_LENGTH 2 // u, v
#define ONE_FACE_SIZE 4//ONE_FACE format: f 8/4/6 7/3/6 6/2/6 -1/-1/-1
struct ObjData {
// Version of the data structure for load / store in the private binary format.
@@ -101,7 +100,7 @@ struct ObjData {
// x, y, z, color_x,color_y,color_z,color_w
std::vector<float> coordinates;
bool has_vertex_color{false};
// u, v
// u, v, w
std::vector<float> textureCoordinates;
// x, y, z
std::vector<float> normals;
+4
View File
@@ -178,6 +178,10 @@ enum class IroningType {
Count,
};
// Smallest usable ironing line spacing. Anything tighter yields an unprintable number of lines,
// and zero stops the fillers from making progress.
constexpr double IRONING_SPACING_MIN = 0.05;
//BBS
enum class WallInfillOrder {
InnerOuterInfill,
+1 -1
View File
@@ -64,7 +64,7 @@ struct SupportParameters {
this->ironing = object_config.support_ironing;
this->ironing_flow = support_material_interface_flow.with_height(support_material_interface_flow.height() * 0.01 * object_config.support_ironing_flow.value);
this->ironing_spacing = object_config.support_ironing_spacing;
this->ironing_spacing = std::max(IRONING_SPACING_MIN, object_config.support_ironing_spacing.value);
this->ironing_pattern = object_config.support_ironing_pattern;
// Calculate a minimum support layer height as a minimum over all extruders, but not smaller than 10um.
+26 -2
View File
@@ -210,6 +210,30 @@ void ConfigManipulation::check_filament_max_volumetric_speed(DynamicPrintConfig
}
void ConfigManipulation::check_filament_ironing_spacing(DynamicPrintConfig *config)
{
const auto *opt = config->option<ConfigOptionFloatsNullable>("filament_ironing_spacing");
if (opt == nullptr)
return;
std::vector<double> values = opt->values;
bool reset = false;
for (size_t i = 0; i < values.size(); ++i)
if (!opt->is_nil(i) && values[i] < IRONING_SPACING_MIN) {
values[i] = 0.1;
reset = true;
}
if (!reset)
return;
const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1"));
MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK);
DynamicPrintConfig new_conf = *config;
is_msg_dlg_already_exist = true;
dialog.ShowModal();
new_conf.set_key_value("filament_ironing_spacing", new ConfigOptionFloatsNullable(values));
apply(config, &new_conf);
is_msg_dlg_already_exist = false;
}
void ConfigManipulation::check_chamber_temperature(DynamicPrintConfig* config)
{
bool support_chamber_temp_control=GUI::wxGetApp().preset_bundle->printers.get_selected_preset().config.opt_bool("support_chamber_temp_control");
@@ -332,7 +356,7 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con
}
//BBS: ironing_spacing shouldn't be too small or equal to zero
if (config->opt_float("ironing_spacing") < 0.05)
if (config->opt_float("ironing_spacing") < IRONING_SPACING_MIN)
{
const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1"));
MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK);
@@ -343,7 +367,7 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con
apply(config, &new_conf);
is_msg_dlg_already_exist = false;
}
if (config->opt_float("support_ironing_spacing") < 0.05)
if (config->opt_float("support_ironing_spacing") < IRONING_SPACING_MIN)
{
const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1"));
MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK);
+1
View File
@@ -84,6 +84,7 @@ public:
void check_nozzle_temperature_initial_layer_range(DynamicPrintConfig* config);
void check_adaptive_pressure_advance_model(DynamicPrintConfig* config);
void check_filament_max_volumetric_speed(DynamicPrintConfig *config);
void check_filament_ironing_spacing(DynamicPrintConfig *config);
void check_chamber_temperature(DynamicPrintConfig* config);
void check_chamber_minimal_temperature(DynamicPrintConfig* config);
bool check_layer_height(DynamicPrintConfig* config);
+1
View File
@@ -4905,6 +4905,7 @@ void TabFilament::update()
return; // ys_FIXME
m_config_manipulation.check_filament_max_volumetric_speed(m_config);
m_config_manipulation.check_filament_ironing_spacing(m_config);
m_update_cnt++;
+55
View File
@@ -747,6 +747,61 @@ TEST_CASE("A region with ironing turned off is never ironed", "[Fill]")
REQUIRE(Layer::choose_ironing_extruder(cfg, spiral_mode, /*is_topmost_layer=*/true) == -1);
}
// Ironing path count and total length in mm, over the whole object.
static std::pair<size_t, double> ironing_extent(const Print &print)
{
size_t paths = 0;
double length = 0.;
for (const Layer *layer : print.objects().front()->layers())
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (ironing_role(entity->role())) {
++paths;
length += unscale<double>(entity->length());
}
return {paths, length};
}
TEST_CASE("Ironing spacing below the minimum irons at the minimum spacing", "[Fill]")
{
const std::string pattern = GENERATE("rectilinear", "concentric");
const bool via_filament = GENERATE(false, true);
const double spacing = GENERATE(0., 0.001);
CAPTURE(pattern, via_filament, spacing);
auto ironing_for = [&pattern, via_filament](double spacing) {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"ironing_type", "top"},
{"ironing_pattern", pattern},
{"layer_height", 0.2}});
// The filament override replaces the process spacing, which stays at a usable value.
if (via_filament)
config.set_deserialize_strict({{"ironing_spacing", 0.1}, {"filament_ironing_spacing", spacing}});
else
config.set_deserialize_strict({{"ironing_spacing", spacing}});
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, config);
return ironing_extent(print);
};
const std::pair<size_t, double> clamped = ironing_for(spacing);
const std::pair<size_t, double> minimum = ironing_for(IRONING_SPACING_MIN);
REQUIRE(minimum.first > 0);
CHECK(clamped.first == minimum.first);
CHECK_THAT(clamped.second, Catch::Matchers::WithinRel(minimum.second, 1e-9));
}
TEST_CASE("Concentric fill at zero spacing returns without paths", "[Fill]")
{
std::unique_ptr<Fill> filler(Fill::new_from_type(ipConcentric));
filler->spacing = 0.;
filler->bounding_box = BoundingBox(Point(0, 0), Point::new_scale(10, 10));
FillParams params;
params.density = 1.f;
Surface surface(stTop, ExPolygon({Point(0, 0), Point::new_scale(10, 0), Point::new_scale(10, 10), Point::new_scale(0, 10)}));
CHECK(filler->fill_surface(&surface, params).empty());
}
TEST_CASE("Solid infill direction offsets every layer when no template is set", "[Fill]")
{
auto angles_for = [](int direction) {
+1 -5
View File
@@ -14,7 +14,6 @@ add_executable(${_TEST_NAME}_tests
test_clipper_utils.cpp
test_config.cpp
test_config_variant_expansion.cpp
test_drc.cpp
test_toolordering_nozzle_group.cpp
test_preset_bundle_loading.cpp
test_preset_setting_id.cpp
@@ -33,7 +32,6 @@ add_executable(${_TEST_NAME}_tests
test_mutable_priority_queue.cpp
test_minimum_spanning_tree.cpp
test_nozzle_volume_type.cpp
test_obj.cpp
test_step.cpp
test_stl.cpp
test_triangle_selector.cpp
@@ -69,9 +67,7 @@ if (TARGET OpenVDB::openvdb)
target_sources(${_TEST_NAME}_tests PRIVATE test_hollowing.cpp)
endif()
# libslic3r links Draco privately; test_drc.cpp encodes its own fixtures.
find_package(Draco REQUIRED)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r draco::draco Catch2::Catch2WithMain)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
-71
View File
@@ -1,71 +0,0 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Format/DRC.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <boost/nowide/fstream.hpp>
#include <draco/compression/encode.h>
#include <draco/mesh/mesh.h>
#include "test_utils.hpp"
using namespace Slic3r;
namespace {
// Encodes one triangle whose vertices carry a single attribute of the given type.
// The decoder accepts both a mesh without POSITION and a face index past the point count.
void write_drc_triangle(const std::string &path, draco::GeometryAttribute::Type attribute_type, uint32_t second_point)
{
draco::Mesh mesh;
mesh.set_num_points(3);
draco::GeometryAttribute attribute;
attribute.Init(attribute_type, nullptr, 3, draco::DT_FLOAT32, false, sizeof(float) * 3, 0);
const int attribute_id = mesh.AddAttribute(attribute, true, 3);
const float points[3][3] = {{0, 0, 0}, {1, 0, 0}, {0, 1, 0}};
for (int i = 0; i < 3; ++i)
mesh.attribute(attribute_id)->SetAttributeValue(draco::AttributeValueIndex(i), points[i]);
draco::Mesh::Face face;
face[0] = draco::PointIndex(0);
face[1] = draco::PointIndex(second_point);
face[2] = draco::PointIndex(2);
mesh.AddFace(face);
draco::Encoder encoder;
encoder.SetEncodingMethod(draco::MESH_SEQUENTIAL_ENCODING);
draco::EncoderBuffer buffer;
REQUIRE(encoder.EncodeMeshToBuffer(mesh, &buffer).ok());
boost::nowide::ofstream out(path, std::ios::binary);
out.write(buffer.data(), static_cast<std::streamsize>(buffer.size()));
}
} // namespace
TEST_CASE("A Draco triangle with positions loads", "[DRC]")
{
ScopedTemporaryFile drc(".drc");
write_drc_triangle(drc.string(), draco::GeometryAttribute::POSITION, 1);
TriangleMesh mesh;
REQUIRE(load_drc(drc.string().c_str(), &mesh));
CHECK(mesh.facets_count() == 1);
}
TEST_CASE("A Draco mesh without a POSITION attribute fails to load", "[DRC][Regression]")
{
ScopedTemporaryFile drc(".drc");
write_drc_triangle(drc.string(), draco::GeometryAttribute::GENERIC, 1);
TriangleMesh mesh;
CHECK_FALSE(load_drc(drc.string().c_str(), &mesh));
}
TEST_CASE("A Draco face referencing a missing point fails to load", "[DRC][Regression]")
{
ScopedTemporaryFile drc(".drc");
write_drc_triangle(drc.string(), draco::GeometryAttribute::POSITION, 200);
TriangleMesh mesh;
CHECK_FALSE(load_drc(drc.string().c_str(), &mesh));
}
-122
View File
@@ -1,122 +0,0 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Format/OBJ.hpp"
#include <boost/nowide/fstream.hpp>
#include "test_utils.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
struct LoadedObj
{
bool ok{false};
TriangleMesh mesh;
ObjInfo info;
};
// A tetrahedron with a material and two texture coordinates, (0.25, 0.5) and (0.75, 1).
// Only the first face and the vt lines are varied; the other three faces reference vt 1.
LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::string &vts = "vt 0.25 0.5\nvt 0.75 1\n")
{
ScopedTemporaryFile obj(".obj");
ScopedTemporaryFile mtl(".mtl");
{
boost::nowide::ofstream out(mtl.string());
out << "newmtl a\nKd 1 0 0\n";
}
{
boost::nowide::ofstream out(obj.string());
out << "mtllib " << mtl.path().filename().string() << "\n"
<< "v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n"
<< vts
<< "usemtl a\n"
<< first_face << "\n"
<< "f 1/1 2/1 4/1\nf 1/1 4/1 3/1\nf 2/1 3/1 4/1\n";
}
LoadedObj loaded;
std::string message;
loaded.ok = load_obj(obj.string().c_str(), &loaded.mesh, loaded.info, message);
return loaded;
}
} // namespace
TEST_CASE("An out-of-range texture index falls back to a zero UV and keeps the geometry", "[OBJ][Regression]")
{
const LoadedObj loaded = load_textured_tetrahedron("f 1/1000000000 3/1 2/1");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0., 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0., 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(0.5, 1e-6));
}
TEST_CASE("A face without texture indices loads among faces that have them", "[OBJ][Regression]")
{
const LoadedObj loaded = load_textured_tetrahedron("f 1 3 2");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
// One UV entry per face, so later faces keep their own coordinates.
REQUIRE(loaded.info.uvs.size() == 4);
for (const Vec2f &uv : loaded.info.uvs.front()) {
CHECK_THAT(uv.x(), WithinAbs(0., 1e-6));
CHECK_THAT(uv.y(), WithinAbs(0., 1e-6));
}
CHECK_THAT(loaded.info.uvs[1][0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(loaded.info.uvs[1][0].y(), WithinAbs(0.5, 1e-6));
}
TEST_CASE("A negative texture index counts back from the last texture coordinate", "[OBJ]")
{
// -1 is the most recent vt (0.75, 1), -2 the one before it (0.25, 0.5).
const LoadedObj loaded = load_textured_tetrahedron("f 1/-2 3/-1 2/-1");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6));
}
TEST_CASE("Texture coordinates with a w component are kept", "[OBJ][Regression]")
{
const LoadedObj loaded = load_textured_tetrahedron("f 1/1 3/2 2/2", "vt 0.25 0.5 0\nvt 0.75 1 0\n");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6));
}
TEST_CASE("A texture coordinate with w does not shift the indices of the ones after it", "[OBJ][Regression]")
{
// The w on the first vt used to drop that line, so vt 2 resolved to the third coordinate.
const LoadedObj loaded = load_textured_tetrahedron("f 1/2 3/3 2/-1", "vt 0.1 0.2 0\nvt 0.25 0.5\nvt 0.75 1\n");
REQUIRE(loaded.ok);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6));
CHECK_THAT(uv[2].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[2].y(), WithinAbs(1., 1e-6));
}