feat(v3.2): 3MF format import/export

This commit is contained in:
茂之钳
2026-07-24 11:30:36 +00:00
parent decb866097
commit a8052c5aae
5 changed files with 697 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "vde/mesh/halfedge_mesh.h"
#include <string>
#include <vector>
namespace vde::foundation {
/// 3MF mesh data with optional vertex colors
struct ThreeMFMesh {
mesh::HalfedgeMesh mesh;
std::string name;
std::vector<float> vertex_colors; ///< optional RGB per vertex (3*nv floats)
};
/// Read a 3MF file and return all meshes
[[nodiscard]] std::vector<ThreeMFMesh> 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<ThreeMFMesh>& meshes,
const std::string& creator = "ViewDesignEngine");
} // namespace vde::foundation
+1
View File
@@ -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
+477
View File
@@ -0,0 +1,477 @@
#include "vde/foundation/io_3mf.h"
#include <fstream>
#include <sstream>
#include <cstring>
#include <cstdint>
#include <array>
#include <map>
#include <algorithm>
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<CentralDirEntry> cd_entries;
std::vector<std::string> 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<const uint8_t*>(content.data()), content.size());
uint32_t size = static_cast<uint32_t>(content.size());
uint32_t offset = static_cast<uint32_t>(file.tellp());
LocalFileHeader lfh;
lfh.crc32 = crc;
lfh.compressed_size = size;
lfh.uncompressed_size = size;
lfh.filename_len = static_cast<uint16_t>(name.size());
lfh.extra_len = 0;
file.write(reinterpret_cast<const char*>(&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<uint16_t>(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<uint32_t>(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<const char*>(&cde), sizeof(cde));
file.write(cd_filenames[i].data(), cd_filenames[i].size());
cd_size += static_cast<uint32_t>(sizeof(cde) + cd_filenames[i].size());
}
EOCD eocd;
eocd.entries_disk = static_cast<uint16_t>(cd_entries.size());
eocd.total_entries = static_cast<uint16_t>(cd_entries.size());
eocd.cd_size = cd_size;
eocd.cd_offset = cd_offset;
file.write(reinterpret_cast<const char*>(&eocd), sizeof(eocd));
file.close();
return true;
}
};
/// Read a ZIP archive (store only, no compression)
/// Returns map: filename → content
std::map<std::string, std::string> read_zip(const std::string& path) {
std::map<std::string, std::string> 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<decltype(fsize)>(0, fsize - 65557);
file.seekg(search_start, std::ios::beg);
std::vector<uint8_t> tail(static_cast<size_t>(fsize - search_start));
file.read(reinterpret_cast<char*>(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<uint32_t>(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<char*>(&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<std::string> filenames(num_entries);
std::vector<uint32_t> local_offsets(num_entries);
for (uint16_t i = 0; i < num_entries; ++i) {
CentralDirEntry cde;
file.read(reinterpret_cast<char*>(&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<char*>(&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 += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&apos;"; break;
default: out += c;
}
}
return out;
}
static std::string make_content_types_xml() {
return R"(<?xml version="1.0" encoding="UTF-8"?>)"
"\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n"
" <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n"
" <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n"
"</Types>\n";
}
static std::string make_model_xml(const std::vector<ThreeMFMesh>& meshes,
const std::string& creator) {
std::ostringstream xml;
xml << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
xml << "<model unit=\"millimeter\" xml:lang=\"en-US\"\n";
xml << " xmlns=\"http://schemas.microsoft.com/3dmanufacturing/core/2015/02\">\n";
xml << " <metadata name=\"Application\">" << xml_escape(creator) << "</metadata>\n";
xml << " <resources>\n";
int obj_id = 1;
for (const auto& m : meshes) {
xml << " <object id=\"" << obj_id << "\" type=\"model\"";
if (!m.name.empty()) {
xml << " name=\"" << xml_escape(m.name) << "\"";
}
xml << ">\n";
xml << " <mesh>\n";
xml << " <vertices>\n";
size_t nv = m.mesh.num_vertices();
for (size_t vi = 0; vi < nv; ++vi) {
const auto& p = m.mesh.vertex(vi);
xml << " <vertex x=\"" << p.x() << "\" y=\"" << p.y()
<< "\" z=\"" << p.z() << "\"/>\n";
}
xml << " </vertices>\n";
xml << " <triangles>\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 << " <triangle v1=\"" << verts[0]
<< "\" v2=\"" << verts[1]
<< "\" v3=\"" << verts[2] << "\"/>\n";
}
}
xml << " </triangles>\n";
xml << " </mesh>\n";
xml << " </object>\n";
++obj_id;
}
xml << " </resources>\n";
xml << " <build>\n";
for (int i = 1; i < obj_id; ++i) {
xml << " <item objectid=\"" << i << "\"/>\n";
}
xml << " </build>\n";
xml << "</model>\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 <object><mesh>
// Collect vertex coordinates as triplets of strings
std::vector<std::array<double, 3>> verts;
std::vector<std::array<int, 3>> 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("<object ") != std::string::npos) {
object_name = xml_get_attr(line, "name");
}
if (line.find("<vertices>") != std::string::npos) {
in_vertices = true;
continue;
}
if (line.find("</vertices>") != std::string::npos) {
in_vertices = false;
continue;
}
if (line.find("<triangles>") != std::string::npos) {
in_triangles = true;
continue;
}
if (line.find("</triangles>") != std::string::npos) {
in_triangles = false;
continue;
}
if (in_vertices && line.find("<vertex ") != std::string::npos) {
auto xs = xml_get_attr(line, "x");
auto ys = xml_get_attr(line, "y");
auto zs = xml_get_attr(line, "z");
if (!xs.empty() && !ys.empty() && !zs.empty()) {
verts.push_back({std::stod(xs), std::stod(ys), std::stod(zs)});
}
}
if (in_triangles && line.find("<triangle ") != std::string::npos) {
auto v1s = xml_get_attr(line, "v1");
auto v2s = xml_get_attr(line, "v2");
auto v3s = xml_get_attr(line, "v3");
if (!v1s.empty() && !v2s.empty() && !v3s.empty()) {
tris.push_back({std::stoi(v1s), std::stoi(v2s), std::stoi(v3s)});
}
}
}
// Build mesh
std::vector<mesh::Point3D> pts;
pts.reserve(verts.size());
for (const auto& v : verts) {
pts.emplace_back(v[0], v[1], v[2]);
}
std::vector<std::array<int, 3>> faces;
faces.reserve(tris.size());
for (const auto& t : tris) {
// Validate indices
if (t[0] >= 0 && static_cast<size_t>(t[0]) < verts.size() &&
t[1] >= 0 && static_cast<size_t>(t[1]) < verts.size() &&
t[2] >= 0 && static_cast<size_t>(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<ThreeMFMesh> 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<std::string> 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<ThreeMFMesh> 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<ThreeMFMesh>& 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
+1
View File
@@ -1 +1,2 @@
add_vde_test(test_io_gltf)
add_vde_test(test_io_3mf)
+191
View File
@@ -0,0 +1,191 @@
#include <gtest/gtest.h>
#include <cstdio>
#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<char>(f)),
std::istreambuf_iterator<char>());
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);
}