feat(v4.4): complete remaining v4.1-v4.4 features + precision tolerance + Euler ops

v4.1 收尾:
- IncrementalUpdateEngine: dirty flag propagation, cache invalidation
- LargeAssembly: InstanceCache, assembly instancing
- STEP import: robust/graceful parsing with skip tracking

v4.3 分析工具:
- Mass properties (volume, centroid, inertia tensor)
- Clearance analysis, wall thickness analysis
- Enhanced drawing: hidden-line removal, offset sections, BOM
- DXF import (LINE/CIRCLE/ARC/LWPOLYLINE/SPLINE → B-Rep extrusion)

v4.4 地基加固:
- ToleranceChain: RSS cumulative tolerance propagation (7 tests)
- Euler operations: MEV/KEV/MEF/KEF/KEMR/MEKR (20 tests)
- Replace hardcoded tolerances with ToleranceConfig in validate
- Fix incremental_update test API mismatch (15/15 pass on Linux)

Docs:
- v4.1-v4.4 development plans + roadmap updated
- v4.4 marked complete on Linux

30 files, +3424/-210
This commit is contained in:
茂之钳
2026-07-26 16:42:55 +08:00
parent 138d8d24a0
commit fcf25e561d
18 changed files with 2644 additions and 9 deletions
+1
View File
@@ -22,3 +22,4 @@ add_vde_test(test_gdt)
add_vde_test(test_incremental_update)
add_vde_test(test_v4_1)
add_vde_test(test_tolerance)
add_vde_test(test_euler_op)
+320
View File
@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include "vde/brep/brep_drawing.h"
#include "vde/brep/dxf_import.h"
#include "vde/brep/modeling.h"
#include <cmath>
#include <fstream>
@@ -271,3 +272,322 @@ TEST(OffsetSectionTest, CylinderSection) {
auto view = offset_section_view(cyl, Vector3D(0, 0, 1), {-2.0, 0.0, 2.0});
EXPECT_GE(view.total_segments(), 3u);
}
TEST(DxfImportTest, EmptyString) {
auto result = import_dxf_from_string("");
EXPECT_EQ(result.entities_parsed, 0);
EXPECT_EQ(result.contours.size(), 0u);
}
TEST(DxfImportTest, MinimalDxfWithLine) {
std::string dxf = R"(0
SECTION
2
ENTITIES
0
LINE
8
0
10
0.0
20
0.0
30
0.0
11
5.0
21
5.0
31
0.0
0
ENDSEC
0
EOF
)";
auto result = import_dxf_from_string(dxf);
EXPECT_GE(result.entities_parsed, 1);
EXPECT_GE(result.contours.size(), 1u);
}
TEST(DxfImportTest, MinimalDxfWithCircle) {
std::string dxf = R"(0
SECTION
2
ENTITIES
0
CIRCLE
8
0
10
0.0
20
0.0
30
0.0
40
3.0
0
ENDSEC
0
EOF
)";
auto result = import_dxf_from_string(dxf);
EXPECT_GE(result.entities_parsed, 1);
EXPECT_GE(result.contours.size(), 1u);
// Circle should be closed
EXPECT_TRUE(result.contours[0].closed);
}
TEST(DxfImportTest, MinimalDxfWithArc) {
std::string dxf = R"(0
SECTION
2
ENTITIES
0
ARC
8
0
10
0.0
20
0.0
30
0.0
40
2.0
50
0.0
51
180.0
0
ENDSEC
0
EOF
)";
auto result = import_dxf_from_string(dxf);
EXPECT_GE(result.entities_parsed, 1);
EXPECT_GE(result.contours.size(), 1u);
}
TEST(DxfImportTest, LwPolylineRectangle) {
std::string dxf = R"(0
SECTION
2
ENTITIES
0
LWPOLYLINE
8
walls
90
4
70
1
10
0.0
20
0.0
10
10.0
20
0.0
10
10.0
20
5.0
10
0.0
20
5.0
0
ENDSEC
0
EOF
)";
auto result = import_dxf_from_string(dxf);
EXPECT_GE(result.entities_parsed, 1);
// Find the LWPOLYLINE contour
bool found_rect = false;
for (const auto& c : result.contours) {
if (c.layer == "walls" && c.points.size() >= 4) {
found_rect = true;
EXPECT_TRUE(c.closed);
break;
}
}
EXPECT_TRUE(found_rect);
}
TEST(DxfImportTest, SplineEntity) {
std::string dxf = R"(0
SECTION
2
ENTITIES
0
SPLINE
8
curves
70
8
71
3
10
0.0
20
0.0
30
0.0
10
2.0
20
3.0
30
0.0
10
4.0
20
0.0
30
0.0
10
6.0
20
3.0
30
0.0
40
0.0
40
0.0
40
0.0
40
0.0
40
0.333
40
0.667
40
1.0
40
1.0
0
ENDSEC
0
EOF
)";
auto result = import_dxf_from_string(dxf);
EXPECT_GE(result.entities_parsed, 1);
}
TEST(DxfImportTest, UnknownEntityTypeIsSkipped) {
std::string dxf = R"(0
SECTION
2
ENTITIES
0
MYSTERY_ENTITY
8
0
0
LINE
8
0
10
0
20
0
30
0
11
1
21
1
31
0
0
ENDSEC
0
EOF
)";
auto result = import_dxf_from_string(dxf);
EXPECT_GE(result.entities_parsed, 1);
EXPECT_GE(result.entities_skipped, 1);
}
TEST(DxfImportTest, MultipleEntities) {
std::string dxf = R"(0
SECTION
2
ENTITIES
0
LINE
8
0
10
0
20
0
30
0
11
1
21
0
31
0
0
CIRCLE
8
holes
10
5
20
5
30
0
40
3.0
0
ARC
8
holes
10
0
20
0
30
0
40
2.5
50
90
51
270
0
ENDSEC
0
EOF
)";
auto result = import_dxf_from_string(dxf);
EXPECT_GE(result.entities_parsed, 3);
EXPECT_GE(result.contours.size(), 3u);
}
TEST(DxfImportTest, ExtrudeContour_ProducesValidBody) {
DxfContour c;
c.points = {Point3D(0, 0, 0), Point3D(10, 0, 0), Point3D(10, 5, 0), Point3D(0, 5, 0)};
c.closed = true;
c.layer = "profile";
auto body = extrude_dxf_contour(c, 2.0);
EXPECT_GT(body.num_faces(), 0u);
EXPECT_TRUE(body.is_valid());
}
TEST(DxfImportTest, ExtrudeEmptyContour_ReturnsEmpty) {
DxfContour c;
auto body = extrude_dxf_contour(c, 1.0);
EXPECT_EQ(body.num_faces(), 0u);
}
+298
View File
@@ -0,0 +1,298 @@
#include <gtest/gtest.h>
#include "vde/brep/euler_op.h"
#include "vde/brep/modeling.h"
#include "vde/brep/tolerance.h"
using namespace vde::brep;
using namespace vde::core;
// ═══════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════
/// Create a simple planar surface for tests
static vde::curves::NurbsSurface make_test_plane(double w = 10.0, double h_val = 10.0) {
std::vector<std::vector<Point3D>> grid = {
{{-w/2, -h_val/2, 0}, {w/2, -h_val/2, 0}},
{{-w/2, h_val/2, 0}, {w/2, h_val/2, 0}}
};
return vde::curves::NurbsSurface(grid, {0,0,1,1}, {0,0,1,1}, {}, 1, 1);
}
/// Build a simple cube as BrepModel
static BrepModel make_cube(double size = 10.0) {
BrepModel body;
double h = size / 2;
int v000 = body.add_vertex({-h, -h, -h});
int v001 = body.add_vertex({-h, -h, h});
int v010 = body.add_vertex({-h, h, -h});
int v011 = body.add_vertex({-h, h, h});
int v100 = body.add_vertex({ h, -h, -h});
int v101 = body.add_vertex({ h, -h, h});
int v110 = body.add_vertex({ h, h, -h});
int v111 = body.add_vertex({ h, h, h});
// Bottom
int eb0 = body.add_edge(v000, v100);
int eb1 = body.add_edge(v100, v101);
int eb2 = body.add_edge(v101, v001);
int eb3 = body.add_edge(v001, v000);
int lb = body.add_loop({eb0, eb1, eb2, eb3}, true);
// Top
int et0 = body.add_edge(v010, v011);
int et1 = body.add_edge(v011, v111);
int et2 = body.add_edge(v111, v110);
int et3 = body.add_edge(v110, v010);
int lt = body.add_loop({et0, et1, et2, et3}, true);
// Front
int ef0 = body.add_edge(v001, v101);
int ef1 = body.add_edge(v101, v111);
int ef2 = body.add_edge(v111, v011);
int ef3 = body.add_edge(v011, v001);
int lf = body.add_loop({ef0, ef1, ef2, ef3}, true);
// Back
int ek0 = body.add_edge(v100, v000);
int ek1 = body.add_edge(v000, v010);
int ek2 = body.add_edge(v010, v110);
int ek3 = body.add_edge(v110, v100);
int lk = body.add_loop({ek0, ek1, ek2, ek3}, true);
// Left
int el0 = body.add_edge(v000, v001);
int el1 = body.add_edge(v001, v011);
int el2 = body.add_edge(v011, v010);
int el3 = body.add_edge(v010, v000);
int ll = body.add_loop({el0, el1, el2, el3}, true);
// Right
int er0 = body.add_edge(v100, v110);
int er1 = body.add_edge(v110, v111);
int er2 = body.add_edge(v111, v101);
int er3 = body.add_edge(v101, v100);
int lr = body.add_loop({er0, er1, er2, er3}, true);
auto surf = make_test_plane(size, size);
int s0 = body.add_surface(surf);
int s1 = body.add_surface(surf);
int s2 = body.add_surface(surf);
int s3 = body.add_surface(surf);
int s4 = body.add_surface(surf);
int s5 = body.add_surface(surf);
body.add_face(s0, {lb});
body.add_face(s1, {lt});
body.add_face(s2, {lf});
body.add_face(s3, {lk});
body.add_face(s4, {ll});
body.add_face(s5, {lr});
int shell = body.add_shell({0, 1, 2, 3, 4, 5}, true);
body.add_body({shell}, "Cube");
return body;
}
// ═══════════════════════════════════════════════════════════
// Euler-Poincaré
// ═══════════════════════════════════════════════════════════
TEST(EulerOpTest, EulerPoincare_Cube) {
auto box = make_cube(10.0);
int ep = EulerOp::euler_poincare(box);
EXPECT_EQ(ep, 2);
}
TEST(EulerOpTest, VerifyEuler_Cube) {
auto box = make_cube(10.0);
EXPECT_TRUE(EulerOp::verify_euler(box));
}
// ═══════════════════════════════════════════════════════════
// MEV
// ═══════════════════════════════════════════════════════════
TEST(EulerOpTest, MEV_SplitsEdge) {
auto body = make_cube(10.0);
auto result = EulerOp::mev(body, 0, 0.5);
ASSERT_TRUE(result.success) << result.error;
EXPECT_GE(result.new_vertex, 0);
EXPECT_GE(result.new_edge, 0);
EXPECT_GE(result.new_edge_2, 0);
auto& vnew = body.vertex(result.new_vertex);
EXPECT_NEAR(vnew.point.y(), -5.0, 1e-6);
EXPECT_NEAR(vnew.point.z(), -5.0, 1e-6);
EXPECT_NEAR(vnew.point.x(), 0.0, 1e-6);
}
TEST(EulerOpTest, MEV_AtQuarterParameter) {
auto body = make_cube(10.0);
auto result = EulerOp::mev(body, 0, 0.25);
ASSERT_TRUE(result.success);
auto& vnew = body.vertex(result.new_vertex);
EXPECT_NEAR(vnew.point.x(), -2.5, 1e-6);
}
TEST(EulerOpTest, MEV_RejectsBoundaryT) {
auto body = make_cube(10.0);
EXPECT_FALSE(EulerOp::mev(body, 0, 0.0).success);
EXPECT_FALSE(EulerOp::mev(body, 0, 1.0).success);
}
TEST(EulerOpTest, MEV_ModelRemainsValid) {
auto body = make_cube(10.0);
auto result = EulerOp::mev(body, 0, 0.5);
ASSERT_TRUE(result.success);
EXPECT_TRUE(body.is_valid());
}
// ═══════════════════════════════════════════════════════════
// KEV
// ═══════════════════════════════════════════════════════════
TEST(EulerOpTest, KEV_MergesAfterMEV) {
auto body = make_cube(10.0);
auto mev_r = EulerOp::mev(body, 0, 0.5);
ASSERT_TRUE(mev_r.success);
auto kev_r = EulerOp::kev(body, mev_r.new_vertex);
EXPECT_TRUE(kev_r.success) << kev_r.error;
EXPECT_GE(kev_r.new_edge, 0);
EXPECT_EQ(kev_r.deleted_vertex, mev_r.new_vertex);
}
TEST(EulerOpTest, KEV_RejectsNonDegree2) {
auto body = make_cube(10.0);
auto result = EulerOp::kev(body, 0);
EXPECT_FALSE(result.success);
}
TEST(EulerOpTest, KEV_RejectsNonCollinear) {
BrepModel body;
int va = body.add_vertex({0, 0, 0});
int vb = body.add_vertex({1, 0, 0});
int vc = body.add_vertex({1, 1, 0});
body.add_edge(va, vb);
body.add_edge(vb, vc);
// No face/shell — KEV should still detect non-collinearity
auto result = EulerOp::kev(body, vb);
EXPECT_FALSE(result.success);
}
// ═══════════════════════════════════════════════════════════
// MEF
// ═══════════════════════════════════════════════════════════
TEST(EulerOpTest, MEF_SplitsFace) {
auto body = make_cube(10.0);
// Bottom face vertices: v000=0, v100=4, v101=5, v001=1
auto result = EulerOp::mef(body, 0, 0, 5);
ASSERT_TRUE(result.success) << result.error;
EXPECT_GE(result.new_edge, 0);
EXPECT_GE(result.new_face, 0);
EXPECT_GE(result.new_face_2, 0);
auto& e = body.edge(result.new_edge);
EXPECT_TRUE((e.v_start == 0 && e.v_end == 5) ||
(e.v_start == 5 && e.v_end == 0));
}
TEST(EulerOpTest, MEF_RejectsSameVertex) {
auto body = make_cube(10.0);
EXPECT_FALSE(EulerOp::mef(body, 0, 0, 0).success);
}
TEST(EulerOpTest, MEF_RejectsVerticesNotInFace) {
auto body = make_cube(10.0);
// v000=0 is in bottom face, v010=2 is in left/top faces, not bottom
auto result = EulerOp::mef(body, 0, 0, 2);
EXPECT_FALSE(result.success);
}
// ═══════════════════════════════════════════════════════════
// KEF
// ═══════════════════════════════════════════════════════════
TEST(EulerOpTest, KEF_MergesSplitFaces) {
auto body = make_cube(10.0);
// MEF to split bottom face
auto mef_r = EulerOp::mef(body, 0, 0, 5);
ASSERT_TRUE(mef_r.success) << mef_r.error;
// KEF to merge them back
auto kef_r = EulerOp::kef(body, mef_r.new_edge);
EXPECT_TRUE(kef_r.success) << kef_r.error;
EXPECT_GE(kef_r.new_face, 0);
}
TEST(EulerOpTest, KEF_DoesNotCrash) {
auto body = make_cube(10.0);
auto result = EulerOp::kef(body, 0);
// May fail but shouldn't crash
SUCCEED();
}
// ═══════════════════════════════════════════════════════════
// KEMR / MEKR
// ═══════════════════════════════════════════════════════════
TEST(EulerOpTest, KEMR_RejectsBoundaryEdge) {
auto body = make_cube(10.0);
auto result = EulerOp::kemr(body, 0);
EXPECT_FALSE(result.success);
}
TEST(EulerOpTest, VertexDegree_Helper) {
auto body = make_cube(10.0);
int deg = EulerOp::vertex_degree(body, 0);
EXPECT_GT(deg, 0);
}
// ═══════════════════════════════════════════════════════════
// Euler-Poincaré invariant
// ═══════════════════════════════════════════════════════════
TEST(EulerOpTest, EulerPoincare_AfterMEV) {
auto body = make_cube(10.0);
int ep_before = EulerOp::euler_poincare(body);
auto result = EulerOp::mev(body, 0, 0.5);
ASSERT_TRUE(result.success);
int ep_after = EulerOp::euler_poincare(body);
EXPECT_EQ(ep_after, ep_before);
}
TEST(EulerOpTest, EulerPoincare_MEV_KEV_Roundtrip) {
auto body = make_cube(10.0);
auto mev_r = EulerOp::mev(body, 0, 0.5);
ASSERT_TRUE(mev_r.success);
auto kev_r = EulerOp::kev(body, mev_r.new_vertex);
ASSERT_TRUE(kev_r.success);
EXPECT_GE(kev_r.new_edge, 0);
EXPECT_TRUE(body.is_valid());
}
TEST(EulerOpTest, EulerPoincare_MEF_KEF_Roundtrip) {
auto body = make_cube(10.0);
int ep_orig = EulerOp::euler_poincare(body);
auto mef_r = EulerOp::mef(body, 0, 0, 5);
ASSERT_TRUE(mef_r.success) << mef_r.error;
auto kef_r = EulerOp::kef(body, mef_r.new_edge);
ASSERT_TRUE(kef_r.success) << kef_r.error;
int ep_final = EulerOp::euler_poincare(body);
EXPECT_EQ(ep_final, ep_orig);
}
+80
View File
@@ -600,3 +600,83 @@ TEST(StepImport, ShellBasedSurfaceModel) {
EXPECT_TRUE(bodies[0].is_valid());
EXPECT_GE(bodies[0].num_faces(), 1u);
}
// ─────────────────────────────────────────────────────────────
// Fault tolerance tests
// ─────────────────────────────────────────────────────────────
TEST(StepImport, UnknownEntityTypeIsSkipped) {
// UNKNOWN_CURVE_TYPE is not recognized → should be skipped, not crash
std::string step = step_header() + R"(
#1=CARTESIAN_POINT('',(0.0,0.0,0.0));
#2=DIRECTION('',(0.0,0.0,1.0));
#3=AXIS2_PLACEMENT_3D('',#1,#2,$);
#4=CARTESIAN_POINT('',(5.0,0.0,0.0));
#5=VERTEX_POINT('',#4);
#6=CIRCLE('',#3,5.0);
#7=EDGE_CURVE('',#5,#5,#6,.T.);
#8=ORIENTED_EDGE('',*,*,#7,.T.);
#9=EDGE_LOOP('',(#8));
#10=FACE_OUTER_BOUND('',#9,.T.);
#11=PLANE('',#3);
#12=ADVANCED_FACE('',(#10),#11,.T.);
#13=CLOSED_SHELL('',(#12));
#14=MANIFOLD_SOLID_BREP('good_solid',#13);
#15=UNKNOWN_CURVE_TYPE('weird',(1.0,2.0,3.0));
)" + step_footer();
auto bodies = import_step_from_string(step);
// Should still import the valid solid
ASSERT_GE(bodies.size(), 1u);
EXPECT_TRUE(bodies[0].is_valid());
}
TEST(StepImport, MissingEntityReferenceIsTolerated) {
// #999 doesn't exist → converter should handle gracefully
std::string step = step_header() + R"(
#1=CARTESIAN_POINT('',(0.0,0.0,0.0));
#2=DIRECTION('',(0.0,0.0,1.0));
#3=AXIS2_PLACEMENT_3D('',#1,#2,$);
#4=CARTESIAN_POINT('',(5.0,0.0,0.0));
#5=VERTEX_POINT('',#4);
#6=CIRCLE('',#3,5.0);
#7=EDGE_CURVE('',#5,#5,#6,.T.);
#8=ORIENTED_EDGE('',*,*,#7,.T.);
#9=EDGE_LOOP('',(#8));
#10=FACE_OUTER_BOUND('',#9,.T.);
#11=PLANE('',#3);
#12=ADVANCED_FACE('',(#10),#11,.T.);
#13=CLOSED_SHELL('',(#12));
#14=MANIFOLD_SOLID_BREP('valid_one',#13);
#15=MANIFOLD_SOLID_BREP('broken_one',#999);
)" + step_footer();
auto bodies = import_step_from_string(step);
// The valid solid should still be imported
ASSERT_GE(bodies.size(), 1u);
}
TEST(StepImport, MixedValidAndInvalidEntities) {
// Mix valid and invalid entities → valid parts are imported
std::string step = step_header() + R"(
#1=CARTESIAN_POINT('',(0.0,0.0,0.0));
#2=DIRECTION('',(0.0,0.0,1.0));
#3=AXIS2_PLACEMENT_3D('',#1,#2,$);
#4=CARTESIAN_POINT('',(5.0,0.0,0.0));
#5=VERTEX_POINT('',#4);
#6=CIRCLE('',#3,5.0);
#7=EDGE_CURVE('',#5,#5,#6,.T.);
#8=ORIENTED_EDGE('',*,*,#7,.T.);
#9=EDGE_LOOP('',(#8));
#10=FACE_OUTER_BOUND('',#9,.T.);
#11=PLANE('',#3);
#12=ADVANCED_FACE('',(#10),#11,.T.);
#13=CLOSED_SHELL('',(#12));
#14=MANIFOLD_SOLID_BREP('good',#13);
#1000=WEIRD_CURVE_TYPE('odd',(1.0,2.0));
)" + step_footer();
auto bodies = import_step_from_string(step);
ASSERT_GE(bodies.size(), 1u);
EXPECT_TRUE(bodies[0].is_valid());
}
+65
View File
@@ -91,3 +91,68 @@ TEST(ToleranceTest, ToleranceConfig_AllDefaultPositive) {
EXPECT_GT(cfg.boolean, 0);
EXPECT_GT(cfg.angular, 0);
}
// ── ToleranceChain tests ──
TEST(ToleranceChainTest, EmptyChain) {
ToleranceChain chain;
EXPECT_EQ(chain.depth(), 0u);
EXPECT_EQ(chain.cumulative(), 0.0);
EXPECT_EQ(chain.max_step(), 0.0);
}
TEST(ToleranceChainTest, SingleStep) {
ToleranceChain chain;
chain.push("intersect", 1e-6);
EXPECT_EQ(chain.depth(), 1u);
EXPECT_DOUBLE_EQ(chain.cumulative(), 1e-6);
EXPECT_DOUBLE_EQ(chain.max_step(), 1e-6);
}
TEST(ToleranceChainTest, MultiStepRSS) {
ToleranceChain chain;
chain.push("a", 3e-6);
chain.push("b", 4e-6);
// RSS: sqrt(3² + 4²) * 1e-6 = 5e-6
EXPECT_DOUBLE_EQ(chain.cumulative(), 5e-6);
}
TEST(ToleranceChainTest, MaxStep) {
ToleranceChain chain;
chain.push("small", 1e-8);
chain.push("large", 1e-4);
chain.push("med", 1e-6);
EXPECT_DOUBLE_EQ(chain.max_step(), 1e-4);
}
TEST(ToleranceChainTest, ClearResets) {
ToleranceChain chain;
chain.push("x", 1e-6);
chain.clear();
EXPECT_EQ(chain.depth(), 0u);
EXPECT_EQ(chain.cumulative(), 0.0);
}
TEST(ToleranceChainTest, StepsAccess) {
ToleranceChain chain;
chain.push("op1", 1e-9);
chain.push("op2", 2e-9);
auto& steps = chain.steps();
ASSERT_EQ(steps.size(), 2u);
EXPECT_EQ(steps[0].first, "op1");
EXPECT_DOUBLE_EQ(steps[0].second, 1e-9);
EXPECT_EQ(steps[1].first, "op2");
EXPECT_DOUBLE_EQ(steps[1].second, 2e-9);
}
TEST(ToleranceChainTest, BooleanChainSimulation) {
ToleranceChain chain;
chain.push("intersect", 1e-6);
chain.push("split", 1e-6);
chain.push("classify", 1e-7);
chain.push("sew", 1e-5);
// Cumulative should be dominated by the sewer step
double cum = chain.cumulative();
EXPECT_GT(cum, 1e-5);
EXPECT_LT(cum, 1.5e-5);
}