diff --git a/include/vde/foundation/io_3mf.h b/include/vde/foundation/io_3mf.h new file mode 100644 index 0000000..bc0f66f --- /dev/null +++ b/include/vde/foundation/io_3mf.h @@ -0,0 +1,27 @@ +#pragma once +#include "vde/mesh/halfedge_mesh.h" +#include +#include + +namespace vde::foundation { + +/// 3MF mesh data with optional vertex colors +struct ThreeMFMesh { + mesh::HalfedgeMesh mesh; + std::string name; + std::vector vertex_colors; ///< optional RGB per vertex (3*nv floats) +}; + +/// Read a 3MF file and return all meshes +[[nodiscard]] std::vector read_3mf(const std::string& filepath); + +/// Write meshes to a 3MF file +/// @param filepath Output .3mf file +/// @param meshes Mesh list to export +/// @param creator Application name (stored in metadata) +/// @return true on success +bool write_3mf(const std::string& filepath, + const std::vector& meshes, + const std::string& creator = "ViewDesignEngine"); + +} // namespace vde::foundation diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 34e2bbe..8c396cb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(vde_foundation STATIC foundation/serializer.cpp foundation/io_ply.cpp foundation/io_gltf.cpp + foundation/io_3mf.cpp ) target_include_directories(vde_foundation PUBLIC ${CMAKE_SOURCE_DIR}/include diff --git a/src/foundation/io_3mf.cpp b/src/foundation/io_3mf.cpp new file mode 100644 index 0000000..f5e9040 --- /dev/null +++ b/src/foundation/io_3mf.cpp @@ -0,0 +1,477 @@ +#include "vde/foundation/io_3mf.h" +#include +#include +#include +#include +#include +#include +#include + +namespace vde::foundation { +namespace { + +// ============================================================ +// Minimal ZIP (store-only) — no external dependencies +// ============================================================ + +namespace zip { + +#pragma pack(push, 1) +struct LocalFileHeader { + uint32_t signature = 0x04034b50; + uint16_t version_needed = 20; + uint16_t flags = 0; + uint16_t compression = 0; // 0 = stored + uint16_t mod_time = 0; + uint16_t mod_date = 0; + uint32_t crc32 = 0; + uint32_t compressed_size = 0; + uint32_t uncompressed_size = 0; + uint16_t filename_len = 0; + uint16_t extra_len = 0; +}; + +struct CentralDirEntry { + uint32_t signature = 0x02014b50; + uint16_t version_made = 20; + uint16_t version_needed = 20; + uint16_t flags = 0; + uint16_t compression = 0; + uint16_t mod_time = 0; + uint16_t mod_date = 0; + uint32_t crc32 = 0; + uint32_t compressed_size = 0; + uint32_t uncompressed_size = 0; + uint16_t filename_len = 0; + uint16_t extra_len = 0; + uint16_t comment_len = 0; + uint16_t disk_start = 0; + uint16_t internal_attr = 0; + uint32_t external_attr = 0; + uint32_t local_header_offset = 0; +}; + +struct EOCD { + uint32_t signature = 0x06054b50; + uint16_t disk_num = 0; + uint16_t disk_cd = 0; + uint16_t entries_disk = 0; + uint16_t total_entries = 0; + uint32_t cd_size = 0; + uint32_t cd_offset = 0; + uint16_t comment_len = 0; +}; +#pragma pack(pop) + +static uint32_t crc32_table[256]; +static bool crc32_table_init = false; + +static void init_crc32_table() { + if (crc32_table_init) return; + for (uint32_t i = 0; i < 256; ++i) { + uint32_t crc = i; + for (int j = 0; j < 8; ++j) { + crc = (crc >> 1) ^ (crc & 1 ? 0xEDB88320u : 0); + } + crc32_table[i] = crc; + } + crc32_table_init = true; +} + +static uint32_t calc_crc32(const uint8_t* data, size_t len) { + init_crc32_table(); + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; ++i) { + crc = crc32_table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); + } + return crc ^ 0xFFFFFFFFu; +} + +/// Write a ZIP archive (store only, no compression) +struct ZipWriter { + std::ofstream file; + std::vector cd_entries; + std::vector cd_filenames; + + bool open(const std::string& path) { + file.open(path, std::ios::binary); + return file.good(); + } + + bool add_file(const std::string& name, const std::string& content) { + uint32_t crc = calc_crc32(reinterpret_cast(content.data()), content.size()); + uint32_t size = static_cast(content.size()); + uint32_t offset = static_cast(file.tellp()); + + LocalFileHeader lfh; + lfh.crc32 = crc; + lfh.compressed_size = size; + lfh.uncompressed_size = size; + lfh.filename_len = static_cast(name.size()); + lfh.extra_len = 0; + + file.write(reinterpret_cast(&lfh), sizeof(lfh)); + file.write(name.data(), name.size()); + file.write(content.data(), content.size()); + + CentralDirEntry cde; + cde.crc32 = crc; + cde.compressed_size = size; + cde.uncompressed_size = size; + cde.filename_len = static_cast(name.size()); + cde.extra_len = 0; + cde.comment_len = 0; + cde.local_header_offset = offset; + + cd_entries.push_back(cde); + cd_filenames.push_back(name); + return true; + } + + bool close() { + uint32_t cd_offset = static_cast(file.tellp()); + uint32_t cd_size = 0; + + for (size_t i = 0; i < cd_entries.size(); ++i) { + const auto& cde = cd_entries[i]; + file.write(reinterpret_cast(&cde), sizeof(cde)); + file.write(cd_filenames[i].data(), cd_filenames[i].size()); + cd_size += static_cast(sizeof(cde) + cd_filenames[i].size()); + } + + EOCD eocd; + eocd.entries_disk = static_cast(cd_entries.size()); + eocd.total_entries = static_cast(cd_entries.size()); + eocd.cd_size = cd_size; + eocd.cd_offset = cd_offset; + file.write(reinterpret_cast(&eocd), sizeof(eocd)); + file.close(); + return true; + } +}; + +/// Read a ZIP archive (store only, no compression) +/// Returns map: filename → content +std::map read_zip(const std::string& path) { + std::map result; + std::ifstream file(path, std::ios::binary); + if (!file) return result; + + // Get file size + file.seekg(0, std::ios::end); + auto fsize = file.tellg(); + file.seekg(0, std::ios::beg); + + // Find EOCD signature backwards from end of file + // EOCD: signature(4) + 18 fixed bytes + variable comment + // We search from end back up to 64KB (max comment length) + uint32_t eocd_offset = 0; + bool found = false; + auto search_start = std::max(0, fsize - 65557); + file.seekg(search_start, std::ios::beg); + std::vector tail(static_cast(fsize - search_start)); + file.read(reinterpret_cast(tail.data()), tail.size()); + + for (size_t i = 0; i + 4 <= tail.size(); ++i) { + uint32_t sig; + std::memcpy(&sig, tail.data() + i, 4); + if (sig == 0x06054b50u) { + eocd_offset = static_cast(search_start + i); + found = true; + break; + } + } + if (!found) return result; + + // Parse EOCD + file.seekg(eocd_offset, std::ios::beg); + EOCD eocd; + file.read(reinterpret_cast(&eocd), sizeof(eocd)); + if (eocd.signature != 0x06054b50u) return result; + + uint16_t num_entries = eocd.total_entries; + uint32_t cd_offset = eocd.cd_offset; + + // Read central directory entries + file.seekg(cd_offset, std::ios::beg); + std::vector filenames(num_entries); + std::vector local_offsets(num_entries); + + for (uint16_t i = 0; i < num_entries; ++i) { + CentralDirEntry cde; + file.read(reinterpret_cast(&cde), sizeof(cde)); + if (cde.signature != 0x02014b50u) return result; + + std::string fname(cde.filename_len, '\0'); + file.read(&fname[0], cde.filename_len); + // Skip extra + comment + file.seekg(cde.extra_len + cde.comment_len, std::ios::cur); + + filenames[i] = fname; + local_offsets[i] = cde.local_header_offset; + } + + // Extract files + for (uint16_t i = 0; i < num_entries; ++i) { + file.seekg(local_offsets[i], std::ios::beg); + LocalFileHeader lfh; + file.read(reinterpret_cast(&lfh), sizeof(lfh)); + if (lfh.signature != 0x04034b50u) continue; + + // Skip filename + extra field + file.seekg(lfh.filename_len + lfh.extra_len, std::ios::cur); + + // Read file data + std::string content(lfh.compressed_size, '\0'); + file.read(&content[0], lfh.compressed_size); + result[filenames[i]] = content; + } + + return result; +} + +} // namespace zip + +// ============================================================ +// 3MF XML helpers +// ============================================================ + +static std::string xml_escape(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + switch (c) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '"': out += """; break; + case '\'': out += "'"; break; + default: out += c; + } + } + return out; +} + +static std::string make_content_types_xml() { + return R"()" + "\n\n" + " \n" + " \n" + "\n"; +} + +static std::string make_model_xml(const std::vector& meshes, + const std::string& creator) { + std::ostringstream xml; + xml << "\n"; + xml << "\n"; + xml << " " << xml_escape(creator) << "\n"; + xml << " \n"; + + int obj_id = 1; + for (const auto& m : meshes) { + xml << " \n"; + xml << " \n"; + xml << " \n"; + + size_t nv = m.mesh.num_vertices(); + for (size_t vi = 0; vi < nv; ++vi) { + const auto& p = m.mesh.vertex(vi); + xml << " \n"; + } + xml << " \n"; + xml << " \n"; + + size_t nf = m.mesh.num_faces(); + for (size_t fi = 0; fi < nf; ++fi) { + auto verts = m.mesh.face_vertices(fi); + if (verts.size() >= 3) { + xml << " \n"; + } + } + xml << " \n"; + xml << " \n"; + xml << " \n"; + ++obj_id; + } + + xml << " \n"; + xml << " \n"; + for (int i = 1; i < obj_id; ++i) { + xml << " \n"; + } + xml << " \n"; + xml << "\n"; + return xml.str(); +} + +// Simple XML attribute extractor — avoids full XML parser dependency +static std::string xml_get_attr(const std::string& line, const std::string& attr) { + auto pos = line.find(attr + "=\""); + if (pos == std::string::npos) return {}; + pos += attr.size() + 2; + auto end = line.find('"', pos); + if (end == std::string::npos) return {}; + return line.substr(pos, end - pos); +} + +static ThreeMFMesh parse_model_xml(const std::string& xml) { + ThreeMFMesh result; + std::istringstream iss(xml); + std::string line; + + // We parse vertices and triangles from a single + // Collect vertex coordinates as triplets of strings + std::vector> verts; + std::vector> tris; + bool in_vertices = false, in_triangles = false; + std::string object_name; + + while (std::getline(iss, line)) { + // Trim whitespace + size_t start = 0; + while (start < line.size() && (line[start] == ' ' || line[start] == '\t' || line[start] == '\r')) + ++start; + line = line.substr(start); + if (line.empty() || line.back() == '\r') { + if (!line.empty() && line.back() == '\r') line.pop_back(); + } + + if (line.find("") != std::string::npos) { + in_vertices = true; + continue; + } + if (line.find("") != std::string::npos) { + in_vertices = false; + continue; + } + if (line.find("") != std::string::npos) { + in_triangles = true; + continue; + } + if (line.find("") != std::string::npos) { + in_triangles = false; + continue; + } + + if (in_vertices && line.find(" pts; + pts.reserve(verts.size()); + for (const auto& v : verts) { + pts.emplace_back(v[0], v[1], v[2]); + } + + std::vector> faces; + faces.reserve(tris.size()); + for (const auto& t : tris) { + // Validate indices + if (t[0] >= 0 && static_cast(t[0]) < verts.size() && + t[1] >= 0 && static_cast(t[1]) < verts.size() && + t[2] >= 0 && static_cast(t[2]) < verts.size()) { + faces.push_back(t); + } + } + + result.mesh.build_from_triangles(pts, faces); + result.name = object_name; + return result; +} + +} // anonymous namespace + +// ============================================================ +// Public API +// ============================================================ + +std::vector read_3mf(const std::string& filepath) { + // Check file exists + { + std::ifstream test(filepath, std::ios::binary); + if (!test) return {}; + } + + auto zip_files = zip::read_zip(filepath); + if (zip_files.empty()) return {}; + + // Find model file(s). 3MF spec says: 3D/3dmodel.model + // But could be any .model file or we try known paths + std::vector model_paths; + for (const auto& [name, content] : zip_files) { + // Match .model extension or specific paths + if (name.size() > 6 && name.substr(name.size() - 6) == ".model") { + model_paths.push_back(name); + } else if (name == "3D/3dmodel.model") { + model_paths.push_back(name); + } + } + + // If no explicit .model files found, try common paths + if (model_paths.empty()) { + for (const auto& known : {"3D/3dmodel.model", "3dmodel.model"}) { + if (zip_files.count(known)) { + model_paths.push_back(known); + } + } + } + + std::vector result; + for (const auto& mp : model_paths) { + auto it = zip_files.find(mp); + if (it != zip_files.end()) { + result.push_back(parse_model_xml(it->second)); + } + } + + return result; +} + +bool write_3mf(const std::string& filepath, + const std::vector& meshes, + const std::string& creator) { + if (meshes.empty()) return false; + + // Build archive contents + std::string content_types = make_content_types_xml(); + std::string model_xml = make_model_xml(meshes, creator); + + zip::ZipWriter zw; + if (!zw.open(filepath)) return false; + + zw.add_file("[Content_Types].xml", content_types); + zw.add_file("3D/3dmodel.model", model_xml); + zw.close(); + return true; +} + +} // namespace vde::foundation diff --git a/tests/foundation/CMakeLists.txt b/tests/foundation/CMakeLists.txt index 66999a0..1b77070 100644 --- a/tests/foundation/CMakeLists.txt +++ b/tests/foundation/CMakeLists.txt @@ -1 +1,2 @@ add_vde_test(test_io_gltf) +add_vde_test(test_io_3mf) diff --git a/tests/foundation/test_io_3mf.cpp b/tests/foundation/test_io_3mf.cpp new file mode 100644 index 0000000..df6f0fa --- /dev/null +++ b/tests/foundation/test_io_3mf.cpp @@ -0,0 +1,191 @@ +#include +#include +#include "vde/foundation/io_3mf.h" + +using namespace vde::foundation; +using namespace vde::mesh; + +// Helper: build a tetrahedron +static ThreeMFMesh make_tetrahedron(const std::string& name = "") { + ThreeMFMesh m; + m.name = name; + m.mesh.build_from_triangles( + { + Point3D(0, 0, 0), Point3D(1, 0, 0), + Point3D(0.5, 0.866, 0), Point3D(0.5, 0.289, 0.816) + }, + { + {0, 1, 2}, {0, 3, 1}, {1, 3, 2}, {0, 2, 3} + } + ); + return m; +} + +// Helper: build a cube (8 vertices, 12 triangles) +static ThreeMFMesh make_cube(const std::string& name = "") { + ThreeMFMesh m; + m.name = name; + m.mesh.build_from_triangles( + { + Point3D(0,0,0), Point3D(1,0,0), Point3D(1,1,0), Point3D(0,1,0), + Point3D(0,0,1), Point3D(1,0,1), Point3D(1,1,1), Point3D(0,1,1) + }, + { + {0,1,2}, {0,2,3}, // front + {4,5,6}, {4,6,7}, // back + {0,4,5}, {0,5,1}, // bottom + {2,1,5}, {2,5,6}, // right + {3,2,6}, {3,6,7}, // top + {0,3,7}, {0,7,4} // left + } + ); + return m; +} + +// ── Export / round-trip ───────────────────────────────────── + +TEST(Io3mfTest, ExportThenReadTetrahedron) { + auto orig = make_tetrahedron("Tet"); + const char* path = "/tmp/test_3mf_tet.3mf"; + std::remove(path); + + ASSERT_TRUE(write_3mf(path, {orig}, "VDE-Test")); + + auto loaded = read_3mf(path); + ASSERT_EQ(loaded.size(), 1u); + EXPECT_EQ(loaded[0].mesh.num_vertices(), 4u); + EXPECT_EQ(loaded[0].mesh.num_faces(), 4u); + EXPECT_EQ(loaded[0].name, "Tet"); + + std::remove(path); +} + +TEST(Io3mfTest, ExportThenReadCube) { + auto orig = make_cube("MyBox"); + const char* path = "/tmp/test_3mf_cube.3mf"; + std::remove(path); + + ASSERT_TRUE(write_3mf(path, {orig})); + + auto loaded = read_3mf(path); + ASSERT_EQ(loaded.size(), 1u); + EXPECT_EQ(loaded[0].mesh.num_vertices(), 8u); + EXPECT_EQ(loaded[0].mesh.num_faces(), 12u); + EXPECT_EQ(loaded[0].name, "MyBox"); + + std::remove(path); +} + +TEST(Io3mfTest, NamePreserved) { + auto orig = make_tetrahedron("DesignerPart001"); + const char* path = "/tmp/test_3mf_name.3mf"; + std::remove(path); + + ASSERT_TRUE(write_3mf(path, {orig})); + auto loaded = read_3mf(path); + ASSERT_EQ(loaded.size(), 1u); + EXPECT_EQ(loaded[0].name, "DesignerPart001"); + + std::remove(path); +} + +TEST(Io3mfTest, NameEmptyPreserved) { + auto orig = make_tetrahedron(); // no name + const char* path = "/tmp/test_3mf_noname.3mf"; + std::remove(path); + + ASSERT_TRUE(write_3mf(path, {orig})); + auto loaded = read_3mf(path); + ASSERT_EQ(loaded.size(), 1u); + EXPECT_TRUE(loaded[0].name.empty()); + + std::remove(path); +} + +TEST(Io3mfTest, ReadNonExistentFile) { + auto result = read_3mf("/tmp/nonexistent_xyz_3mf.3mf"); + EXPECT_TRUE(result.empty()); +} + +TEST(Io3mfTest, EmptyMeshesReturnsFalse) { + const char* path = "/tmp/test_3mf_empty.3mf"; + std::remove(path); + EXPECT_FALSE(write_3mf(path, {})); + std::remove(path); +} + +TEST(Io3mfTest, MultipleMeshes) { + auto t1 = make_tetrahedron("PartA"); + auto t2 = make_cube("PartB"); + const char* path = "/tmp/test_3mf_multi.3mf"; + std::remove(path); + + ASSERT_TRUE(write_3mf(path, {t1, t2})); + + auto loaded = read_3mf(path); + EXPECT_EQ(loaded.size(), 2u); + EXPECT_EQ(loaded[0].name, "PartA"); + EXPECT_EQ(loaded[1].name, "PartB"); + EXPECT_EQ(loaded[0].mesh.num_vertices(), 4u); + EXPECT_EQ(loaded[1].mesh.num_vertices(), 8u); + + std::remove(path); +} + +TEST(Io3mfTest, CreatorStoredInMetadata) { + auto orig = make_tetrahedron(); + const char* path = "/tmp/test_3mf_creator.3mf"; + std::remove(path); + + ASSERT_TRUE(write_3mf(path, {orig}, "SuperCAD-2025")); + + // Read raw XML and verify metadata tag + auto zip_content = read_3mf(path); + ASSERT_FALSE(zip_content.empty()); + + // Re-read the raw file to check metadata + std::ifstream f(path, std::ios::binary); + ASSERT_TRUE(f.good()); + std::string raw((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + f.close(); + + // Should contain the creator in metadata + EXPECT_NE(raw.find("SuperCAD-2025"), std::string::npos); + + std::remove(path); +} + +TEST(Io3mfTest, VertexPositionsRoundTrip) { + // Build a mesh with specific coordinates + ThreeMFMesh orig; + orig.name = "PrecisionTest"; + orig.mesh.build_from_triangles( + { + Point3D(1.23456789, 9.87654321, -5.5), + Point3D(0.0, 0.0, 0.0), + Point3D(100.0, -50.0, 25.0) + }, + {{0, 1, 2}} + ); + + const char* path = "/tmp/test_3mf_precision.3mf"; + std::remove(path); + ASSERT_TRUE(write_3mf(path, {orig})); + + auto loaded = read_3mf(path); + ASSERT_EQ(loaded.size(), 1u); + ASSERT_EQ(loaded[0].mesh.num_vertices(), 3u); + + // Check coordinates are close (XML text round-trip precision) + auto& v0 = loaded[0].mesh.vertex(0); + auto& v1 = loaded[0].mesh.vertex(1); + auto& v2 = loaded[0].mesh.vertex(2); + EXPECT_NEAR(v0.x(), 1.23456789, 1e-6); + EXPECT_NEAR(v0.y(), 9.87654321, 1e-6); + EXPECT_NEAR(v0.z(), -5.5, 1e-6); + EXPECT_NEAR(v1.x(), 0.0, 1e-12); + EXPECT_NEAR(v2.x(), 100.0, 1e-12); + + std::remove(path); +}