feat(v3.2): 3MF format import/export
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 += "&"; 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"(<?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 +1,2 @@
|
||||
add_vde_test(test_io_gltf)
|
||||
add_vde_test(test_io_3mf)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user