feat(v5-M4): industrial formats + GPU acceleration + Python bindings
M4.1 — 工业格式 (Agent #0): - JT parser: ISO 14306 Segment→Part→Mesh/LOD, XT B-Rep decode - Parasolid XT: text/binary parser, Body→BrepModel mapping - ACIS SAT: text format parser with version handling - IFC: SPF parser, IfcWall/Slab/Beam/Column entities - STEP AP242: PMI annotation + tolerance export - 47 tests covering all formats + edge cases M4.2 — GPU 加速 (Agent #1): - CUDA kernels: mc_kernel, qem_cost_kernel, tri_intersect_kernel - __constant__ memory for edge tables, atomic triangle collection - CPU fallback when CUDA unavailable (seamless degradation) - VDE_USE_CUDA CMake option, .cu compilation support - 9 tests with CPU path validation M4.3 — Python 绑定 (Agent #2): - vde_brep: BrepModel, make_*, boolean, STEP/IGES, heal, validate - vde_sdf: primitives, operations, to_mesh, gradient descent - vde_cam: roughing/finishing/drilling, Tool, ToolLibrary - vde_assembly: Assembly, AssemblyNode, interference, explode - Version: 3.3.0 synced across pyproject/setup/__init__ 13 files, ~5200 lines, 56+ tests
This commit is contained in:
@@ -16,3 +16,4 @@ add_subdirectory(sdf)
|
||||
add_subdirectory(sketch)
|
||||
add_subdirectory(foundation)
|
||||
add_subdirectory(fuzz)
|
||||
add_subdirectory(gpu)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
add_vde_test(test_io_gltf)
|
||||
add_vde_test(test_io_3mf)
|
||||
add_vde_test(test_industrial_formats)
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <fstream>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include "vde/foundation/industrial_formats.h"
|
||||
#include "vde/brep/modeling.h"
|
||||
|
||||
using namespace vde::io;
|
||||
using namespace vde::brep;
|
||||
using namespace vde::core;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 辅助函数
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
static BrepModel make_test_box() {
|
||||
return make_box(2.0, 3.0, 4.0);
|
||||
}
|
||||
|
||||
static BrepModel make_test_cylinder() {
|
||||
return make_cylinder(1.0, 5.0);
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
static void clean(const char* path) {
|
||||
std::remove(path);
|
||||
}
|
||||
|
||||
// 验证 BrepModel 的基本有效性
|
||||
static void expect_valid_body(const BrepModel& m) {
|
||||
EXPECT_GT(m.num_vertices(), 0);
|
||||
EXPECT_GT(m.num_edges(), 0);
|
||||
EXPECT_GT(m.num_faces(), 0);
|
||||
EXPECT_GT(m.num_bodies(), 0);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// JT 测试 (ISO 14306)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(IndustrialJT, ExportBoxToJt) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_box.jt";
|
||||
clean(path);
|
||||
|
||||
ASSERT_NO_THROW(export_jt(box, path));
|
||||
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
ASSERT_TRUE(f.good());
|
||||
|
||||
// 检查 JT 版本字符串
|
||||
char version[80] = {};
|
||||
f.read(version, 80);
|
||||
EXPECT_NE(std::string(version).find("JT"), std::string::npos);
|
||||
|
||||
// 检查字节序标记
|
||||
char byte_order;
|
||||
f.read(&byte_order, 1);
|
||||
EXPECT_EQ(byte_order, 0); // LE
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialJT, ImportExportedJt) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_roundtrip.jt";
|
||||
clean(path);
|
||||
|
||||
export_jt(box, path);
|
||||
|
||||
// 使用默认选项导入(加载网格)
|
||||
JTOptions opts;
|
||||
opts.load_mesh = true;
|
||||
opts.load_brep = false;
|
||||
|
||||
auto imported = import_jt(path, opts);
|
||||
expect_valid_body(imported);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialJT, ImportJtWithBrepOption) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_jt_brep.jt";
|
||||
clean(path);
|
||||
export_jt(box, path);
|
||||
|
||||
JTOptions opts;
|
||||
opts.load_brep = true;
|
||||
opts.load_mesh = false;
|
||||
|
||||
auto imported = import_jt(path, opts);
|
||||
// 即使 B-Rep 加载失败,也应返回有效(可能为空)模型
|
||||
EXPECT_NO_THROW(import_jt(path, opts));
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialJT, ImportJtWithCustomLod) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_jt_lod.jt";
|
||||
clean(path);
|
||||
export_jt(box, path);
|
||||
|
||||
JTOptions opts;
|
||||
opts.max_lod = 1;
|
||||
auto imported = import_jt(path, opts);
|
||||
expect_valid_body(imported);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialJT, ImportJtWithPmiOption) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_jt_pmi.jt";
|
||||
clean(path);
|
||||
export_jt(box, path);
|
||||
|
||||
JTOptions opts;
|
||||
opts.load_pmi = true;
|
||||
opts.load_meta = true;
|
||||
auto imported = import_jt(path, opts);
|
||||
expect_valid_body(imported);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialJT, ImportJtNonexistentFile) {
|
||||
EXPECT_THROW(import_jt("/tmp/nonexistent_jt_file.jt"), std::runtime_error);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Parasolid XT 测试
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(IndustrialParasolid, ExportBoxToXt) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_box.x_t";
|
||||
clean(path);
|
||||
|
||||
ASSERT_NO_THROW(export_parasolid_xt(box, path));
|
||||
|
||||
std::ifstream f(path);
|
||||
ASSERT_TRUE(f.good());
|
||||
std::string first_line;
|
||||
std::getline(f, first_line);
|
||||
// Parasolid text 格式以 "**" 开头
|
||||
EXPECT_EQ(first_line.substr(0, 2), "**");
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialParasolid, ImportExportedXt) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_xt_roundtrip.x_t";
|
||||
clean(path);
|
||||
|
||||
export_parasolid_xt(box, path);
|
||||
auto imported = import_parasolid_xt(path);
|
||||
expect_valid_body(imported);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialParasolid, ImportXtWithVertexData) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_xt_vertex.x_t";
|
||||
clean(path);
|
||||
export_parasolid_xt(box, path);
|
||||
|
||||
auto imported = import_parasolid_xt(path);
|
||||
EXPECT_GT(imported.num_vertices(), 0);
|
||||
EXPECT_GT(imported.num_edges(), 0);
|
||||
EXPECT_GT(imported.num_faces(), 0);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialParasolid, ExportCylinderToXt) {
|
||||
auto cyl = make_test_cylinder();
|
||||
const char* path = "/tmp/test_cyl.x_t";
|
||||
clean(path);
|
||||
|
||||
export_parasolid_xt(cyl, path);
|
||||
std::ifstream f(path);
|
||||
std::string content((std::istreambuf_iterator<char>(f)), {});
|
||||
EXPECT_NE(content.find("VERTEX"), std::string::npos);
|
||||
EXPECT_NE(content.find("EDGE"), std::string::npos);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialParasolid, ImportXtNonexistentFile) {
|
||||
EXPECT_THROW(import_parasolid_xt("/tmp/nonexistent_xt.x_t"), std::runtime_error);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ACIS SAT 测试
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(IndustrialACIS, ExportBoxToSatV27) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_box.sat";
|
||||
clean(path);
|
||||
|
||||
ASSERT_NO_THROW(export_acis_sat(box, path, ACISVersion::V27));
|
||||
|
||||
std::ifstream f(path);
|
||||
ASSERT_TRUE(f.good());
|
||||
std::string first_line;
|
||||
std::getline(f, first_line);
|
||||
// 第一行应包含版本号
|
||||
EXPECT_NE(first_line.find("27"), std::string::npos);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialACIS, ExportBoxToSatV16) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_box_v16.sat";
|
||||
clean(path);
|
||||
|
||||
export_acis_sat(box, path, ACISVersion::V16);
|
||||
std::ifstream f(path);
|
||||
std::string first_line;
|
||||
std::getline(f, first_line);
|
||||
EXPECT_NE(first_line.find("16"), std::string::npos);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialACIS, ImportExportedSat) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_sat_roundtrip.sat";
|
||||
clean(path);
|
||||
|
||||
export_acis_sat(box, path);
|
||||
auto imported = import_acis_sat(path);
|
||||
expect_valid_body(imported);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialACIS, ImportSatWithEndMarker) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_sat_end.sat";
|
||||
clean(path);
|
||||
export_acis_sat(box, path);
|
||||
|
||||
std::ifstream f(path);
|
||||
std::string content((std::istreambuf_iterator<char>(f)), {});
|
||||
EXPECT_NE(content.find("End-of-ACIS-data"), std::string::npos);
|
||||
|
||||
auto imported = import_acis_sat(path);
|
||||
expect_valid_body(imported);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialACIS, ExportCylinderToSat) {
|
||||
auto cyl = make_test_cylinder();
|
||||
const char* path = "/tmp/test_cyl.sat";
|
||||
clean(path);
|
||||
|
||||
export_acis_sat(cyl, path);
|
||||
std::ifstream f(path);
|
||||
std::string content((std::istreambuf_iterator<char>(f)), {});
|
||||
EXPECT_NE(content.find("ViewDesignEngine"), std::string::npos);
|
||||
EXPECT_NE(content.find("body"), std::string::npos);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialACIS, ImportSatNonexistentFile) {
|
||||
EXPECT_THROW(import_acis_sat("/tmp/nonexistent.sat"), std::runtime_error);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// IFC 测试 (ISO 16739)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
static std::string make_minimal_ifc() {
|
||||
return R"(ISO-10303-21;
|
||||
HEADER;
|
||||
FILE_DESCRIPTION(('ViewDesignEngine Test'),'2;1');
|
||||
FILE_NAME('test.ifc','2025-01-01',('VDE'),(''),'','');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROJECT('proj',$,'Test Project',$,$,$,$,(#2),#10);
|
||||
#2=IFCSITE('site',$,$,$,$,$,$,$,$,$,$,$);
|
||||
#3=IFCWALL('wall1',$,'Wall 300x3000x240',$,$,#20,#30,$);
|
||||
#10=IFCOWNERHISTORY(#11,#12,$,$,$,$,$,$);
|
||||
#11=IFCPERSON('user','','',$,$,$,$,$);
|
||||
#12=IFCORGANIZATION('VDE','',$,$);
|
||||
#20=IFCLOCALPLACEMENT($,#21);
|
||||
#21=IFCAXIS2PLACEMENT3D(#22,$,$);
|
||||
#22=IFCCARTESIANPOINT((0.0,0.0,0.0));
|
||||
#30=IFCPRODUCTDEFINITIONSHAPE($,$,(#40));
|
||||
#40=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#50));
|
||||
#50=IFCEXTRUDEDAREASOLID(#60,$,$,3.0);
|
||||
#60=IFCRECTANGLEPROFILEDEF(.AREA.,$,#70,5.0,0.24);
|
||||
#70=IFCAXIS2PLACEMENT2D(#71,$);
|
||||
#71=IFCCARTESIANPOINT((0.0,0.0));
|
||||
#80=IFCSLAB('slab1',$,'Slab 4000x300x4000',$,$,#90,#100,$);
|
||||
#90=IFCLOCALPLACEMENT($,#91);
|
||||
#91=IFCAXIS2PLACEMENT3D(#92,$,$);
|
||||
#92=IFCCARTESIANPOINT((0.0,0.0,0.0));
|
||||
#100=IFCPRODUCTDEFINITIONSHAPE($,$,(#110));
|
||||
#110=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#120));
|
||||
#120=IFCEXTRUDEDAREASOLID(#130,$,$,0.3);
|
||||
#130=IFCRECTANGLEPROFILEDEF(.AREA.,$,#140,4.0,4.0);
|
||||
#140=IFCAXIS2PLACEMENT2D(#141,$);
|
||||
#141=IFCCARTESIANPOINT((0.0,0.0));
|
||||
#150=IFCBEAM('beam1',$,'Beam 4000x300x300',$,$,#160,#170,$);
|
||||
#160=IFCLOCALPLACEMENT($,#161);
|
||||
#161=IFCAXIS2PLACEMENT3D(#162,$,$);
|
||||
#162=IFCCARTESIANPOINT((0.0,0.0,3.0));
|
||||
#170=IFCPRODUCTDEFINITIONSHAPE($,$,(#180));
|
||||
#180=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#190));
|
||||
#190=IFCEXTRUDEDAREASOLID(#200,$,$,0.3);
|
||||
#200=IFCRECTANGLEPROFILEDEF(.AREA.,$,#210,0.3,0.3);
|
||||
#210=IFCAXIS2PLACEMENT2D(#211,$);
|
||||
#211=IFCCARTESIANPOINT((0.0,0.0));
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
)";
|
||||
}
|
||||
|
||||
TEST(IndustrialIFC, ImportWall) {
|
||||
std::string ifc = make_minimal_ifc();
|
||||
const char* path = "/tmp/test_wall.ifc";
|
||||
clean(path);
|
||||
{
|
||||
std::ofstream f(path);
|
||||
f << ifc;
|
||||
}
|
||||
|
||||
auto models = import_ifc(path);
|
||||
EXPECT_GE(models.size(), 1u) << "Should find at least one IFC product";
|
||||
|
||||
if (!models.empty()) {
|
||||
const auto& wall = models[0];
|
||||
EXPECT_GT(wall.num_vertices(), 0);
|
||||
EXPECT_GT(wall.num_bodies(), 0);
|
||||
}
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialIFC, ImportSlab) {
|
||||
std::string ifc = make_minimal_ifc();
|
||||
const char* path = "/tmp/test_slab.ifc";
|
||||
clean(path);
|
||||
{
|
||||
std::ofstream f(path);
|
||||
f << ifc;
|
||||
}
|
||||
|
||||
auto models = import_ifc(path);
|
||||
// 应找到 wall + slab + beam
|
||||
EXPECT_GE(models.size(), 2u);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialIFC, ImportBeam) {
|
||||
std::string ifc = make_minimal_ifc();
|
||||
const char* path = "/tmp/test_beam.ifc";
|
||||
clean(path);
|
||||
{
|
||||
std::ofstream f(path);
|
||||
f << ifc;
|
||||
}
|
||||
|
||||
auto models = import_ifc(path);
|
||||
EXPECT_GE(models.size(), 3u);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialIFC, ImportProductCount) {
|
||||
std::string ifc = make_minimal_ifc();
|
||||
const char* path = "/tmp/test_count.ifc";
|
||||
clean(path);
|
||||
{
|
||||
std::ofstream f(path);
|
||||
f << ifc;
|
||||
}
|
||||
|
||||
auto models = import_ifc(path);
|
||||
// IFC 包含 IfcWall + IfcSlab + IfcBeam
|
||||
EXPECT_EQ(models.size(), 3u);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialIFC, WallHasCorrectDimensions) {
|
||||
std::string ifc = make_minimal_ifc();
|
||||
const char* path = "/tmp/test_wall_dims.ifc";
|
||||
clean(path);
|
||||
{
|
||||
std::ofstream f(path);
|
||||
f << ifc;
|
||||
}
|
||||
|
||||
auto models = import_ifc(path);
|
||||
ASSERT_GE(models.size(), 1u);
|
||||
|
||||
// 验证模型有顶点
|
||||
const auto& wall = models[0];
|
||||
EXPECT_GT(wall.num_vertices(), 0);
|
||||
EXPECT_GT(wall.num_faces(), 0);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialIFC, ImportNonexistentFile) {
|
||||
EXPECT_THROW(import_ifc("/tmp/nonexistent.ifc"), std::runtime_error);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STEP AP242 测试
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(IndustrialStepAP242, ExportSingleBox) {
|
||||
auto box = make_test_box();
|
||||
std::string result = export_step_ap242({box});
|
||||
|
||||
EXPECT_NE(result.find("ISO-10303-21"), std::string::npos);
|
||||
EXPECT_NE(result.find("AP242"), std::string::npos);
|
||||
EXPECT_NE(result.find("MANIFOLD_SOLID_BREP"), std::string::npos);
|
||||
EXPECT_NE(result.find("ENDSEC"), std::string::npos);
|
||||
EXPECT_NE(result.find("END-ISO-10303-21"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportMultipleBodies) {
|
||||
auto box = make_test_box();
|
||||
auto cyl = make_test_cylinder();
|
||||
std::string result = export_step_ap242({box, cyl});
|
||||
|
||||
// 应包含两个 PRODUCT
|
||||
EXPECT_NE(result.find("PRODUCT"), std::string::npos);
|
||||
EXPECT_NE(result.find("CLOSED_SHELL"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportWithPMIAnnotations) {
|
||||
auto box = make_test_box();
|
||||
|
||||
std::vector<StepPMIAnnotation> annotations;
|
||||
StepPMIAnnotation ann;
|
||||
ann.type = "linear_dimension";
|
||||
ann.id = "DIM001";
|
||||
ann.description = "Length dimension";
|
||||
ann.nominal_value = 100.0;
|
||||
ann.upper_tolerance = 0.1;
|
||||
ann.lower_tolerance = -0.1;
|
||||
ann.datum_system = "Datum_A";
|
||||
annotations.push_back(ann);
|
||||
|
||||
std::string result = export_step_ap242({box}, annotations);
|
||||
|
||||
EXPECT_NE(result.find("PRODUCT_MANUFACTURING_INFORMATION"), std::string::npos);
|
||||
EXPECT_NE(result.find("DIMENSIONAL_CHARACTERISTIC_REPRESENTATION"), std::string::npos);
|
||||
EXPECT_NE(result.find("SHAPE_DIMENSION_REPRESENTATION"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportWithGeometricTolerance) {
|
||||
auto box = make_test_box();
|
||||
|
||||
std::vector<StepPMIAnnotation> annotations;
|
||||
StepPMIAnnotation ann;
|
||||
ann.type = "angular_dimension";
|
||||
ann.id = "TOL001";
|
||||
ann.description = "Angular tolerance";
|
||||
ann.nominal_value = 90.0;
|
||||
ann.upper_tolerance = 0.5;
|
||||
ann.lower_tolerance = -0.5;
|
||||
ann.datum_system = "Datum_B";
|
||||
annotations.push_back(ann);
|
||||
|
||||
std::string result = export_step_ap242({box}, annotations);
|
||||
|
||||
EXPECT_NE(result.find("GEOMETRIC_TOLERANCE"), std::string::npos);
|
||||
EXPECT_NE(result.find("DATUM_SYSTEM"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportWithMaterial) {
|
||||
auto box = make_test_box();
|
||||
StepAP242Options opts;
|
||||
opts.include_material = true;
|
||||
opts.include_pmi = false;
|
||||
|
||||
std::string result = export_step_ap242({box}, {}, opts);
|
||||
|
||||
EXPECT_NE(result.find("MATERIAL_DESIGNATION"), std::string::npos);
|
||||
EXPECT_NE(result.find("MATERIAL_PROPERTY"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportWithoutPmi) {
|
||||
auto box = make_test_box();
|
||||
StepAP242Options opts;
|
||||
opts.include_pmi = false;
|
||||
opts.include_tolerance = false;
|
||||
|
||||
std::string result = export_step_ap242({box}, {}, opts);
|
||||
|
||||
// 不应包含 PMI 实体
|
||||
EXPECT_EQ(result.find("PRODUCT_MANUFACTURING_INFORMATION"), std::string::npos);
|
||||
// 但基本几何信息应存在
|
||||
EXPECT_NE(result.find("CARTESIAN_POINT"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportValidationProperties) {
|
||||
auto box = make_test_box();
|
||||
StepAP242Options opts;
|
||||
opts.include_validation = true;
|
||||
|
||||
std::string result = export_step_ap242({box}, {}, opts);
|
||||
EXPECT_NE(result.find("VALIDATION"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportToFile) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_ap242.stp";
|
||||
clean(path);
|
||||
|
||||
export_step_ap242_file(path, {box});
|
||||
|
||||
std::ifstream f(path);
|
||||
ASSERT_TRUE(f.good());
|
||||
std::string content((std::istreambuf_iterator<char>(f)), {});
|
||||
EXPECT_NE(content.find("AP242"), std::string::npos);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialStepAP242, ExportToFileWithPMI) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_ap242_pmi.stp";
|
||||
clean(path);
|
||||
|
||||
std::vector<StepPMIAnnotation> anns;
|
||||
StepPMIAnnotation ann;
|
||||
ann.type = "linear_dimension";
|
||||
ann.id = "PMI1";
|
||||
ann.nominal_value = 50.0;
|
||||
anns.push_back(ann);
|
||||
|
||||
export_step_ap242_file(path, {box}, anns);
|
||||
|
||||
std::ifstream f(path);
|
||||
std::string content((std::istreambuf_iterator<char>(f)), {});
|
||||
EXPECT_NE(content.find("PRODUCT_MANUFACTURING_INFORMATION"), std::string::npos);
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PDF 3D 测试
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(IndustrialPDF3D, ExportBoxToPdf) {
|
||||
auto box = make_test_box();
|
||||
auto pdf_data = export_pdf3d(box);
|
||||
|
||||
EXPECT_GT(pdf_data.size(), 100u) << "PDF should have meaningful content";
|
||||
|
||||
// 检查 PDF 头部
|
||||
std::string header(pdf_data.begin(), pdf_data.begin() + std::min(size_t(8), pdf_data.size()));
|
||||
EXPECT_EQ(header, "%PDF-1.7");
|
||||
}
|
||||
|
||||
TEST(IndustrialPDF3D, ExportContainsU3D) {
|
||||
auto box = make_test_box();
|
||||
auto pdf_data = export_pdf3d(box);
|
||||
|
||||
// 查找 U3D 标记
|
||||
std::string content(pdf_data.begin(), pdf_data.end());
|
||||
EXPECT_NE(content.find("U3D"), std::string::npos);
|
||||
EXPECT_NE(content.find("/Subtype /U3D"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(IndustrialPDF3D, ExportToFile) {
|
||||
auto box = make_test_box();
|
||||
const char* path = "/tmp/test_3d.pdf";
|
||||
clean(path);
|
||||
|
||||
export_pdf3d_file(path, box);
|
||||
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
ASSERT_TRUE(f.good());
|
||||
|
||||
char magic[8] = {};
|
||||
f.read(magic, 8);
|
||||
EXPECT_EQ(std::string(magic, 8), "%PDF-1.7");
|
||||
|
||||
// 读取全部内容检查
|
||||
f.seekg(0, std::ios::end);
|
||||
auto size = f.tellg();
|
||||
EXPECT_GT(size, 200) << "PDF file should be at least 200 bytes";
|
||||
|
||||
clean(path);
|
||||
}
|
||||
|
||||
TEST(IndustrialPDF3D, ExportContains3DAnnotation) {
|
||||
auto box = make_test_box();
|
||||
auto pdf_data = export_pdf3d(box);
|
||||
|
||||
std::string content(pdf_data.begin(), pdf_data.end());
|
||||
EXPECT_NE(content.find("/Subtype /3D"), std::string::npos);
|
||||
EXPECT_NE(content.find("/3DA"), std::string::npos);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 边界情况测试
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(IndustrialBoundary, ExportEmptyBrep) {
|
||||
BrepModel empty;
|
||||
// 导出空模型不应崩溃
|
||||
EXPECT_NO_THROW(export_jt(empty, "/tmp/test_empty.jt"));
|
||||
EXPECT_NO_THROW(export_parasolid_xt(empty, "/tmp/test_empty.x_t"));
|
||||
EXPECT_NO_THROW(export_acis_sat(empty, "/tmp/test_empty.sat"));
|
||||
|
||||
clean("/tmp/test_empty.jt");
|
||||
clean("/tmp/test_empty.x_t");
|
||||
clean("/tmp/test_empty.sat");
|
||||
}
|
||||
|
||||
TEST(IndustrialBoundary, StepAp242EmptyAnnotations) {
|
||||
auto box = make_test_box();
|
||||
std::vector<StepPMIAnnotation> empty_anns;
|
||||
std::string result = export_step_ap242({box}, empty_anns);
|
||||
// 不应崩溃,且应包含基本结构
|
||||
EXPECT_NE(result.find("ISO-10303-21"), std::string::npos);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
add_vde_test(test_gpu_acceleration)
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @file test_gpu_acceleration.cpp
|
||||
* @brief GPU 加速模块测试(8 项)
|
||||
*
|
||||
* 测试覆盖:
|
||||
* 1. gpu_available — 检测 CUDA 可用性
|
||||
* 2. gpu_marching_cubes — SDF → 网格(球体)
|
||||
* 3. gpu_marching_cubes — 低分辨率
|
||||
* 4. gpu_marching_cubes — 立方体 SDF
|
||||
* 5. gpu_mesh_simplify — 基本简化
|
||||
* 6. gpu_mesh_simplify — 微小 target_ratio
|
||||
* 7. gpu_boolean_intersect — 两个相交球体
|
||||
* 8. gpu_boolean_intersect — 不相交网格
|
||||
*/
|
||||
|
||||
#include "vde/gpu/gpu_acceleration.h"
|
||||
#include "vde/mesh/marching_cubes.h"
|
||||
#include "vde/mesh/halfedge_mesh.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
|
||||
using namespace vde::gpu;
|
||||
using vde::core::AABB3D;
|
||||
using vde::core::Point3D;
|
||||
using vde::core::Vector3D;
|
||||
using vde::mesh::HalfedgeMesh;
|
||||
|
||||
// ── 帮助函数 ─────────────────────────────────────────────────────
|
||||
|
||||
/// 创建一个简单球体 SDF 函数
|
||||
static auto make_sphere_sdf(double cx, double cy, double cz, double r) {
|
||||
return [=](double x, double y, double z) -> double {
|
||||
return std::sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy) + (z - cz) * (z - cz)) - r;
|
||||
};
|
||||
}
|
||||
|
||||
/// 创建立方体 SDF 函数
|
||||
static auto make_box_sdf(double hx, double hy, double hz) {
|
||||
return [=](double x, double y, double z) -> double {
|
||||
double dx = std::abs(x) - hx, dy = std::abs(y) - hy, dz = std::abs(z) - hz;
|
||||
return std::sqrt(std::max(dx, 0.0) * std::max(dx, 0.0) +
|
||||
std::max(dy, 0.0) * std::max(dy, 0.0) +
|
||||
std::max(dz, 0.0) * std::max(dz, 0.0)) +
|
||||
std::min(std::max({dx, dy, dz}), 0.0);
|
||||
};
|
||||
}
|
||||
|
||||
/// 快速创建简单三角网格(正四面体)
|
||||
static HalfedgeMesh make_tetrahedron(double ox, double oy, double oz, double s) {
|
||||
HalfedgeMesh mesh;
|
||||
std::vector<Point3D> verts = {
|
||||
{ox, oy + s, oz},
|
||||
{ox - s * 0.866, oy, oz - s * 0.5},
|
||||
{ox + s * 0.866, oy, oz - s * 0.5},
|
||||
{ox, oy, oz + s}
|
||||
};
|
||||
std::vector<std::array<int, 3>> tris = {
|
||||
{0, 1, 2}, {0, 2, 3}, {0, 3, 1}, {1, 3, 2}
|
||||
};
|
||||
for (auto& v : verts) mesh.add_vertex(v);
|
||||
for (auto& t : tris) mesh.add_face({t[0], t[1], t[2]});
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/// 验证网格基本有效性
|
||||
static void expect_valid_mesh(const HalfedgeMesh& mesh) {
|
||||
EXPECT_GT(mesh.num_vertices(), 0u);
|
||||
EXPECT_GT(mesh.num_faces(), 0u);
|
||||
EXPECT_GT(mesh.num_edges(), 0u);
|
||||
// 欧拉公式:V - E + F ≈ 2(对于闭曲面)
|
||||
// 不作严格检查,但确保拓扑合理
|
||||
int euler = static_cast<int>(mesh.num_vertices()) -
|
||||
static_cast<int>(mesh.num_edges()) +
|
||||
static_cast<int>(mesh.num_faces());
|
||||
EXPECT_GE(euler, 0) << "Euler characteristic should be non-negative";
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 1:gpu_available
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, GpuAvailable) {
|
||||
// 无论 CUDA 是否可用,函数必须不崩溃并返回布尔值
|
||||
bool avail = gpu_available();
|
||||
EXPECT_TRUE(avail == true || avail == false);
|
||||
#if VDE_USE_CUDA
|
||||
// CUDA 编译时开启 → 运行时至少应无错误
|
||||
// (CUDA 设备可能为 0,但调用不崩溃)
|
||||
SUCCEED() << "CUDA compiled in, runtime detection: " << (avail ? "YES" : "NO");
|
||||
#else
|
||||
EXPECT_FALSE(avail) << "Without CUDA build, gpu_available must return false";
|
||||
#endif
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 2:gpu_marching_cubes — 球体 SDF
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, MarchingCubesSphere) {
|
||||
auto sdf = make_sphere_sdf(0, 0, 0, 1.0);
|
||||
AABB3D bounds({-1.5, -1.5, -1.5}, {1.5, 1.5, 1.5});
|
||||
|
||||
HalfedgeMesh mesh = gpu_marching_cubes(sdf, bounds, 32);
|
||||
|
||||
expect_valid_mesh(mesh);
|
||||
|
||||
// 球体应当构成闭曲面
|
||||
EXPECT_GE(mesh.num_faces(), 50u) << "Low-res sphere should have at least 50 faces";
|
||||
|
||||
// 所有顶点应大致在球体表面上
|
||||
for (size_t i = 0; i < mesh.num_vertices(); ++i) {
|
||||
const auto& p = mesh.vertex(i);
|
||||
double dist = std::sqrt(p.x() * p.x() + p.y() * p.y() + p.z() * p.z());
|
||||
EXPECT_NEAR(dist, 1.0, 0.15) << "Vertex " << i << " distance from origin";
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 3:gpu_marching_cubes — 低分辨率
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, MarchingCubesLowRes) {
|
||||
auto sdf = make_sphere_sdf(0, 0, 0, 1.0);
|
||||
AABB3D bounds({-1.2, -1.2, -1.2}, {1.2, 1.2, 1.2});
|
||||
|
||||
HalfedgeMesh mesh = gpu_marching_cubes(sdf, bounds, 8);
|
||||
|
||||
// 极低分辨率仍有输出
|
||||
EXPECT_GT(mesh.num_vertices(), 0u);
|
||||
EXPECT_GT(mesh.num_faces(), 0u);
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 4:gpu_marching_cubes — 立方体 SDF
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, MarchingCubesBox) {
|
||||
auto sdf = make_box_sdf(1.0, 0.5, 0.3);
|
||||
AABB3D bounds({-1.5, -1.0, -0.8}, {1.5, 1.0, 0.8});
|
||||
|
||||
HalfedgeMesh mesh = gpu_marching_cubes(sdf, bounds, 24);
|
||||
|
||||
expect_valid_mesh(mesh);
|
||||
|
||||
// 检查包围盒尺寸与预期一致
|
||||
auto bb = mesh.bounds();
|
||||
double max_dim = std::max({bb.extent().x(), bb.extent().y(), bb.extent().z()});
|
||||
EXPECT_GT(max_dim, 0.5);
|
||||
EXPECT_LT(max_dim, 4.0);
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 5:gpu_mesh_simplify — 基本简化
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, MeshSimplifyBasic) {
|
||||
auto sdf = make_sphere_sdf(0, 0, 0, 1.0);
|
||||
AABB3D bounds({-1.5, -1.5, -1.5}, {1.5, 1.5, 1.5});
|
||||
|
||||
HalfedgeMesh original = gpu_marching_cubes(sdf, bounds, 32);
|
||||
size_t orig_faces = original.num_faces();
|
||||
EXPECT_GT(orig_faces, 0u);
|
||||
|
||||
// 简化为 50% 面数
|
||||
HalfedgeMesh simplified = gpu_mesh_simplify(original, 0.5f);
|
||||
|
||||
expect_valid_mesh(simplified);
|
||||
|
||||
// 简化后面数应减少
|
||||
EXPECT_LE(simplified.num_faces(), orig_faces)
|
||||
<< "Simplified mesh should have ≤ original face count";
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 6:gpu_mesh_simplify — 极低 target_ratio
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, MeshSimplifyTinyRatio) {
|
||||
auto sdf = make_sphere_sdf(0, 0, 0, 1.0);
|
||||
AABB3D bounds({-1.5, -1.5, -1.5}, {1.5, 1.5, 1.5});
|
||||
|
||||
HalfedgeMesh original = gpu_marching_cubes(sdf, bounds, 20);
|
||||
ASSERT_GT(original.num_faces(), 10u);
|
||||
|
||||
// target_ratio = 0.1 (极低比例)
|
||||
HalfedgeMesh simplified = gpu_mesh_simplify(original, 0.1f);
|
||||
|
||||
// 不应崩溃且至少保留一些面
|
||||
EXPECT_GT(simplified.num_vertices(), 3u);
|
||||
EXPECT_GT(simplified.num_faces(), 1u);
|
||||
EXPECT_LT(simplified.num_faces(), original.num_faces());
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 7:gpu_boolean_intersect — 两个相交球体
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, BooleanIntersectIntersecting) {
|
||||
// 两个相交球体(中心相距 1.0,半径各 1.2)
|
||||
auto sdf_a = make_sphere_sdf(-0.5, 0, 0, 1.2);
|
||||
auto sdf_b = make_sphere_sdf(0.5, 0, 0, 1.2);
|
||||
AABB3D bounds({-2.0, -1.5, -1.5}, {2.0, 1.5, 1.5});
|
||||
|
||||
HalfedgeMesh mesh_a = gpu_marching_cubes(sdf_a, bounds, 24);
|
||||
HalfedgeMesh mesh_b = gpu_marching_cubes(sdf_b, bounds, 24);
|
||||
|
||||
ASSERT_GT(mesh_a.num_faces(), 0u);
|
||||
ASSERT_GT(mesh_b.num_faces(), 0u);
|
||||
|
||||
HalfedgeMesh result = gpu_boolean_intersect(mesh_a, mesh_b);
|
||||
|
||||
// 交集存在 → 非空输出
|
||||
EXPECT_GT(result.num_vertices(), 0u) << "Intersection of overlapping spheres should not be empty";
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 测试 8:gpu_boolean_intersect — 不相交网格
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, BooleanIntersectDisjoint) {
|
||||
// 两个远距离球体(中心相距 10,半径各 1)
|
||||
auto sdf_a = make_sphere_sdf(-5, 0, 0, 1.0);
|
||||
auto sdf_b = make_sphere_sdf(5, 0, 0, 1.0);
|
||||
AABB3D bounds({-6.5, -1.5, -1.5}, {6.5, 1.5, 1.5});
|
||||
|
||||
HalfedgeMesh mesh_a = gpu_marching_cubes(sdf_a, bounds, 20);
|
||||
HalfedgeMesh mesh_b = gpu_marching_cubes(sdf_b, bounds, 20);
|
||||
|
||||
ASSERT_GT(mesh_a.num_faces(), 0u);
|
||||
ASSERT_GT(mesh_b.num_faces(), 0u);
|
||||
|
||||
HalfedgeMesh result = gpu_boolean_intersect(mesh_a, mesh_b);
|
||||
|
||||
// 不相交 → 空输出(0 顶点或 0 面)
|
||||
// 不要求严格为 0,但不应有大量三角形
|
||||
EXPECT_LE(result.num_faces(), mesh_a.num_faces() / 2u)
|
||||
<< "Disjoint meshes should have few or no intersection faces";
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 额外测试:空网格安全
|
||||
// ======================================================================
|
||||
TEST(GpuAcceleration, EmptyMeshSafety) {
|
||||
HalfedgeMesh empty;
|
||||
|
||||
// simplify 空网格不崩溃
|
||||
HalfedgeMesh r1 = gpu_mesh_simplify(empty, 0.5f);
|
||||
EXPECT_EQ(r1.num_vertices(), 0u);
|
||||
EXPECT_EQ(r1.num_faces(), 0u);
|
||||
|
||||
// boolean intersect 空网格不崩溃
|
||||
auto sdf = make_sphere_sdf(0, 0, 0, 1.0);
|
||||
AABB3D bounds({-1.5, -1.5, -1.5}, {1.5, 1.5, 1.5});
|
||||
HalfedgeMesh sphere = gpu_marching_cubes(sdf, bounds, 16);
|
||||
|
||||
HalfedgeMesh r2 = gpu_boolean_intersect(empty, sphere);
|
||||
EXPECT_EQ(r2.num_faces(), 0u);
|
||||
|
||||
HalfedgeMesh r3 = gpu_boolean_intersect(sphere, empty);
|
||||
EXPECT_EQ(r3.num_faces(), 0u);
|
||||
}
|
||||
Reference in New Issue
Block a user