Skip to content

Commit 71a40e4

Browse files
Copilotsyoyo
andauthored
Add Windows long path and Unicode file path support for .obj/.mtl loading (#415)
* Initial plan * Add Windows long path and Unicode file path support for .obj/.mtl loading Co-authored-by: syoyo <18676+syoyo@users.noreply.github.com> * Add unit tests for UTF-8 path and long path loading, with minimal test model files Co-authored-by: syoyo <18676+syoyo@users.noreply.github.com> * Fix Windows link error: add pragma comment for Advapi32.lib Co-authored-by: syoyo <18676+syoyo@users.noreply.github.com> * Fix Windows API correctness: wide temp path, wide mkdir, long-path copy, slash normalization Co-authored-by: syoyo <18676+syoyo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: syoyo <18676+syoyo@users.noreply.github.com> Co-authored-by: Syoyo Fujita <syoyo@lighttransport.com>
1 parent c20c973 commit 71a40e4

File tree

4 files changed

+300
-0
lines changed

4 files changed

+300
-0
lines changed

models/utf8-path-test.mtl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
newmtl Material
2+
Ka 0 0 0
3+
Kd 0.8 0.8 0.8
4+
Ks 0 0 0

models/utf8-path-test.obj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
mtllib utf8-path-test.mtl
2+
v 0.0 0.0 0.0
3+
v 1.0 0.0 0.0
4+
v 0.0 1.0 0.0
5+
usemtl Material
6+
f 1 2 3

tests/tester.cc

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@
2929
#include <fstream>
3030
#include <iostream>
3131
#include <sstream>
32+
#include <string>
33+
34+
#ifdef _WIN32
35+
#include <direct.h> // _mkdir
36+
#include <windows.h> // GetTempPathW, CreateDirectoryW, RegOpenKeyExA
37+
#include <winreg.h> // registry constants
38+
#pragma comment(lib, "Advapi32.lib") // RegOpenKeyExA, RegQueryValueExA, RegCloseKey
39+
40+
// Converts a UTF-16 wide string to a UTF-8 std::string.
41+
static std::string WcharToUTF8(const std::wstring &wstr) {
42+
if (wstr.empty()) return std::string();
43+
int len = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1,
44+
NULL, 0, NULL, NULL);
45+
if (len <= 0) return std::string();
46+
std::string str(static_cast<size_t>(len - 1), '\0');
47+
WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &str[0], len, NULL, NULL);
48+
return str;
49+
}
50+
#else
51+
#include <cerrno>
52+
#include <sys/stat.h> // mkdir
53+
#endif
3254

3355
template <typename T>
3456
static bool FloatEquals(const T& a, const T& b) {
@@ -399,6 +421,190 @@ static bool TestStreamLoadObj() {
399421

400422
const char* gMtlBasePath = "../models/";
401423

424+
// ---------------------------------------------------------------------------
425+
// Helpers for path-related tests
426+
// ---------------------------------------------------------------------------
427+
428+
// Creates a single directory level. Returns true on success or if it already exists.
429+
static bool MakeDir(const std::string& path) {
430+
#ifdef _WIN32
431+
// Use the wide-character API so that paths with non-ASCII characters work.
432+
std::wstring wpath = UTF8ToWchar(path);
433+
if (wpath.empty()) return false;
434+
if (CreateDirectoryW(wpath.c_str(), NULL) != 0) return true;
435+
return GetLastError() == ERROR_ALREADY_EXISTS;
436+
#else
437+
return mkdir(path.c_str(), 0755) == 0 || errno == EEXIST;
438+
#endif
439+
}
440+
441+
// Removes a directory and all its contents.
442+
// NOTE: All callers pass paths that are fully constructed within this test
443+
// file from hardcoded string literals, so there is no user-controlled input
444+
// that could be used for command injection.
445+
static void RemoveTestDir(const std::string& path) {
446+
#ifdef _WIN32
447+
std::string cmd = "rd /s /q \"" + path + "\"";
448+
#else
449+
std::string cmd = "rm -rf '" + path + "'";
450+
#endif
451+
if (system(cmd.c_str()) != 0) { /* cleanup failure is non-fatal */ }
452+
}
453+
454+
// Copies a file in binary mode. The destination path is taken as UTF-8.
455+
// On Windows, LongPathW(UTF8ToWchar()) is used so that long paths (> MAX_PATH)
456+
// are handled, exercising the same conversion that tinyobjloader itself uses.
457+
static bool CopyTestFile(const std::string& src, const std::string& dst) {
458+
std::ifstream in(src.c_str(), std::ios::binary);
459+
if (!in) return false;
460+
#ifdef _WIN32
461+
// Apply long-path prefix so that the copy works even for paths > MAX_PATH.
462+
std::ofstream out(LongPathW(UTF8ToWchar(dst)).c_str(), std::ios::binary);
463+
#else
464+
std::ofstream out(dst.c_str(), std::ios::binary);
465+
#endif
466+
if (!out) return false;
467+
out << in.rdbuf();
468+
return !out.fail();
469+
}
470+
471+
#ifdef _WIN32
472+
// Returns true if Windows has the system-wide long path support enabled
473+
// (HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1).
474+
static bool IsWindowsLongPathEnabled() {
475+
HKEY hKey;
476+
DWORD value = 0;
477+
DWORD size = sizeof(DWORD);
478+
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE,
479+
"SYSTEM\\CurrentControlSet\\Control\\FileSystem", 0,
480+
KEY_READ, &hKey) == ERROR_SUCCESS) {
481+
RegQueryValueExA(hKey, "LongPathsEnabled", NULL, NULL,
482+
reinterpret_cast<LPBYTE>(&value), &size);
483+
RegCloseKey(hKey);
484+
}
485+
return value != 0;
486+
}
487+
#endif // _WIN32
488+
489+
// ---------------------------------------------------------------------------
490+
// Path-related tests
491+
// ---------------------------------------------------------------------------
492+
493+
// Test: load .obj/.mtl from a directory path containing UTF-8 non-ASCII
494+
// characters. On Windows our code converts the UTF-8 path to UTF-16 before
495+
// calling the file API. On Linux, UTF-8 paths are handled natively.
496+
void test_load_obj_from_utf8_path() {
497+
// Build a temp directory name that contains the UTF-8 encoded character é
498+
// (U+00E9, encoded as \xC3\xA9 in UTF-8).
499+
#ifdef _WIN32
500+
wchar_t wtmpbuf[MAX_PATH];
501+
GetTempPathW(MAX_PATH, wtmpbuf);
502+
std::string test_dir =
503+
WcharToUTF8(wtmpbuf) + "tinyobj_utf8_\xc3\xa9_test\\";
504+
#else
505+
std::string test_dir = "/tmp/tinyobj_utf8_\xc3\xa9_test/";
506+
#endif
507+
508+
if (!MakeDir(test_dir)) {
509+
std::cout << "SKIPPED: Cannot create Unicode temp directory: " << test_dir
510+
<< "\n";
511+
return;
512+
}
513+
514+
const std::string obj_dst = test_dir + "utf8-path-test.obj";
515+
const std::string mtl_dst = test_dir + "utf8-path-test.mtl";
516+
517+
if (!CopyTestFile("../models/utf8-path-test.obj", obj_dst) ||
518+
!CopyTestFile("../models/utf8-path-test.mtl", mtl_dst)) {
519+
RemoveTestDir(test_dir);
520+
TEST_CHECK_(false, "Failed to copy test files to Unicode temp directory");
521+
return;
522+
}
523+
524+
tinyobj::ObjReader reader;
525+
bool ret = reader.ParseFromFile(obj_dst);
526+
527+
RemoveTestDir(test_dir);
528+
529+
if (!reader.Warning().empty())
530+
std::cout << "WARN: " << reader.Warning() << "\n";
531+
if (!reader.Error().empty())
532+
std::cerr << "ERR: " << reader.Error() << "\n";
533+
534+
TEST_CHECK(ret == true);
535+
TEST_CHECK(reader.GetShapes().size() == 1);
536+
TEST_CHECK(reader.GetMaterials().size() == 1);
537+
}
538+
539+
// Test: load .obj/.mtl from a path whose total length exceeds MAX_PATH (260).
540+
// On Windows, tinyobjloader prepends the \\?\ extended-length path prefix so
541+
// that the file can be opened even on systems that have the OS-wide long path
542+
// support enabled. The test is skipped when that support is not active.
543+
// On Linux, long paths work natively; this test verifies no regression.
544+
void test_load_obj_from_long_path() {
545+
#ifdef _WIN32
546+
if (!IsWindowsLongPathEnabled()) {
547+
std::cout
548+
<< "SKIPPED: Windows long path support (LongPathsEnabled) is not "
549+
"enabled\n";
550+
return;
551+
}
552+
wchar_t wtmpbuf[MAX_PATH];
553+
GetTempPathW(MAX_PATH, wtmpbuf);
554+
std::string base = WcharToUTF8(wtmpbuf); // e.g. "C:\Users\...\Temp\"
555+
const char path_sep = '\\';
556+
#else
557+
std::string base = "/tmp/";
558+
const char path_sep = '/';
559+
#endif
560+
561+
// Create a two-level directory where the deepest directory name is 250
562+
// characters long. Combined with the base path and the filename
563+
// "utf8-path-test.obj" (18 chars) the total file path comfortably exceeds
564+
// MAX_PATH (260) on all supported platforms.
565+
std::string test_root = base + "tinyobj_lp_test" + path_sep;
566+
std::string long_subdir = test_root + std::string(250, 'a') + path_sep;
567+
std::string obj_path = long_subdir + "utf8-path-test.obj";
568+
569+
// obj_path must exceed MAX_PATH for the test to be meaningful.
570+
// (On a typical Windows installation it is ~320 chars; on Linux ~287 chars.)
571+
if (obj_path.size() <= 260) {
572+
std::cout << "SKIPPED: generated path (" << obj_path.size()
573+
<< " chars) does not exceed MAX_PATH=260\n";
574+
return;
575+
}
576+
577+
if (!MakeDir(test_root) || !MakeDir(long_subdir)) {
578+
RemoveTestDir(test_root);
579+
std::cout << "SKIPPED: Cannot create long-path temp directory: "
580+
<< long_subdir << "\n";
581+
return;
582+
}
583+
584+
if (!CopyTestFile("../models/utf8-path-test.obj",
585+
long_subdir + "utf8-path-test.obj") ||
586+
!CopyTestFile("../models/utf8-path-test.mtl",
587+
long_subdir + "utf8-path-test.mtl")) {
588+
RemoveTestDir(test_root);
589+
TEST_CHECK_(false, "Failed to copy test files to long-path directory");
590+
return;
591+
}
592+
593+
tinyobj::ObjReader reader;
594+
bool ret = reader.ParseFromFile(obj_path);
595+
596+
RemoveTestDir(test_root);
597+
598+
if (!reader.Warning().empty())
599+
std::cout << "WARN: " << reader.Warning() << "\n";
600+
if (!reader.Error().empty())
601+
std::cerr << "ERR: " << reader.Error() << "\n";
602+
603+
TEST_CHECK(ret == true);
604+
TEST_CHECK(reader.GetShapes().size() == 1);
605+
TEST_CHECK(reader.GetMaterials().size() == 1);
606+
}
607+
402608
void test_cornell_box() {
403609
TEST_CHECK(true == TestLoadObj("../models/cornell_box.obj", gMtlBasePath));
404610
}
@@ -1747,6 +1953,8 @@ TEST_LIST = {
17471953
test_default_kd_for_multiple_materials_issue391},
17481954
{"test_removeUtf8Bom", test_removeUtf8Bom},
17491955
{"test_loadObj_with_BOM", test_loadObj_with_BOM},
1956+
{"test_load_obj_from_utf8_path", test_load_obj_from_utf8_path},
1957+
{"test_load_obj_from_long_path", test_load_obj_from_long_path},
17501958
{"test_loadObjWithCallback_with_BOM", test_loadObjWithCallback_with_BOM},
17511959
{"test_texcoord_w_component", test_texcoord_w_component},
17521960
{"test_texcoord_w_mixed_component", test_texcoord_w_mixed_component},

tiny_obj_loader.h

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,16 @@ bool ParseTextureNameAndOption(std::string *texname, texture_option_t *texopt,
667667
#include <sstream>
668668
#include <utility>
669669

670+
#ifdef _WIN32
671+
#ifndef WIN32_LEAN_AND_MEAN
672+
#define WIN32_LEAN_AND_MEAN
673+
#endif
674+
#ifndef NOMINMAX
675+
#define NOMINMAX
676+
#endif
677+
#include <windows.h>
678+
#endif
679+
670680
#ifdef TINYOBJLOADER_USE_MAPBOX_EARCUT
671681

672682
#ifdef TINYOBJLOADER_DONOT_INCLUDE_MAPBOX_EARCUT
@@ -690,6 +700,66 @@ bool ParseTextureNameAndOption(std::string *texname, texture_option_t *texopt,
690700

691701
#endif // TINYOBJLOADER_USE_MAPBOX_EARCUT
692702

703+
#ifdef _WIN32
704+
// Converts a UTF-8 encoded string to a UTF-16 wide string for use with
705+
// Windows file APIs that support Unicode paths (including paths longer than
706+
// MAX_PATH when combined with the extended-length path prefix).
707+
static std::wstring UTF8ToWchar(const std::string &str) {
708+
if (str.empty()) return std::wstring();
709+
int size_needed =
710+
MultiByteToWideChar(CP_UTF8, 0, str.c_str(),
711+
static_cast<int>(str.size()), NULL, 0);
712+
if (size_needed == 0) return std::wstring();
713+
std::wstring wstr(static_cast<size_t>(size_needed), L'\0');
714+
int result =
715+
MultiByteToWideChar(CP_UTF8, 0, str.c_str(),
716+
static_cast<int>(str.size()), &wstr[0], size_needed);
717+
if (result == 0) return std::wstring();
718+
return wstr;
719+
}
720+
721+
// Prepends the Windows extended-length path prefix ("\\?\") to an absolute
722+
// path when the path length meets or exceeds MAX_PATH (260 characters).
723+
// This allows Windows APIs to handle paths up to 32767 characters long.
724+
// UNC paths (starting with "\\") are converted to "\\?\UNC\" form.
725+
static std::wstring LongPathW(const std::wstring &wpath) {
726+
const std::wstring kLongPathPrefix = L"\\\\?\\";
727+
const std::wstring kUNCPrefix = L"\\\\";
728+
const std::wstring kLongUNCPathPrefix = L"\\\\?\\UNC\\";
729+
730+
// Already has the extended-length prefix; return as-is.
731+
if (wpath.size() >= kLongPathPrefix.size() &&
732+
wpath.substr(0, kLongPathPrefix.size()) == kLongPathPrefix) {
733+
return wpath;
734+
}
735+
736+
// Only add the prefix when the path is long enough to require it.
737+
if (wpath.size() < MAX_PATH) {
738+
return wpath;
739+
}
740+
741+
// Normalize forward slashes to backslashes: the extended-length "\\?\"
742+
// prefix requires backslash separators only.
743+
std::wstring normalized = wpath;
744+
for (std::wstring::size_type i = 0; i < normalized.size(); ++i) {
745+
if (normalized[i] == L'/') normalized[i] = L'\\';
746+
}
747+
748+
// UNC path: "\\server\share\..." -> "\\?\UNC\server\share\..."
749+
if (normalized.size() >= kUNCPrefix.size() &&
750+
normalized.substr(0, kUNCPrefix.size()) == kUNCPrefix) {
751+
return kLongUNCPathPrefix + normalized.substr(kUNCPrefix.size());
752+
}
753+
754+
// Absolute path with drive letter: "C:\..." -> "\\?\C:\..."
755+
if (normalized.size() >= 2 && normalized[1] == L':') {
756+
return kLongPathPrefix + normalized;
757+
}
758+
759+
return normalized;
760+
}
761+
#endif // _WIN32
762+
693763
namespace tinyobj {
694764

695765
MaterialReader::~MaterialReader() {}
@@ -2503,7 +2573,11 @@ bool MaterialFileReader::operator()(const std::string &matId,
25032573
for (size_t i = 0; i < paths.size(); i++) {
25042574
std::string filepath = JoinPath(paths[i], matId);
25052575

2576+
#ifdef _WIN32
2577+
std::ifstream matIStream(LongPathW(UTF8ToWchar(filepath)).c_str());
2578+
#else
25062579
std::ifstream matIStream(filepath.c_str());
2580+
#endif
25072581
if (matIStream) {
25082582
LoadMtl(matMap, materials, &matIStream, warn, err);
25092583

@@ -2521,7 +2595,11 @@ bool MaterialFileReader::operator()(const std::string &matId,
25212595

25222596
} else {
25232597
std::string filepath = matId;
2598+
#ifdef _WIN32
2599+
std::ifstream matIStream(LongPathW(UTF8ToWchar(filepath)).c_str());
2600+
#else
25242601
std::ifstream matIStream(filepath.c_str());
2602+
#endif
25252603
if (matIStream) {
25262604
LoadMtl(matMap, materials, &matIStream, warn, err);
25272605

@@ -2571,7 +2649,11 @@ bool LoadObj(attrib_t *attrib, std::vector<shape_t> *shapes,
25712649

25722650
std::stringstream errss;
25732651

2652+
#ifdef _WIN32
2653+
std::ifstream ifs(LongPathW(UTF8ToWchar(filename)).c_str());
2654+
#else
25742655
std::ifstream ifs(filename);
2656+
#endif
25752657
if (!ifs) {
25762658
errss << "Cannot open file [" << filename << "]\n";
25772659
if (err) {

0 commit comments

Comments
 (0)