Browse Source

ObjImporter: rewrite without STL strings, iostreams and exceptions.

And expand tests with corner cases that I previously forgot about. Can't
really provide any benchmarking info, since the Rungholt scene from
McGuire archives uses quads and negative indices.
master
Vladimír Vondruš 4 years ago
parent
commit
dacf37f237
  1. 8
      src/Magnum/SceneTools/Test/SceneConverterTest.cpp
  2. 7
      src/MagnumPlugins/ObjImporter/CMakeLists.txt
  3. 461
      src/MagnumPlugins/ObjImporter/ObjImporter.cpp
  4. 1
      src/MagnumPlugins/ObjImporter/ObjImporter.h
  5. 2
      src/MagnumPlugins/ObjImporter/Test/CMakeLists.txt
  6. 115
      src/MagnumPlugins/ObjImporter/Test/ObjImporterTest.cpp
  7. 1
      src/MagnumPlugins/ObjImporter/Test/invalid-keyword.obj
  8. 6
      src/MagnumPlugins/ObjImporter/Test/invalid-keywords.obj
  9. 45
      src/MagnumPlugins/ObjImporter/Test/invalid-number-count.obj
  10. 19
      src/MagnumPlugins/ObjImporter/Test/invalid-numbers.obj

8
src/Magnum/SceneTools/Test/SceneConverterTest.cpp

@ -1109,7 +1109,7 @@ const struct {
Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply") Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply")
}}, }},
"ObjImporter", nullptr, nullptr, nullptr, "ObjImporter", nullptr, nullptr, nullptr,
"Trade::ObjImporter::mesh(): wrong index count for point\n" "Trade::ObjImporter::mesh(): expected exactly 1 position index tuple for a point, got 5 5\n"
"Cannot import the mesh\n"}, "Cannot import the mesh\n"},
{"can't import a mesh for concatenation", {InPlaceInit, { {"can't import a mesh for concatenation", {InPlaceInit, {
"-I", "ObjImporter", "--concatenate-meshes", "-I", "ObjImporter", "--concatenate-meshes",
@ -1117,7 +1117,7 @@ const struct {
Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply") Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply")
}}, }},
"ObjImporter", nullptr, nullptr, nullptr, "ObjImporter", nullptr, nullptr, nullptr,
"Trade::ObjImporter::mesh(): wrong index count for point\n" "Trade::ObjImporter::mesh(): expected exactly 1 position index tuple for a point, got 5 5\n"
"Cannot import mesh 0\n"}, "Cannot import mesh 0\n"},
{"can't import a scene for concatenation", {InPlaceInit, { {"can't import a scene for concatenation", {InPlaceInit, {
/** @todo change to an OBJ once ObjImporter imports materials (and /** @todo change to an OBJ once ObjImporter imports materials (and
@ -1135,7 +1135,7 @@ const struct {
Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply") Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply")
}}, }},
"ObjImporter", nullptr, nullptr, nullptr, "ObjImporter", nullptr, nullptr, nullptr,
"Trade::ObjImporter::mesh(): wrong index count for point\n" "Trade::ObjImporter::mesh(): expected exactly 1 position index tuple for a point, got 5 5\n"
"Cannot import mesh 0\n"}, "Cannot import mesh 0\n"},
{"invalid mesh attribute filter", {InPlaceInit, { {"invalid mesh attribute filter", {InPlaceInit, {
/** @todo drop --mesh once it's not needed anymore again */ /** @todo drop --mesh once it's not needed anymore again */
@ -1188,7 +1188,7 @@ const struct {
Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply") Utility::Path::join(SCENETOOLS_TEST_OUTPUT_DIR, "SceneConverterTestFiles/whatever.ply")
}}, }},
"ObjImporter", nullptr, "StanfordSceneConverter", nullptr, "ObjImporter", nullptr, "StanfordSceneConverter", nullptr,
"Trade::ObjImporter::mesh(): wrong index count for point\n" "Trade::ObjImporter::mesh(): expected exactly 1 position index tuple for a point, got 5 5\n"
"Cannot add importer contents\n"}, "Cannot add importer contents\n"},
{"can't add processed meshes", {InPlaceInit, { {"can't add processed meshes", {InPlaceInit, {
"-I", "ObjImporter", "-C", "StanfordSceneConverter", "-I", "ObjImporter", "-C", "StanfordSceneConverter",

7
src/MagnumPlugins/ObjImporter/CMakeLists.txt

@ -45,13 +45,6 @@ if(MAGNUM_OBJIMPORTER_BUILD_STATIC AND MAGNUM_BUILD_STATIC_PIC)
set_target_properties(ObjImporter PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(ObjImporter PROPERTIES POSITION_INDEPENDENT_CODE ON)
endif() endif()
target_link_libraries(ObjImporter PUBLIC MagnumTrade MagnumMeshTools) target_link_libraries(ObjImporter PUBLIC MagnumTrade MagnumMeshTools)
if(CORRADE_TARGET_EMSCRIPTEN)
# Since 1.39.0, this needs to be enabled on the plugin. Before (on 1.38.44
# at least) this wasn't needed, at least for the test -- having it enabled
# for the test binary and in the linker was enough. Most probably related
# to fastcomp->llvm backend switch.
set_property(TARGET ObjImporter APPEND_STRING PROPERTY COMPILE_FLAGS " -s DISABLE_EXCEPTION_CATCHING=0")
endif()
install(FILES ObjImporter.h ${CMAKE_CURRENT_BINARY_DIR}/configure.h install(FILES ObjImporter.h ${CMAKE_CURRENT_BINARY_DIR}/configure.h
DESTINATION ${MAGNUM_PLUGINS_INCLUDE_INSTALL_DIR}/ObjImporter) DESTINATION ${MAGNUM_PLUGINS_INCLUDE_INSTALL_DIR}/ObjImporter)

461
src/MagnumPlugins/ObjImporter/ObjImporter.cpp

@ -26,17 +26,13 @@
#include "ObjImporter.h" #include "ObjImporter.h"
#include <fstream>
#include <limits>
#include <sstream>
#include <unordered_map> #include <unordered_map>
#include <Corrade/Containers/GrowableArray.h> #include <Corrade/Containers/GrowableArray.h>
#include <Corrade/Containers/Optional.h> #include <Corrade/Containers/Optional.h>
#include <Corrade/Containers/StridedArrayView.h> #include <Corrade/Containers/StridedArrayView.h>
#include <Corrade/Containers/String.h> #include <Corrade/Containers/String.h>
#include <Corrade/Containers/StringStl.h> /** @todo remove once iostream is dropped */ #include <Corrade/Containers/StringStlHash.h>
#include <Corrade/Utility/DebugStl.h> #include <Corrade/Utility/Algorithms.h>
#include <Corrade/Utility/String.h>
#include "Magnum/Mesh.h" #include "Magnum/Mesh.h"
#include "Magnum/MeshTools/RemoveDuplicates.h" #include "Magnum/MeshTools/RemoveDuplicates.h"
@ -46,52 +42,103 @@
namespace Magnum { namespace Trade { namespace Magnum { namespace Trade {
using namespace Containers::Literals;
namespace { namespace {
struct Mesh { struct Mesh {
std::streampos begin; /* Points to File::fileData, the end is implicitly `(this + 1)->begin` */
std::streampos end; const char* begin;
/* Name of the mesh. The first mesh has a name only if it appears before
any data line (v/vt/vn/... or p/l/f/...). */
Containers::StringView name;
/* Offset of the first position, texture coordinate and normal index.
Assuming that not only vertex data but also index data follow the mesh
name, this way we don't have to parse the whole file if just a single
mesh out of many is requested. */
UnsignedInt positionIndexOffset; UnsignedInt positionIndexOffset;
UnsignedInt textureCoordinateIndexOffset; UnsignedInt textureCoordinateIndexOffset;
UnsignedInt normalIndexOffset; UnsignedInt normalIndexOffset;
std::string name;
}; };
} }
struct ObjImporter::File { struct ObjImporter::File {
std::unordered_map<std::string, UnsignedInt> meshesForName; std::unordered_map<Containers::StringView, UnsignedInt> meshesForName;
/* Contains always n + 1 entries, with the last entry being an upper bound
on the file range and index offsets */
Containers::Array<Mesh> meshes; Containers::Array<Mesh> meshes;
Containers::Pointer<std::istream> in; Containers::Array<char> fileData;
}; };
namespace { namespace {
void ignoreLine(std::istream& in) { /* The spec doesn't say anything explicit about whitespace, but at the very
in.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); least the examples at http://paulbourke.net/dataformats/obj/ pad the values
} with whitespace and various tools are also producing such files, as reported
at https://forum.babylonjs.com/t/extra-whitespace-breaks-obj-parsing/5244
template<std::size_t size> Math::Vector<size, Float> extractFloatData(const std::string& str, Float* extra = nullptr) {
std::vector<std::string> data = Utility::String::splitWithoutEmptyParts(str, ' '); Besides space I'm considering a tab and a CR character. *Not* a newline,
if(data.size() < size || data.size() > size + (extra ? 1 : 0)) { since that is a significant delimiter that has to be treated separately. */
Error() << "Trade::ObjImporter::mesh(): invalid float array size"; constexpr Containers::StringView Whitespace = " \t\r"_s;
throw 0;
/* Mostly just a copy of Corrade's Utility::Json::parseFloatInternal() and
parseUnsignedIntInternal() */
/** @todo make a common API in Corrade once we have something that can parse
numbers like a grownup */
inline bool parseFloat(const char* const errorPrefix, const Containers::StringView string, Float& out) {
/** @todo replace with something that can parse non-null-terminated stuff,
then drop this "too long" error */
char buffer[128];
const std::size_t size = string.size();
if(size > Containers::arraySize(buffer) - 1) {
Error{} << errorPrefix << "too long numeric literal" << string;
return false;
} }
Math::Vector<size, Float> output; std::memcpy(buffer, string.data(), size);
buffer[size] = '\0';
char* end;
out = std::strtof(buffer, &end);
if(!string || std::size_t(end - buffer) != size) {
Error{} << errorPrefix << "invalid floating-point literal" << string;
return false;
}
for(std::size_t i = 0; i != size; ++i) /* Success; value already written above */
output[i] = std::stof(data[i]); return true;
}
if(data.size() == size+1) { inline bool parseUnsignedInt(const char* const errorPrefix, const Containers::StringView string, UnsignedInt& out) {
/* This should be obvious from the first if, but add this just to make /** @todo replace with something that can parse non-null-terminated stuff,
Clang Analyzer happy */ then drop this "too long" error */
CORRADE_INTERNAL_ASSERT(extra); char buffer[128];
const std::size_t size = string.size();
if(size > Containers::arraySize(buffer) - 1) {
Error{} << errorPrefix << "too long numeric literal" << string;
return false;
}
*extra = std::stof(data.back()); std::memcpy(buffer, string.data(), size);
buffer[size] = '\0';
char* end;
/* Not using strtoul() here as on Windows it's 32-bit and we wouldn't be
able to detect overflows */
/** @todo replace with something that can report errors in a non-insane
way */
const std::uint64_t outLong = std::strtoull(buffer, &end, 10);
if(!string || std::size_t(end - buffer) != size) {
Error{} << errorPrefix << "invalid integer literal" << string;
return false;
}
if(outLong > ~std::uint32_t{}) {
Error{} << errorPrefix << "too large integer literal" << string;
return false;
} }
return output; /* On success convert the value to 32 bits */
out = outLong;
return true;
} }
} }
@ -104,84 +151,72 @@ ObjImporter::~ObjImporter() = default;
ImporterFeatures ObjImporter::doFeatures() const { return ImporterFeature::OpenData; } ImporterFeatures ObjImporter::doFeatures() const { return ImporterFeature::OpenData; }
void ObjImporter::doClose() { _file.reset(); } void ObjImporter::doClose() {
_file = {};
}
bool ObjImporter::doIsOpened() const { return !!_file; } bool ObjImporter::doIsOpened() const { return !!_file; }
void ObjImporter::doOpenFile(const Containers::StringView filename) { void ObjImporter::doOpenData(Containers::Array<char>&& data, const DataFlags dataFlags) {
/** @todo ARGH clean this up, won't work with UTF-8 */
Containers::Pointer<std::istream> in{new std::ifstream{filename, std::ios::binary}};
if(!in->good()) {
Error() << "Trade::ObjImporter::openFile(): cannot open file" << filename;
return;
}
_file.reset(new File); _file.reset(new File);
_file->in = Utility::move(in);
parseMeshNames();
}
void ObjImporter::doOpenData(Containers::Array<char>&& data, DataFlags) {
_file.reset(new File);
/** @todo ARGH MY EYES what is this cursed thing, burn it to the ground */
_file->in.reset(new std::istringstream{{data.begin(), data.size()}});
parseMeshNames(); /* Copy file content. Take over the existing array or copy the data if we
} can't. We need to keep the data around as JSON tokens are views onto it
and also for the GLB binary chunk. */
if(dataFlags & (DataFlag::Owned|DataFlag::ExternallyOwned)) {
_file->fileData = std::move(data);
} else {
_file->fileData = Containers::Array<char>{NoInit, data.size()};
Utility::copy(data, _file->fileData);
}
void ObjImporter::parseMeshNames() { /* First mesh starts at the beginning, its indices start from 1 */
/* First mesh starts at the beginning, its indices start from 1. The end
offset will be updated to proper value later. */
UnsignedInt positionIndexOffset = 1; UnsignedInt positionIndexOffset = 1;
UnsignedInt normalIndexOffset = 1; UnsignedInt normalIndexOffset = 1;
UnsignedInt textureCoordinateIndexOffset = 1; UnsignedInt textureCoordinateIndexOffset = 1;
arrayAppend(_file->meshes, InPlaceInit, _file->fileData.begin(), Containers::StringView{}, positionIndexOffset, textureCoordinateIndexOffset, normalIndexOffset);
/* The first mesh doesn't have name by default but we might find it later, /* The first mesh doesn't have name by default but we might find it later,
so we need to track whether there are any data before first name */ so we need to track whether there are any data before first name */
bool thisIsFirstMeshAndItHasNoData = true; bool thisIsFirstMeshAndItHasNoData = true;
arrayAppend(_file->meshes, InPlaceInit, 0, 0, positionIndexOffset, normalIndexOffset, textureCoordinateIndexOffset, std::string{});
while(_file->in->good()) { /** @todo check size < 1G on 32b? currently it'd just assert, but it's
/* The previous object might end at the beginning of this line */ unlikely that such amount of contiguous memory would even be available
const std::streampos end = _file->in->tellg(); there, so ¯\_()_/¯ */
Containers::StringView in{_file->fileData, _file->fileData.size()};
while(in) {
/* Get a (trimmed) line from the input */
const Containers::StringView lineEnd = in.findOr('\n', in.end());
const Containers::StringView line = in.prefix(lineEnd.begin()).trimmed(Whitespace);
in = in.suffix(lineEnd.end());
/* Comment line */ /* Comment or empty line, skip */
if(_file->in->peek() == '#') { if(!line || line.hasPrefix('#')) continue;
ignoreLine(*_file->in);
continue;
}
/* Parse the keyword */ /* Parse the keyword */
std::string keyword; const Containers::StringView keywordEnd = line.findAnyOr(Whitespace, line.end());
*_file->in >> keyword; const Containers::StringView keyword = line.prefix(keywordEnd.begin());
/* Mesh name */ /* Mesh name */
if(keyword == "o") { if(keyword == "o"_s) {
std::string name; const Containers::StringView name = line.suffix(keywordEnd.end()).trimmed(Whitespace);
std::getline(*_file->in, name);
name = Utility::String::trim(name);
/* This is the name of first mesh */ /* This is the name of first mesh */
if(thisIsFirstMeshAndItHasNoData) { if(thisIsFirstMeshAndItHasNoData) {
thisIsFirstMeshAndItHasNoData = false; thisIsFirstMeshAndItHasNoData = false;
/* Update its name and add it to name map */ /* Update its name and add it to name map */
if(!name.empty()) if(name)
_file->meshesForName.emplace(name, _file->meshes.size() - 1); _file->meshesForName.emplace(name, _file->meshes.size() - 1);
_file->meshes.back().name = Utility::move(name); _file->meshes.back().name = name;
/* Update its begin offset to be more precise */
_file->meshes.back().begin = _file->in->tellg();
/* Otherwise this is a name of new mesh */ /* Otherwise this is a name of new mesh */
} else { } else {
/* Set end of the previous one */
_file->meshes.back().end = end;
/* Save name and offset of the new one. The end offset will be /* Save name and offset of the new one. The end offset will be
updated later. */ updated later. */
if(!name.empty()) if(name)
_file->meshesForName.emplace(name, _file->meshes.size()); _file->meshesForName.emplace(name, _file->meshes.size());
arrayAppend(_file->meshes, InPlaceInit, _file->in->tellg(), 0, positionIndexOffset, textureCoordinateIndexOffset, normalIndexOffset, Utility::move(name)); arrayAppend(_file->meshes, InPlaceInit, in.begin(), name, positionIndexOffset, textureCoordinateIndexOffset, normalIndexOffset);
} }
continue; continue;
@ -190,43 +225,40 @@ void ObjImporter::parseMeshNames() {
the first object is unnamed. We need to check for them. */ the first object is unnamed. We need to check for them. */
/* Vertex data, update index offset for the following meshes */ /* Vertex data, update index offset for the following meshes */
} else if(keyword == "v") { } else if(keyword == "v"_s) {
++positionIndexOffset; ++positionIndexOffset;
thisIsFirstMeshAndItHasNoData = false; thisIsFirstMeshAndItHasNoData = false;
} else if(keyword == "vt") { } else if(keyword == "vt"_s) {
++textureCoordinateIndexOffset; ++textureCoordinateIndexOffset;
thisIsFirstMeshAndItHasNoData = false; thisIsFirstMeshAndItHasNoData = false;
} else if(keyword == "vn") { } else if(keyword == "vn"_s) {
++normalIndexOffset; ++normalIndexOffset;
thisIsFirstMeshAndItHasNoData = false; thisIsFirstMeshAndItHasNoData = false;
/* Index data, just mark that we found something for first unnamed /* Index data, just mark that we found something for first unnamed
object */ object */
} else if(thisIsFirstMeshAndItHasNoData) for(const std::string data: {"p", "l", "f"}) { } else if(keyword == "p"_s ||
if(keyword == data) { keyword == "l"_s ||
thisIsFirstMeshAndItHasNoData = false; keyword == "f"_s) {
break; thisIsFirstMeshAndItHasNoData = false;
}
} }
/* Ignore the rest of the line */
ignoreLine(*_file->in);
} }
/* Set end of the last object */ /* Save the final offset so we have an upper bound on index offsets */
_file->in->clear(); arrayAppend(_file->meshes, InPlaceInit, in.begin(), Containers::StringView{}, positionIndexOffset, textureCoordinateIndexOffset, normalIndexOffset);
_file->in->seekg(0, std::ios::end);
_file->meshes.back().end = _file->in->tellg();
} }
UnsignedInt ObjImporter::doMeshCount() const { return _file->meshes.size(); } UnsignedInt ObjImporter::doMeshCount() const {
/* There's always one more item for an upper bound */
return _file->meshes.size() - 1;
}
Int ObjImporter::doMeshForName(const Containers::StringView name) { Int ObjImporter::doMeshForName(const Containers::StringView name) {
const auto it = _file->meshesForName.find(name); const auto it = _file->meshesForName.find(name);
return it == _file->meshesForName.end() ? -1 : it->second; return it == _file->meshesForName.end() ? -1 : it->second;
} }
Containers::String ObjImporter::doMeshName(UnsignedInt id) { Containers::String ObjImporter::doMeshName(const UnsignedInt id) {
return _file->meshes[id].name; return _file->meshes[id].name;
} }
@ -246,10 +278,9 @@ template<class T> bool checkAndDuplicateInto(const Containers::StridedArrayView1
} }
Containers::Optional<MeshData> ObjImporter::doMesh(UnsignedInt id, UnsignedInt) { Containers::Optional<MeshData> ObjImporter::doMesh(const UnsignedInt id, UnsignedInt) {
/* Seek the file, set mesh parsing parameters */ /* Seek the file, set mesh parsing parameters */
const Mesh& mesh = _file->meshes[id]; const Mesh& mesh = _file->meshes[id];
_file->in->seekg(mesh.begin);
Containers::Optional<MeshPrimitive> primitive; Containers::Optional<MeshPrimitive> primitive;
Containers::Array<Vector3> positions; Containers::Array<Vector3> positions;
@ -260,68 +291,151 @@ Containers::Optional<MeshData> ObjImporter::doMesh(UnsignedInt id, UnsignedInt)
Containers::Array<Vector3ui> indices; Containers::Array<Vector3ui> indices;
std::size_t textureCoordinateIndexCount = 0, normalIndexCount = 0; std::size_t textureCoordinateIndexCount = 0, normalIndexCount = 0;
try { while(_file->in->good() && _file->in->tellg() < mesh.end) { Containers::StringView in{mesh.begin, std::size_t(_file->meshes[id + 1].begin - mesh.begin)};
/* Ignore comments */ while(in) {
if(_file->in->peek() == '#') { /* Get a line from the input */
ignoreLine(*_file->in); const Containers::StringView lineEnd = in.findOr('\n', in.end());
const Containers::StringView line = in.prefix(lineEnd.begin()).trimmed(Whitespace);
in = in.suffix(lineEnd.end());
/* Comment or empty line, skip */
if(!line || line.hasPrefix('#')) continue;
/* Parse the keyword */
const Containers::StringView keywordEnd = line.findAnyOr(Whitespace, line.end());
const Containers::StringView keyword = line.prefix(keywordEnd.begin());
/* Skip keywords that are not interesting to us or that were parsed
earlier. In particular, the `o` can be here because the mesh range
is everything until the next mesh data start, so including the next mesh name. */
if(keyword == "o"_s ||
keyword == "g"_s ||
keyword == "s"_s ||
keyword == "mtllib"_s ||
keyword == "usemtl"_s)
continue; continue;
}
/* Get the line */ /* Keyword contents */
std::string line; Containers::StringView contents = line.suffix(keywordEnd.end()).trimmedPrefix(Whitespace);
std::getline(*_file->in, line);
line = Utility::String::trim(line); /* Vertex data */
if(keyword == "v"_s || keyword == "vt"_s || keyword == "vn"_s) {
/* Ignore empty lines */ /* Decide on how many components we expect at most. There's
if(line.empty()) continue; optional behavior for four-component positions and
three-component texture coordinates, so it can't be an exact
/* Split the line into keyword and contents */ count. */
const std::size_t keywordEnd = line.find(' '); std::size_t maxComponentCount;
const std::string keyword = line.substr(0, keywordEnd); if(keyword == "v"_s) maxComponentCount = 4;
const std::string contents = keywordEnd != std::string::npos ? else if(keyword == "vt"_s) maxComponentCount = 3;
Utility::String::ltrim(line.substr(keywordEnd+1)) : ""; else if(keyword == "vn"_s) maxComponentCount = 3;
else CORRADE_INTERNAL_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */
/* Vertex position */
if(keyword == "v") { /* Parse them all. If there's less than expected, `i` would be too
Float extra{1.0f}; small; if there's more then `contents` would stay non-empty. */
const Vector3 data = extractFloatData<3>(contents, &extra); Float data[4];
if(!Math::TypeTraits<Float>::equals(extra, 1.0f)) { std::size_t i = 0;
Error() << "Trade::ObjImporter::mesh(): homogeneous coordinates are not supported"; for(; i != maxComponentCount && contents; ++i) {
return Containers::NullOpt; const Containers::StringView foundSpace = contents.findAnyOr(Whitespace, contents.end());
if(!parseFloat("Trade::ObjImporter::mesh():", contents.prefix(foundSpace.begin()), data[i]))
return {};
contents = contents.suffix(foundSpace.end()).trimmedPrefix(Whitespace);
} }
arrayAppend(positions, data); /* Position */
if(keyword == "v"_s) {
if(i < 3 || contents) {
Error{} << "Trade::ObjImporter::mesh(): expected 3 or 4 position coordinates, got" << line.suffix(keywordEnd.end());
return {};
}
if(i == 4 && !Math::equal(data[3], 1.0f)) {
Error{} << "Trade::ObjImporter::mesh(): homogeneous coordinates are not supported";
return {};
}
/* Texture coordinate */ arrayAppend(positions, Vector3::from(data));
} else if(keyword == "vt") {
Float extra{0.0f};
const Vector2 data = extractFloatData<2>(contents, &extra);
if(!Math::TypeTraits<Float>::equals(extra, 0.0f)) {
Error() << "Trade::ObjImporter::mesh(): 3D texture coordinates are not supported";
return Containers::NullOpt;
}
arrayAppend(textureCoordinates, data); /* Texture coordinate */
} else if(keyword == "vt"_s) {
if(i < 2 || contents) {
Error{} << "Trade::ObjImporter::mesh(): expected 2 or 3 texture coordinates, got" << line.suffix(keywordEnd.end());
return {};
}
if(i == 3 && !Math::equal(data[2], 0.0f)) {
Error{} << "Trade::ObjImporter::mesh(): 3D texture coordinates are not supported";
return {};
}
/* Normal */ arrayAppend(textureCoordinates, Vector2::from(data));
} else if(keyword == "vn") {
arrayAppend(normals, Vector3{extractFloatData<3>(contents)}); /* Normal */
} else if(keyword == "vn"_s) {
if(i < 3 || contents) {
Error{} << "Trade::ObjImporter::mesh(): expected 3 normal coordinates, got" << line.suffix(keywordEnd.end());
return {};
}
arrayAppend(normals, Vector3::from(data));
} else CORRADE_INTERNAL_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */
/* Indices */ /* Indices */
} else if(keyword == "p" || keyword == "l" || keyword == "f") { } else if(keyword == "p"_s || keyword == "l"_s || keyword == "f"_s) {
const std::vector<std::string> indexTuples = Utility::String::splitWithoutEmptyParts(contents, ' '); /* Decide on how many tuples we expect */
std::size_t indexTupleCount;
if(keyword == "p"_s) indexTupleCount = 1;
else if(keyword == "l"_s) indexTupleCount = 2;
else if(keyword == "f"_s) indexTupleCount = 3;
else CORRADE_INTERNAL_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */
/* Parse them all. If there's less than expected, `i` would be too
small; if there's more then `contents` would stay non-empty. */
Vector3ui data[3];
std::size_t i = 0;
for(; i != indexTupleCount && contents; ++i) {
const Containers::StringView foundSpace = contents.findAnyOr(Whitespace, contents.end());
Containers::StringView indexTuple = contents.prefix(foundSpace.begin());
/* The number before first slash is a position index */
const Containers::StringView foundSlash1 = indexTuple.findOr('/', indexTuple.end());
if(!parseUnsignedInt("Trade::ObjImporter::mesh():", indexTuple.prefix(foundSlash1.begin()), data[i][0]))
return {};
data[i][0] -= mesh.positionIndexOffset;
/* If there was a slash, next is a texture coordinate or
empty */
if(foundSlash1) {
indexTuple = indexTuple.suffix(foundSlash1.end());
const Containers::StringView foundSlash2 = indexTuple.findOr('/', indexTuple.end());
if(!foundSlash2 || foundSlash2.begin() != indexTuple.begin()) {
if(!parseUnsignedInt("Trade::ObjImporter::mesh():", indexTuple.prefix(foundSlash2.begin()), data[i][2]))
return {};
data[i][2] -= mesh.textureCoordinateIndexOffset;
++textureCoordinateIndexCount;
}
/* If there was a second slash, last is a normal */
if(foundSlash2) {
indexTuple = indexTuple.suffix(foundSlash2.end());
if(!parseUnsignedInt("Trade::ObjImporter::mesh():", indexTuple, data[i][1]))
return {};
data[i][1] -= mesh.normalIndexOffset;
++normalIndexCount;
}
}
contents = contents.suffix(foundSpace.end()).trimmedPrefix(Whitespace);
}
/* Points */ /* Points */
if(keyword == "p") { if(keyword == "p") {
/* Check that we don't mix the primitives in one mesh */
if(primitive && primitive != MeshPrimitive::Points) { if(primitive && primitive != MeshPrimitive::Points) {
Error() << "Trade::ObjImporter::mesh(): mixed primitive" << *primitive << "and" << MeshPrimitive::Points; Error() << "Trade::ObjImporter::mesh(): mixed primitive" << *primitive << "and" << MeshPrimitive::Points;
return Containers::NullOpt; return Containers::NullOpt;
} }
if(i < 1 || contents) {
/* Check vertex count per primitive */ Error() << "Trade::ObjImporter::mesh(): expected exactly 1 position index tuple for a point, got" << line.suffix(keywordEnd.end());
if(indexTuples.size() != 1) {
Error() << "Trade::ObjImporter::mesh(): wrong index count for point";
return Containers::NullOpt; return Containers::NullOpt;
} }
@ -329,15 +443,12 @@ Containers::Optional<MeshData> ObjImporter::doMesh(UnsignedInt id, UnsignedInt)
/* Lines */ /* Lines */
} else if(keyword == "l") { } else if(keyword == "l") {
/* Check that we don't mix the primitives in one mesh */
if(primitive && primitive != MeshPrimitive::Lines) { if(primitive && primitive != MeshPrimitive::Lines) {
Error() << "Trade::ObjImporter::mesh(): mixed primitive" << *primitive << "and" << MeshPrimitive::Lines; Error() << "Trade::ObjImporter::mesh(): mixed primitive" << *primitive << "and" << MeshPrimitive::Lines;
return Containers::NullOpt; return Containers::NullOpt;
} }
if(i < 2 || contents) {
/* Check vertex count per primitive */ Error() << "Trade::ObjImporter::mesh(): expected exactly 2 position index tuples for a line, got" << line.suffix(keywordEnd.end());
if(indexTuples.size() != 2) {
Error() << "Trade::ObjImporter::mesh(): wrong index count for line";
return Containers::NullOpt; return Containers::NullOpt;
} }
@ -345,18 +456,12 @@ Containers::Optional<MeshData> ObjImporter::doMesh(UnsignedInt id, UnsignedInt)
/* Faces */ /* Faces */
} else if(keyword == "f") { } else if(keyword == "f") {
/* Check that we don't mix the primitives in one mesh */
if(primitive && primitive != MeshPrimitive::Triangles) { if(primitive && primitive != MeshPrimitive::Triangles) {
Error() << "Trade::ObjImporter::mesh(): mixed primitive" << *primitive << "and" << MeshPrimitive::Triangles; Error() << "Trade::ObjImporter::mesh(): mixed primitive" << *primitive << "and" << MeshPrimitive::Triangles;
return Containers::NullOpt; return Containers::NullOpt;
} }
if(i < 3 || contents) {
/* Check vertex count per primitive */ Error() << "Trade::ObjImporter::mesh(): expected exactly 3 position index tuples for a triangle, got" << line.suffix(keywordEnd.end());
if(indexTuples.size() < 3) {
Error() << "Trade::ObjImporter::mesh(): wrong index count for triangle";
return Containers::NullOpt;
} else if(indexTuples.size() != 3) {
Error() << "Trade::ObjImporter::mesh(): polygons are not supported";
return Containers::NullOpt; return Containers::NullOpt;
} }
@ -364,50 +469,14 @@ Containers::Optional<MeshData> ObjImporter::doMesh(UnsignedInt id, UnsignedInt)
} else CORRADE_INTERNAL_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ } else CORRADE_INTERNAL_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */
for(const std::string& indexTuple: indexTuples) { /** @todo fix arrayAppend() to not need the cast here */
std::vector<std::string> indexStrings = Utility::String::split(indexTuple, '/'); arrayAppend(indices, Containers::ArrayView<const Vector3ui>{data}.prefix(i));
if(indexStrings.size() > 3) {
Error() << "Trade::ObjImporter::mesh(): invalid index data";
return Containers::NullOpt;
}
Vector3ui index;
/* Position indices */
index[0] = std::stoul(indexStrings[0]) - mesh.positionIndexOffset;
/* Texture coordinates */
if(indexStrings.size() == 2 || (indexStrings.size() == 3 && !indexStrings[1].empty())) {
index[2] = std::stoul(indexStrings[1]) - mesh.textureCoordinateIndexOffset;
++textureCoordinateIndexCount;
}
/* Normal indices */
if(indexStrings.size() == 3) {
index[1] = std::stoul(indexStrings[2]) - mesh.normalIndexOffset;
++normalIndexCount;
}
arrayAppend(indices, index);
}
/* Ignore unsupported keywords, error out on unknown keywords */ /* Unknown keyword */
} else if(![&keyword](){ } else {
/* Using lambda to emulate for-else construct like in Python */ Error{} << "Trade::ObjImporter::mesh(): unknown keyword" << keyword;
for(const std::string expected: {"mtllib", "usemtl", "g", "s"}) return {};
if(keyword == expected) return true;
return false;
}()) {
Error() << "Trade::ObjImporter::mesh(): unknown keyword" << keyword;
return Containers::NullOpt;
} }
}} catch(const std::exception&) {
Error() << "Trade::ObjImporter::mesh(): error while converting numeric data";
return Containers::NullOpt;
} catch(...) {
/* Error message already printed */
return Containers::NullOpt;
} }
/* There should be at least indexed position data */ /* There should be at least indexed position data */

1
src/MagnumPlugins/ObjImporter/ObjImporter.h

@ -124,7 +124,6 @@ class MAGNUM_OBJIMPORTER_EXPORT ObjImporter: public AbstractImporter {
MAGNUM_OBJIMPORTER_LOCAL bool doIsOpened() const override; MAGNUM_OBJIMPORTER_LOCAL bool doIsOpened() const override;
MAGNUM_OBJIMPORTER_LOCAL void doOpenData(Containers::Array<char>&& data, DataFlags dataFlags) override; MAGNUM_OBJIMPORTER_LOCAL void doOpenData(Containers::Array<char>&& data, DataFlags dataFlags) override;
MAGNUM_OBJIMPORTER_LOCAL void doOpenFile(Containers::StringView filename) override;
MAGNUM_OBJIMPORTER_LOCAL void doClose() override; MAGNUM_OBJIMPORTER_LOCAL void doClose() override;
MAGNUM_OBJIMPORTER_LOCAL UnsignedInt doMeshCount() const override; MAGNUM_OBJIMPORTER_LOCAL UnsignedInt doMeshCount() const override;

2
src/MagnumPlugins/ObjImporter/Test/CMakeLists.txt

@ -50,7 +50,7 @@ corrade_add_test(ObjImporterTest ObjImporterTest.cpp
empty.obj empty.obj
invalid-incomplete-data.obj invalid-incomplete-data.obj
invalid-inconsistent-index-tuple.obj invalid-inconsistent-index-tuple.obj
invalid-keyword.obj invalid-keywords.obj
invalid-mixed-primitives.obj invalid-mixed-primitives.obj
invalid-number-count.obj invalid-number-count.obj
invalid-numbers.obj invalid-numbers.obj

115
src/MagnumPlugins/ObjImporter/Test/ObjImporterTest.cpp

@ -68,7 +68,7 @@ struct ObjImporterTest: TestSuite::Tester {
maintain. So it's instead grouped into files by a common error scenario maintain. So it's instead grouped into files by a common error scenario
with each case testing one file and just the invalid() case testing with each case testing one file and just the invalid() case testing
separate files. */ separate files. */
void invalid(); void invalidKeywords();
void invalidMixedPrimitives(); void invalidMixedPrimitives();
void invalidNumbers(); void invalidNumbers();
void invalidNumberCount(); void invalidNumberCount();
@ -76,6 +76,8 @@ struct ObjImporterTest: TestSuite::Tester {
void invalidIncompleteData(); void invalidIncompleteData();
void invalidOptionalCoordinate(); void invalidOptionalCoordinate();
void whitespace();
void openTwice(); void openTwice();
void importTwice(); void importTwice();
@ -93,11 +95,11 @@ const struct {
const struct { const struct {
const char* name; const char* name;
const char* filename;
const char* message; const char* message;
} InvalidData[]{ } InvalidKeywordsData[]{
{"unknown keyword", "invalid-keyword.obj", {"unknown keyword", "unknown keyword bleh"},
"unknown keyword bleh"} {"no space between vertex keyword and number", "unknown keyword vt3"},
{"no space between index keyword and number", "unknown keyword p6"}
}; };
const struct { const struct {
@ -116,9 +118,12 @@ const struct {
const char* name; const char* name;
const char* message; const char* message;
} InvalidNumbersData[]{ } InvalidNumbersData[]{
{"invalid float literal", "error while converting numeric data"}, {"too long float literal", "too long numeric literal 1234.567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567"},
{"invalid integer literal", "error while converting numeric data"}, {"too long integer literal", "too long numeric literal 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"},
{"position index out of range", "index 1 out of range for 1 vertices"}, {"too large integer literal", "too large integer literal 4294967296"},
{"invalid float literal", "invalid floating-point literal bleh"},
{"invalid integer literal", "invalid integer literal bleh"},
{"position index out of range", "index 3 out of range for 1 vertices"},
{"texture index out of range", "index 4 out of range for 3 vertices"}, {"texture index out of range", "index 4 out of range for 3 vertices"},
{"normal index out of range", "index 3 out of range for 2 vertices"}, {"normal index out of range", "index 3 out of range for 2 vertices"},
{"zero index", "index 0 out of range for 1 vertices"} {"zero index", "index 0 out of range for 1 vertices"}
@ -128,14 +133,25 @@ const struct {
const char* name; const char* name;
const char* message; const char* message;
} InvalidNumberCountData[]{ } InvalidNumberCountData[]{
{"two-component position", "invalid float array size"}, {"no position component at all", "expected 3 or 4 position coordinates, got "},
{"five-component position with optional fourth component", "invalid float array size"}, {"two-component position", "expected 3 or 4 position coordinates, got 0.5 1.0"},
{"four-component normal", "invalid float array size"}, {"five-component position with optional fourth component", "expected 3 or 4 position coordinates, got 0.5 1 2 1.0 3.5"},
{"four-component index tuple", "invalid index data"}, {"no texture coordinate component at all", "expected 2 or 3 texture coordinates, got "},
{"point with two indices", "wrong index count for point"}, {"one-component texture coordinate", "expected 2 or 3 texture coordinates, got 0.5"},
{"line with one index", "wrong index count for line"}, {"four-component texture coordinate with optional third component", "expected 2 or 3 texture coordinates, got 0.5 1.0 0.0 7.4"},
{"triangle with two indices", "wrong index count for triangle"}, {"no normal component at all", "expected 3 normal coordinates, got "},
{"quad", "polygons are not supported"} {"two-component normal", "expected 3 normal coordinates, got 0.5 0.0"},
{"four-component normal", "expected 3 normal coordinates, got 0.5 1.0 2.3 7.4"},
{"no index at all", "expected exactly 1 position index tuple for a point, got "},
/** @todo better error message (the literal is empty) */
{"no index before first slash", "invalid integer literal "},
{"no index after first slash", "invalid integer literal "},
{"no index after second slash", "invalid integer literal "},
{"four-component index tuple", "invalid integer literal 1/1"},
{"point with two indices", "expected exactly 1 position index tuple for a point, got 9 9"},
{"line with one index", "expected exactly 2 position index tuples for a line, got 10"},
{"triangle with two indices", "expected exactly 3 position index tuples for a triangle, got 11 11"},
{"quad", "expected exactly 3 position index tuples for a triangle, got 12 12 12 12"}
}; };
const struct { const struct {
@ -188,8 +204,8 @@ ObjImporterTest::ObjImporterTest() {
addTests({&ObjImporterTest::moreMeshes}); addTests({&ObjImporterTest::moreMeshes});
addInstancedTests({&ObjImporterTest::invalid}, addInstancedTests({&ObjImporterTest::invalidKeywords},
Containers::arraySize(InvalidData)); Containers::arraySize(InvalidKeywordsData));
addInstancedTests({&ObjImporterTest::invalidMixedPrimitives}, addInstancedTests({&ObjImporterTest::invalidMixedPrimitives},
Containers::arraySize(InvalidMixedPrimitivesData)); Containers::arraySize(InvalidMixedPrimitivesData));
@ -209,7 +225,9 @@ ObjImporterTest::ObjImporterTest() {
addInstancedTests({&ObjImporterTest::invalidOptionalCoordinate}, addInstancedTests({&ObjImporterTest::invalidOptionalCoordinate},
Containers::arraySize(InvalidOptionalCoordinateData)); Containers::arraySize(InvalidOptionalCoordinateData));
addTests({&ObjImporterTest::openTwice, addTests({&ObjImporterTest::whitespace,
&ObjImporterTest::openTwice,
&ObjImporterTest::importTwice}); &ObjImporterTest::importTwice});
#ifdef OBJIMPORTER_PLUGIN_FILENAME #ifdef OBJIMPORTER_PLUGIN_FILENAME
@ -554,18 +572,19 @@ void ObjImporterTest::moreMeshes() {
TestSuite::Compare::Container); TestSuite::Compare::Container);
} }
void ObjImporterTest::invalid() { void ObjImporterTest::invalidKeywords() {
auto&& data = InvalidData[testCaseInstanceId()]; auto&& data = InvalidKeywordsData[testCaseInstanceId()];
setTestCaseDescription(data.name); setTestCaseDescription(data.name);
Containers::Pointer<AbstractImporter> importer = _manager.instantiate("ObjImporter"); Containers::Pointer<AbstractImporter> importer = _manager.instantiate("ObjImporter");
CORRADE_VERIFY(importer->openFile(Utility::Path::join(OBJIMPORTER_TEST_DIR, data.filename))); CORRADE_VERIFY(importer->openFile(Utility::Path::join(OBJIMPORTER_TEST_DIR, "invalid-keywords.obj")));
CORRADE_COMPARE(importer->meshCount(), 1); /* Ensure we didn't forget to test any case */
CORRADE_COMPARE(importer->meshCount(), Containers::arraySize(InvalidKeywordsData));
Containers::String out; Containers::String out;
Error redirectError{&out}; Error redirectError{&out};
CORRADE_VERIFY(!importer->mesh(0)); CORRADE_VERIFY(!importer->mesh(data.name));
CORRADE_COMPARE(out, Utility::format("Trade::ObjImporter::mesh(): {}\n", data.message)); CORRADE_COMPARE(out, Utility::format("Trade::ObjImporter::mesh(): {}\n", data.message));
} }
@ -665,6 +684,54 @@ void ObjImporterTest::invalidOptionalCoordinate() {
CORRADE_COMPARE(out, Utility::format("Trade::ObjImporter::mesh(): {}\n", data.message)); CORRADE_COMPARE(out, Utility::format("Trade::ObjImporter::mesh(): {}\n", data.message));
} }
void ObjImporterTest::whitespace() {
/* Using an embedded string to have the whitespace visualized clearer */
Containers::StringView data =
" \t\r # This file has various \t\n"
" #whitespace that should be treated properly \n"
" \r\t o\t \tObject \t name \r\n"
" \t v\r \t 3 5 \t7 \t1\n"
" \rvt 8\t 9\t\t\r0\n"
"\n \r \n"
" vn \t 2 \t 4 \r6\n"
"\n \t \r\n"
/* There's no space expected inside the index tuple */
" f\t 1/1/1 \r1/1/1 \t1/1/1 \t\r\n"
"\n\n"
"\n";
Containers::Pointer<AbstractImporter> importer = _manager.instantiate("ObjImporter");
CORRADE_VERIFY(importer->openData(data));
CORRADE_COMPARE(importer->meshCount(), 1);
/* The name is trimmed from both ends, but internal whitespace is left
intact */
CORRADE_COMPARE(importer->meshName(0), "Object \t name");
const Containers::Optional<MeshData> mesh = importer->mesh(0);
CORRADE_VERIFY(mesh);
CORRADE_COMPARE(mesh->primitive(), MeshPrimitive::Triangles);
CORRADE_COMPARE(mesh->attributeCount(), 3);
CORRADE_COMPARE_AS(mesh->attribute<Vector3>(MeshAttribute::Position),
Containers::arrayView<Vector3>({
{3.0f, 5.0f, 7.0f}
}), TestSuite::Compare::Container);
CORRADE_COMPARE_AS(mesh->attribute<Vector2>(MeshAttribute::TextureCoordinates),
Containers::arrayView<Vector2>({
{8.0f, 9.0f}
}), TestSuite::Compare::Container);
CORRADE_COMPARE_AS(mesh->attribute<Vector3>(MeshAttribute::Normal),
Containers::arrayView<Vector3>({
{2.0f, 4.0f, 6.0f}
}), TestSuite::Compare::Container);
CORRADE_VERIFY(mesh->isIndexed());
CORRADE_COMPARE(mesh->indexType(), MeshIndexType::UnsignedInt);
CORRADE_COMPARE_AS(mesh->indices<UnsignedInt>(),
Containers::arrayView<UnsignedInt>({0, 0, 0}),
TestSuite::Compare::Container);
}
void ObjImporterTest::openTwice() { void ObjImporterTest::openTwice() {
Containers::Pointer<AbstractImporter> importer = _manager.instantiate("ObjImporter"); Containers::Pointer<AbstractImporter> importer = _manager.instantiate("ObjImporter");

1
src/MagnumPlugins/ObjImporter/Test/invalid-keyword.obj

@ -1 +0,0 @@
bleh

6
src/MagnumPlugins/ObjImporter/Test/invalid-keywords.obj

@ -0,0 +1,6 @@
o unknown keyword
bleh
o no space between vertex keyword and number
vt3 1
o no space between index keyword and number
p6

45
src/MagnumPlugins/ObjImporter/Test/invalid-number-count.obj

@ -1,29 +1,62 @@
o no position component at all
v
o two-component position o two-component position
v 0.5 1.0 v 0.5 1.0
o five-component position with optional fourth component o five-component position with optional fourth component
v 0.5 1 2 0.0 3.5 v 0.5 1 2 1.0 3.5
o no texture coordinate component at all
vt
o one-component texture coordinate
v 0.5 1 2
vt 0.5
o four-component texture coordinate with optional third component
v 0.5 1 2
vt 0.5 1.0 0.0 7.4
o no normal component at all
vn
o two-component normal
v 0.5 1 2
vn 0.5 0.0
o four-component normal o four-component normal
v 0.5 1 2 v 0.5 1 2
vn 0.5 1.0 2.3 7.4 vn 0.5 1.0 2.3 7.4
o no index at all
p
o no index before first slash
p /
o no index after first slash
p 8/
o no index after second slash
p 8//
o four-component index tuple o four-component index tuple
v 1 2 3 v 1 2 3
p 4/1/1/1 p 8/1/1/1
o point with two indices o point with two indices
v 1 2 3 v 1 2 3
p 5 5 p 9 9
o line with one index o line with one index
v 1 2 3 v 1 2 3
l 6 l 10
o triangle with two indices o triangle with two indices
v 1 2 3 v 1 2 3
f 7 7 f 11 11
o quad o quad
v 1 2 3 v 1 2 3
f 8 8 8 8 f 12 12 12 12

19
src/MagnumPlugins/ObjImporter/Test/invalid-numbers.obj

@ -1,6 +1,15 @@
o too long float literal
v 1234.567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567 0 0
o too long integer literal
p 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678
o too large integer literal
p 4294967296
o invalid float literal o invalid float literal
v 1 bleh 2 v 1 bleh 2
p 1 p 2
o invalid integer literal o invalid integer literal
v 1 0 2 v 1 0 2
@ -8,8 +17,8 @@ p bleh
o position index out of range o position index out of range
v 1 0 2 v 1 0 2
# Should be 3 # Should be 4
p 1 p 3
o texture index out of range o texture index out of range
v 1 0 2 v 1 0 2
@ -17,14 +26,14 @@ vt 0 1
vt 0 1 vt 0 1
vt 0 1 vt 0 1
# Should be 4/3 # Should be 4/3
p 4/4 p 5/4
o normal index out of range o normal index out of range
v 1 0 2 v 1 0 2
vn 0 0 1 vn 0 0 1
vn 0 0 1 vn 0 0 1
# Should be 5/2 # Should be 5/2
p 5//3 p 6//3
o zero index o zero index
v 1 0 2 v 1 0 2

Loading…
Cancel
Save