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.
This commit is contained in:
Hanif Koh
2026-09-28 13:00:25 +08:00
parent ec0d8c225f
commit 824b092a6e
4 changed files with 103 additions and 5 deletions
+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() / 2);
auto uv_at = [&data, uv_count](int idx) -> Vec2f {
if (idx < 0 || idx >= uv_count)
return Vec2f::Zero();
return Vec2f(data.textureCoordinates[idx * 2], data.textureCoordinates[idx * 2 + 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);
+1 -1
View File
@@ -245,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() / 3;
vertex.textureCoordIdx += (int)data.textureCoordinates.size() / 2;
else
-- vertex.textureCoordIdx;
data.vertices.push_back(vertex);
+1
View File
@@ -32,6 +32,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
+92
View File
@@ -0,0 +1,92 @@
#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 is varied; the other three reference vt 1.
LoadedObj load_textured_tetrahedron(const std::string &first_face)
{
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"
<< "vt 0.25 0.5\nvt 0.75 1\n"
<< "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));
}