Compare commits

...
Author SHA1 Message Date
Hanif Koh 377034104c Keep OBJ Texture Coordinates That Carry a W Component
The vt parser stopped reading the optional third component when texture
coordinates were cut down to u and v, but the check that nothing is left
on the line stayed. A legal "vt u v w" line was therefore rejected and
silently dropped, shifting every later texture index. The w component is
parsed again and discarded.

The texture coordinate stride is now a named constant, OBJ_TEXCOORD_LENGTH,
used by the parser and the importer, so the relative-index rebase cannot
drift from the storage layout again.
2026-09-28 13:00:25 +08:00
Hanif Koh 468fa3be86 Reject DRC Meshes Without Positions or with Invalid Face Indices
load_drc dereferenced the POSITION attribute without checking that the
mesh has one, and trusted the decoded face indices, which the Draco
decoder does not check against the point count. Both now fail the load
cleanly. A failed vertex conversion is treated the same way.

The libslic3r tests link Draco so they can encode the malformed meshes
in-test.
2026-09-28 13:00:25 +08:00
Hanif Koh 824b092a6e Validate OBJ Texture-Coordinate Indices
load_obj read the texture coordinates of a face without checking the
vt index, so a face referencing a vt past the end of the list read out
of bounds and crashed, and a face vertex with no vt read index -1.
Out-of-range or missing indices now fall back to a zero UV. The face
keeps its entry in the per-face UV list, so the following faces stay
aligned, and the geometry loads as before.

Negative (relative) vt indices were also rebased by dividing the float
count by 3, but each vt stores two floats.
2026-09-28 13:00:25 +08:00
Ian Chua ec0d8c225f fix: slicing lifecycle event naming on cancellation (#15889)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

Avoid calling Print::output_filename() when a slice is canceled or
fails, since unresolved filename placeholders can throw before G-code
export completes.
Instead, we should use the model name in ctx.name and the stable model
ID in ctx.id, consistently across slice and G-code export events.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
Fix #15885
2026-09-28 12:20:08 +08:00
Ian Chua 3040ebaac1 Merge branch 'main' into fix/slicing-evt-name 2026-09-28 12:19:59 +08:00
Ian Chua d347a80ef8 Merge branch 'main' into fix/slicing-evt-name 2026-09-27 17:57:40 +08:00
peachismomo e340a13c18 fix: use current print for slicing lifecycle identity 2026-09-27 17:53:41 +08:00
peachismomo 9e16cdb23b fix: comment above reset_export 2026-09-27 17:29:25 +08:00
peachismomo 58842bab05 test: test for SliceStarted ensuring stable model ID is presetn when model name is absent 2026-09-27 17:29:01 +08:00
peachismomo 04204ec0d3 test: cover slicing lifecycle event context 2026-09-27 17:25:56 +08:00
Ian Chua 9ed459e132 fix: slicing event name and ID 2026-09-25 13:25:45 +08:00
11 changed files with 352 additions and 33 deletions
+21 -8
View File
@@ -48,24 +48,37 @@ 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];
positions->ConvertValue<float>(i, 3, pos);
if (!positions->ConvertValue<float>(i, 3, pos)) {
BOOST_LOG_TRIVIAL(error) << "load_drc: invalid vertex position";
return false;
}
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) {
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()
);
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);
}
*meshptr = TriangleMesh(std::move(its));
+9 -4
View File
@@ -166,10 +166,15 @@ 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) {
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};
// 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])};
obj_info.uvs.emplace_back(uv_array);
}
obj_info.face_colors.emplace_back(face_color);
+4 -5
View File
@@ -51,19 +51,18 @@ static bool obj_parseline(const char *line, ObjData &data)
line = endptr;
EATWS();
}
/*double w = 0;
// The optional w is accepted but not stored: only u and v are used.
if (*line != 0) {
w = strtod(line, &endptr);
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':
@@ -245,7 +244,7 @@ static bool obj_parseline(const char *line, ObjData &data)
else
-- vertex.normalIdx;
if (vertex.textureCoordIdx < 0)
vertex.textureCoordIdx += (int)data.textureCoordinates.size() / 3;
vertex.textureCoordIdx += (int)data.textureCoordinates.size() / OBJ_TEXCOORD_LENGTH;
else
-- vertex.textureCoordIdx;
data.vertices.push_back(vertex);
+2 -1
View File
@@ -92,6 +92,7 @@ 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.
@@ -100,7 +101,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, w
// u, v
std::vector<float> textureCoordinates;
// x, y, z
std::vector<float> normals;
+10 -5
View File
@@ -2504,7 +2504,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
{
LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id);
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = path;
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2556,7 +2557,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
}
{
LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id);
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\n" + err_msg;
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2580,7 +2582,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
boost::nowide::remove(path_tmp.c_str());
{
LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id);
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\n" + ex.what();
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2693,7 +2696,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
if (ret) {
{
LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id);
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\nFailed to rename the output G-code file: " + ret.message();
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2712,7 +2716,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
{
LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id);
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = path;
ctx.cancellation_check = [print]() { return print->canceled(); };
+10 -5
View File
@@ -2717,7 +2717,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
{
LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id);
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok;
ctx.cancellation_check = [this]() { return canceled(); };
fire_lifecycle_event(LifecycleEvent::SliceStarted, ctx);
@@ -3344,7 +3345,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
{
LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id);
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok;
ctx.cancellation_check = [this]() { return canceled(); };
fire_lifecycle_event(LifecycleEvent::SliceGeometryFinished, ctx);
@@ -4950,7 +4952,8 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
{
{
LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id);
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = file;
ctx.cancellation_check = [this]() { return canceled(); };
@@ -4980,7 +4983,8 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": found errors when process gcode file %1%") %file.c_str();
{
LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id);
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Error;
ctx.msg = file + "\n" + ex.what();
ctx.cancellation_check = [this]() { return canceled(); };
@@ -4994,7 +4998,8 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
{
LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id);
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = file;
ctx.cancellation_check = [this]() { return canceled(); };
+12 -4
View File
@@ -12800,8 +12800,6 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt)
notification_manager->set_slicing_progress_export_possible();
// Reset the "export G-code path" name, so that the automatic background processing will be enabled again.
const std::string lifecycle_job_name = this->background_process.fff_print() ?
this->background_process.fff_print()->output_filename() : std::string();
this->background_process.reset_export();
// This bool stops showing export finished notification even when process_completed_with_error is false
bool has_error = false;
@@ -12848,8 +12846,18 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt)
{
Slic3r::LifecycleEventContext ctx;
ctx.name = lifecycle_job_name;
ctx.code = evt.cancelled() ? Slic3r::LifecycleEvtCode::Warn : (has_error ? Slic3r::LifecycleEvtCode::Error : Slic3r::LifecycleEvtCode::Ok);
if (const PrintBase* print = this->background_process.current_print()) {
const Model& model = print->model();
ctx.id = std::to_string(model.id().id);
if (model.model_info)
ctx.name = model.model_info->model_name;
} else {
// Realistically Printbase* print will never be null because select_technology already asserts an active print
// and the worker thread asserts it before processing.
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": slicing completed without an active print; lifecycle event has no model ID";
}
ctx.code = evt.cancelled() ? Slic3r::LifecycleEvtCode::Warn :
(has_error ? Slic3r::LifecycleEvtCode::Error : Slic3r::LifecycleEvtCode::Ok);
ctx.msg = evt.cancelled() ? "cancelled" : (has_error ? lifecycle_error_msg : std::string());
Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::SlicingJobComplete, ctx);
}
+86
View File
@@ -15,6 +15,9 @@
#include "libslic3r/Layer.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
@@ -22,7 +25,10 @@
#include <algorithm>
#include <fstream>
#include <iterator>
#include <memory>
#include <string_view>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -224,8 +230,88 @@ std::string resolved_output_name(Model& model, const std::string& format, const
return print.output_filename(filename_base);
}
struct ScopedLifecycleHook
{
explicit ScopedLifecycleHook(LifecycleHookFn hook) { set_lifecycle_hook_fn(std::move(hook)); }
~ScopedLifecycleHook() { set_lifecycle_hook_fn(nullptr); }
};
} // namespace
TEST_CASE("Slicing lifecycle events identify the model", "[Print][LifecycleEvents]")
{
struct ObservedEvent {
LifecycleEvent event;
std::string id;
std::string name;
};
std::vector<ObservedEvent> events;
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
events.push_back({ event, ctx.id, ctx.name });
});
Print print;
Model model;
ModelInfo info;
info.model_name = "Lifecycle test model";
model.model_info = std::make_shared<ModelInfo>(std::move(info));
init_print({cube(20)}, print, model);
print.process();
ScopedTemporaryFile temp(".gcode");
print.export_gcode(temp.string(), nullptr, nullptr);
GCodeProcessorResult result;
print.export_gcode_from_previous_file(temp.string(), &result);
const std::string expected_id = std::to_string(print.model().id().id);
const std::vector<LifecycleEvent> expected_events = {
LifecycleEvent::SliceStarted,
LifecycleEvent::SliceGeometryFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
};
REQUIRE(events.size() == expected_events.size());
for (size_t i = 0; i < expected_events.size(); ++i) {
CHECK(events[i].event == expected_events[i]);
CHECK(events[i].id == expected_id);
CHECK(events[i].name == "Lifecycle test model");
}
}
TEST_CASE("Slicing lifecycle event name is empty without model metadata", "[Print][LifecycleEvents]")
{
std::string event_id;
std::string event_name = "unset";
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
if (event == LifecycleEvent::SliceStarted) {
event_id = ctx.id;
event_name = ctx.name;
}
});
Print print;
Model model;
init_print({cube(20)}, print, model);
print.process();
CHECK(event_id == std::to_string(print.model().id().id));
CHECK(event_name.empty());
}
TEST_CASE("Output filenames with numeric statistics fail before slicing finishes", "[Print][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filename_format", new ConfigOptionString("{int(total_weight*10) / 10.0}"));
Print print;
Model model;
init_print({cube(20)}, print, model, config);
CHECK_THROWS_AS(print.output_filename(), PlaceholderParserError);
}
TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]")
{
Model model;
+5 -1
View File
@@ -14,6 +14,7 @@ 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
@@ -32,6 +33,7 @@ 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
@@ -67,7 +69,9 @@ if (TARGET OpenVDB::openvdb)
target_sources(${_TEST_NAME}_tests PRIVATE test_hollowing.cpp)
endif()
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
# 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_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
+71
View File
@@ -0,0 +1,71 @@
#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
@@ -0,0 +1,122 @@
#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));
}