feat(v5-M3): large assembly LOD + constraint solver + CAM strategies + direct modeling
CI / Build & Test (push) Failing after 33s
CI / Release Build (push) Failing after 39s
Build & Test / build-and-test (push) Has been cancelled
Build & Test / python-bindings (push) Has been cancelled

M3.1 — 大装配 LOD + 约束求解器 (Agent #0):
- assembly_lod.h/.cpp: 3-level LOD (Full/Simplified/BBox), view-distance auto-switch
- constraint_solver.h/.cpp: ConstraintGraph, DOF analysis, Newton-Raphson solver
- 7 constraint types, over-constraint detection, incremental solve
- 59 tests (22 LOD + 37 constraint)

M3.2 — 完整 CAM 策略 (Agent #1):
- cam_strategies.h/.cpp: roughing(Z-layer), finishing(parallel/spiral), drilling(G81/G83/G84)
- Tool/ToolLibrary, PostProcessor (Fanuc/Siemens/Heidenhain)
- material_removal_simulation with volume stats
- 27 tests, 26/27 passing

M3.3 — 直接建模 (Agent #2):
- direct_modeling.h/.cpp: tweak_face, move_face, replace_face, push_pull, offset_face
- Auto neighbor-face extension for watertightness
- DirectModelingResult with diagnostics
- 23 tests with validate() verification

12 files, ~5400 lines, 109 tests
This commit is contained in:
茂之钳
2026-07-26 21:19:38 +08:00
parent 73df04d5cb
commit 6bc9db663e
15 changed files with 4806 additions and 130 deletions
+2
View File
@@ -32,3 +32,5 @@ add_vde_test(test_feature_recognition)
add_vde_test(test_v5_features)
add_vde_test(test_v5_1)
add_vde_test(test_advanced_blend)
add_vde_test(test_assembly_lod)
add_vde_test(test_direct_modeling)
+355
View File
@@ -0,0 +1,355 @@
#include <gtest/gtest.h>
#include "vde/brep/assembly_lod.h"
#include "vde/brep/modeling.h"
#include "vde/brep/assembly_instance.h"
#include "vde/core/transform.h"
#include "vde/core/aabb.h"
#include <cmath>
using namespace vde::brep;
using namespace vde::core;
// ═══════════════════════════════════════════════════════════
// 辅助函数
// ═══════════════════════════════════════════════════════════
/// 创建多零件测试装配体
static Assembly make_test_assembly() {
Assembly assy("test_lod");
assy.root.add_part("box_a", make_box(2, 2, 2));
assy.root.add_part("box_b", make_box(2, 2, 2), translate(5, 0, 0));
assy.root.add_part("box_c", make_box(2, 2, 2), translate(10, 0, 0));
return assy;
}
// ═══════════════════════════════════════════════════════════
// 测试 1: 构造与基本属性
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, ConstructFromAssembly) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
EXPECT_EQ(lod.part_count(), 3);
EXPECT_EQ(lod.nodes().size(), 3);
}
TEST(AssemblyLODTest, DefaultLODLevelIsFull) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
for (const auto& node : lod.nodes()) {
EXPECT_EQ(node.current_level, LODLevel::Full);
EXPECT_FALSE(node.manual_override);
}
}
TEST(AssemblyLODTest, HasValidWorldBounds) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
for (const auto& node : lod.nodes()) {
// 未初始化(全零)的 AABB 体积为 0,非空 AABB 体积 > 0
EXPECT_GT(node.world_bounds.volume(), 0.0);
}
}
// ═══════════════════════════════════════════════════════════
// 测试 2: 视距驱动 LOD 选择
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, NearPlaneSelectsFull) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
LODConfig cfg;
cfg.near_plane = 100.0;
cfg.far_plane = 1000.0;
lod.set_config(cfg);
// camera_distance = 10 < near_plane → Full
lod.compute_lod(10.0, 0.5);
for (const auto& node : lod.nodes()) {
EXPECT_EQ(node.current_level, LODLevel::Full);
}
}
TEST(AssemblyLODTest, FarPlaneSelectsBoundingBox) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
LODConfig cfg;
cfg.near_plane = 100.0;
cfg.far_plane = 1000.0;
lod.set_config(cfg);
// camera_distance = 2000 > far_plane → BoundingBox
lod.compute_lod(2000.0, 0.1);
for (const auto& node : lod.nodes()) {
EXPECT_EQ(node.current_level, LODLevel::BoundingBox);
}
}
TEST(AssemblyLODTest, MidRangeWithLargeScreenSizeSelectsSimplified) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
LODConfig cfg;
cfg.near_plane = 100.0;
cfg.far_plane = 1000.0;
lod.set_config(cfg);
// near_plane ≤ distance ≤ far_plane, large screen_size → Simplified
lod.compute_lod(500.0, 0.5);
for (const auto& node : lod.nodes()) {
EXPECT_EQ(node.current_level, LODLevel::Simplified);
}
}
TEST(AssemblyLODTest, MidRangeWithSmallScreenSizeSelectsBBox) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
LODConfig cfg;
cfg.near_plane = 100.0;
cfg.far_plane = 1000.0;
cfg.screen_size_threshold = 0.1;
lod.set_config(cfg);
// near ≤ dist ≤ far, small screen → BoundingBox
lod.compute_lod(500.0, 0.001);
for (const auto& node : lod.nodes()) {
EXPECT_EQ(node.current_level, LODLevel::BoundingBox);
}
}
// ═══════════════════════════════════════════════════════════
// 测试 3: 手动 LOD 设置
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, ManualSetLODLevel) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
auto& node0 = lod.nodes()[0];
lod.set_lod_level(node0, LODLevel::BoundingBox);
EXPECT_EQ(node0.current_level, LODLevel::BoundingBox);
EXPECT_TRUE(node0.manual_override);
}
TEST(AssemblyLODTest, ManualOverridePreventsAutoLOD) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
// 手动设置 node0 为 BoundingBox
lod.set_lod_level(lod.nodes()[0], LODLevel::BoundingBox);
// 自动 LOD 应设置为 Full (near plane)
LODConfig cfg;
cfg.near_plane = 100.0;
cfg.far_plane = 1000.0;
lod.set_config(cfg);
lod.compute_lod(10.0, 0.5); // near plane → Full
// 手动覆盖的节点仍保持 BoundingBox
EXPECT_EQ(lod.nodes()[0].current_level, LODLevel::BoundingBox);
// 其余节点应该是 Full
EXPECT_EQ(lod.nodes()[1].current_level, LODLevel::Full);
EXPECT_EQ(lod.nodes()[2].current_level, LODLevel::Full);
}
TEST(AssemblyLODTest, ClearManualOverride) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
lod.set_lod_level(lod.nodes()[0], LODLevel::Simplified);
EXPECT_TRUE(lod.nodes()[0].manual_override);
lod.clear_manual_override(lod.nodes()[0]);
EXPECT_FALSE(lod.nodes()[0].manual_override);
}
TEST(AssemblyLODTest, ClearAllManualOverrides) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
lod.set_lod_level(lod.nodes()[0], LODLevel::Simplified);
lod.set_lod_level(lod.nodes()[1], LODLevel::BoundingBox);
lod.clear_all_manual_overrides();
for (const auto& node : lod.nodes()) {
EXPECT_FALSE(node.manual_override);
}
}
// ═══════════════════════════════════════════════════════════
// 测试 4: 遍历与统计
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, TraverseAssemblyVisitsAllNodes) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
int visited = 0;
lod.traverse_assembly([&visited](const LODNode&) { ++visited; });
EXPECT_EQ(visited, 3);
}
TEST(AssemblyLODTest, StatisticsCounts) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
lod.set_lod_level(lod.nodes()[0], LODLevel::Full);
lod.set_lod_level(lod.nodes()[1], LODLevel::Simplified);
lod.set_lod_level(lod.nodes()[2], LODLevel::BoundingBox);
auto stats = lod.statistics();
EXPECT_EQ(stats.full_count, 1);
EXPECT_EQ(stats.simplified_count, 1);
EXPECT_EQ(stats.bbox_count, 1);
EXPECT_EQ(stats.manual_count, 3); // all three are manual
}
// ═══════════════════════════════════════════════════════════
// 测试 5: compute_lod_for_node 单独使用
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, ComputeLODForNodeNearPlane) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
LODConfig cfg;
cfg.near_plane = 100.0;
cfg.far_plane = 500.0;
lod.set_config(cfg);
auto level = lod.compute_lod_for_node(lod.nodes()[0], 10.0, 0.5);
EXPECT_EQ(level, LODLevel::Full);
}
TEST(AssemblyLODTest, DisableAutoLODReturnsFull) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
LODConfig cfg;
cfg.enable_auto_lod = false;
lod.set_config(cfg);
lod.compute_lod(5000.0, 0.001);
// Disabled auto LOD: all stay Full
for (const auto& node : lod.nodes()) {
EXPECT_EQ(node.current_level, LODLevel::Full);
}
}
// ═══════════════════════════════════════════════════════════
// 测试 6: 更新边界
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, UpdateBoundsAfterTransformChange) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
auto bb_before = lod.nodes()[0].world_bounds;
// 修改 node0 的变换
lod.nodes()[0].node->local_transform = translate(100, 0, 0);
lod.update_bounds();
auto bb_after = lod.nodes()[0].world_bounds;
// X 坐标应该偏移了 100
EXPECT_NEAR(bb_after.center().x(), bb_before.center().x() + 100.0, 1e-6);
}
// ═══════════════════════════════════════════════════════════
// 测试 7: 空装配
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, EmptyAssembly) {
Assembly assy("empty");
AssemblyLOD lod(assy);
EXPECT_EQ(lod.part_count(), 0);
EXPECT_EQ(lod.nodes().size(), 0);
auto stats = lod.statistics();
EXPECT_EQ(stats.full_count, 0);
EXPECT_EQ(stats.simplified_count, 0);
EXPECT_EQ(stats.bbox_count, 0);
}
// ═══════════════════════════════════════════════════════════
// 测试 8: LODConfig 默认值
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, DefaultConfig) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
const auto& cfg = lod.config();
EXPECT_DOUBLE_EQ(cfg.near_plane, 50.0);
EXPECT_DOUBLE_EQ(cfg.far_plane, 500.0);
EXPECT_TRUE(cfg.enable_auto_lod);
}
// ═══════════════════════════════════════════════════════════
// 测试 9: traverse_with_context
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, TraverseWithContextCallsAllParts) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
int parts_seen = 0;
lod.traverse_with_context([&](LODNode&, const Transform3D&, int) {
++parts_seen;
return true;
});
EXPECT_EQ(parts_seen, 3);
}
TEST(AssemblyLODTest, TraverseWithContextCanEarlyExit) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
int parts_seen = 0;
bool stopped = lod.traverse_with_context([&](LODNode&, const Transform3D&, int) {
++parts_seen;
return false; // stop after first part
});
EXPECT_FALSE(stopped);
EXPECT_EQ(parts_seen, 1);
}
// ═══════════════════════════════════════════════════════════
// 测试 10: InstanceCache 集成
// ═══════════════════════════════════════════════════════════
TEST(AssemblyLODTest, InstanceCacheIsPopulated) {
auto assy = make_test_assembly();
AssemblyLOD lod(assy);
// InstanceCache 应已填充
EXPECT_GT(lod.cache().unique_count(), 0);
}
TEST(AssemblyLODTest, SubAssemblyDoesNotAddToLOD) {
Assembly assy("mixed");
assy.root.add_part("part_a", make_box(1, 1, 1));
auto* sub = assy.root.add_subassembly("sub_a");
sub->add_part("part_b", make_box(1, 1, 1));
AssemblyLOD lod(assy);
// 应只有零件节点(part_a 和 part_b),没有子装配体
EXPECT_EQ(lod.part_count(), 2);
}
+303
View File
@@ -395,3 +395,306 @@ TEST(ConstraintSolverTest, ThreeNodeSolve) {
EXPECT_NEAR(gap_01, 0.0, 1e-3);
EXPECT_NEAR(gap_12, 0.0, 1e-3);
}
// ════════════════════════════════════════════════════════════════
// ConstraintGraph 测试
// ════════════════════════════════════════════════════════════════
TEST(ConstraintGraphTest, AddNodes) {
Assembly assy("graph_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2), translate(5, 0, 0));
ConstraintGraph graph;
int idx_a = graph.add_node("box_a", box_a);
int idx_b = graph.add_node("box_b", box_b);
EXPECT_EQ(graph.node_count(), 2);
EXPECT_EQ(idx_a, 0);
EXPECT_EQ(idx_b, 1);
EXPECT_EQ(graph.node_name(0), "box_a");
EXPECT_EQ(graph.node_name(1), "box_b");
EXPECT_EQ(graph.node_ptr(0), box_a);
}
TEST(ConstraintGraphTest, AddConstraints) {
Assembly assy("graph_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2), translate(5, 0, 0));
ConstraintGraph graph;
graph.add_node("box_a", box_a);
graph.add_node("box_b", box_b);
graph.add_constraint({0, 1, ConstraintType::Coincident});
graph.add_constraint({0, 1, ConstraintType::Concentric});
EXPECT_EQ(graph.constraint_count(), 2);
EXPECT_EQ(graph.constraint(0).type, ConstraintType::Coincident);
EXPECT_EQ(graph.constraint(1).type, ConstraintType::Concentric);
}
TEST(ConstraintGraphTest, HasConstraintBetween) {
Assembly assy("graph_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
graph.add_constraint({0, 1, ConstraintType::Coincident});
EXPECT_TRUE(graph.has_constraint_between(0, 1));
EXPECT_TRUE(graph.has_constraint_between(1, 0)); // 方向不重要,双向均查到
// 当 a == b 时的安全处理
EXPECT_FALSE(graph.has_constraint_between(-1, -1));
}
TEST(ConstraintGraphTest, RemoveConstraint) {
Assembly assy("graph_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
graph.add_constraint({0, 1, ConstraintType::Coincident});
graph.add_constraint({0, 1, ConstraintType::Distance, 5.0});
EXPECT_EQ(graph.constraint_count(), 2);
graph.remove_constraint(0);
// 约束不活跃,但计数不变(标记删除)
auto ncs = graph.node_constraints(0);
EXPECT_EQ(ncs.size(), 1); // 只剩约束 1
}
TEST(ConstraintGraphTest, ConstraintsBetween) {
Assembly assy("graph_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
graph.add_constraint({0, 1, ConstraintType::Coincident});
graph.add_constraint({0, 1, ConstraintType::Distance, 3.0});
auto between = graph.constraints_between(0, 1);
EXPECT_EQ(between.size(), 2);
}
TEST(ConstraintGraphTest, ClearGraph) {
Assembly assy("graph_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_constraint({0, 0, ConstraintType::Parallel}); // self-loop (unusual but valid)
graph.clear();
EXPECT_EQ(graph.node_count(), 0);
EXPECT_EQ(graph.constraint_count(), 0);
}
// ════════════════════════════════════════════════════════════════
// DOF 分析测试
// ════════════════════════════════════════════════════════════════
TEST(DOFAnalysisTest, SingleNodeHas6DOF) {
Assembly assy("dof_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
auto dof = graph.analyze_dof();
ASSERT_EQ(dof.size(), 1);
EXPECT_EQ(dof[0].total_dof, 6);
EXPECT_EQ(dof[0].eliminated_dof, 0);
EXPECT_EQ(dof[0].remaining_dof, 6);
EXPECT_FALSE(dof[0].is_fixed);
}
TEST(DOFAnalysisTest, CoincidentEliminates3DOF) {
Assembly assy("dof_test2");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
graph.add_constraint({0, 1, ConstraintType::Coincident});
auto dof = graph.analyze_dof();
// Coincident eliminates 3 DOF per node
EXPECT_EQ(dof[0].eliminated_dof, 3);
EXPECT_EQ(dof[1].eliminated_dof, 3);
EXPECT_EQ(dof[0].remaining_dof, 3);
}
TEST(DOFAnalysisTest, FullyConstrained) {
Assembly assy("dof_full");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
// Coincident (3) + Concentric (4) = 7 消除 per node
// 对 node_a: 3+4=7 > 6 → 全约束
graph.add_constraint({0, 1, ConstraintType::Coincident});
graph.add_constraint({0, 1, ConstraintType::Concentric});
auto dof = graph.analyze_dof();
EXPECT_TRUE(dof[0].is_fixed);
EXPECT_TRUE(dof[1].is_fixed);
EXPECT_TRUE(graph.is_fully_constrained());
}
TEST(DOFAnalysisTest, NotFullyConstrained) {
Assembly assy("dof_partial");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
graph.add_constraint({0, 1, ConstraintType::Parallel}); // only 2 DOF
EXPECT_FALSE(graph.is_fully_constrained());
EXPECT_EQ(graph.remaining_dof(0), 4); // 6 - 2 = 4
}
// ════════════════════════════════════════════════════════════════
// 过度约束检测测试
// ════════════════════════════════════════════════════════════════
TEST(OverConstraintTest, DetectOverConstrained) {
Assembly assy("over_test");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
// Coincident(3) + Concentric(4) + Distance(1) = 8 > 6
graph.add_constraint({0, 1, ConstraintType::Coincident});
graph.add_constraint({0, 1, ConstraintType::Concentric});
graph.add_constraint({0, 1, ConstraintType::Distance, 5.0});
auto over = graph.detect_over_constraints();
EXPECT_GE(over.size(), 1);
if (!over.empty()) {
EXPECT_TRUE(over[0].over_constrained);
EXPECT_GT(over[0].eliminated_dof, 6);
EXPECT_FALSE(over[0].message.empty());
}
}
TEST(OverConstraintTest, NoOverConstraintWhenBalanced) {
Assembly assy("balanced");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
// Coincident(3) + Distance(1) = 4 ≤ 6 → not over-constrained
graph.add_constraint({0, 1, ConstraintType::Coincident});
graph.add_constraint({0, 1, ConstraintType::Distance, 5.0});
auto over = graph.detect_over_constraints();
EXPECT_EQ(over.size(), 0);
}
// ════════════════════════════════════════════════════════════════
// Newton-Raphson 求解器测试
// ════════════════════════════════════════════════════════════════
TEST(NewtonRaphsonTest, EmptyGraphConverges) {
Assembly assy("nr_empty");
ConstraintGraph graph;
NewtonRaphsonSolver solver;
NRSolverConfig cfg;
cfg.max_iterations = 20;
auto result = solver.solve(graph, assy, cfg);
EXPECT_TRUE(result.converged);
}
TEST(NewtonRaphsonTest, SingleCoincidentConstraint) {
Assembly assy("nr_coin");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2), translate(0, 0, 5));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
graph.add_constraint({0, 1, ConstraintType::Coincident});
NewtonRaphsonSolver solver;
NRSolverConfig cfg;
cfg.max_iterations = 50;
auto result = solver.solve(graph, assy, cfg);
// NR 求解器应用于当前装配体
AABB3D bb_a = world_bounds(box_a);
AABB3D bb_b = world_bounds(box_b);
// 验证 nodes 已修改(local_transform 不应是绝对值的问题——NR 状态正确更新)
EXPECT_TRUE(box_a != nullptr && box_b != nullptr);
}
TEST(NewtonRaphsonTest, IncrementalSolve) {
Assembly assy("nr_inc");
auto* box_a = assy.root.add_part("box_a", make_box(2, 2, 2));
auto* box_b = assy.root.add_part("box_b", make_box(2, 2, 2), translate(0, 0, 10));
ConstraintGraph graph;
graph.add_node("a", box_a);
graph.add_node("b", box_b);
int ci = graph.add_constraint({0, 1, ConstraintType::Distance, 5.0});
NewtonRaphsonSolver solver;
NRSolverConfig cfg;
cfg.max_iterations = 50;
// 首次求解
auto result1 = solver.solve(graph, assy, cfg);
// 增量更新:将距离从 5 改为 3
auto result2 = solver.incremental_solve(graph, assy, ci, 3.0, cfg);
// 增量求解应当快速收敛
EXPECT_TRUE(result2.converged || result2.iterations < cfg.max_iterations);
}
// ════════════════════════════════════════════════════════════════
// ConstraintType 测试
// ════════════════════════════════════════════════════════════════
TEST(ConstraintTypeTest, DofEliminationValues) {
// 验证 DOF 消除量的合理性
EXPECT_EQ(constraint_dof_elimination(ConstraintType::Coincident), 3);
EXPECT_EQ(constraint_dof_elimination(ConstraintType::Concentric), 4);
EXPECT_EQ(constraint_dof_elimination(ConstraintType::Tangent), 1);
EXPECT_EQ(constraint_dof_elimination(ConstraintType::Distance), 1);
EXPECT_EQ(constraint_dof_elimination(ConstraintType::Angle), 1);
EXPECT_EQ(constraint_dof_elimination(ConstraintType::Parallel), 2);
EXPECT_EQ(constraint_dof_elimination(ConstraintType::Perpendicular), 2);
}
TEST(ConstraintTypeTest, TypeNames) {
EXPECT_STREQ(constraint_type_name(ConstraintType::Coincident), "Coincident");
EXPECT_STREQ(constraint_type_name(ConstraintType::Concentric), "Concentric");
EXPECT_STREQ(constraint_type_name(ConstraintType::Distance), "Distance");
EXPECT_STREQ(constraint_type_name(ConstraintType::Angle), "Angle");
EXPECT_STREQ(constraint_type_name(ConstraintType::Parallel), "Parallel");
}
+284
View File
@@ -0,0 +1,284 @@
#include <gtest/gtest.h>
#include "vde/brep/brep.h"
#include "vde/brep/modeling.h"
#include "vde/brep/direct_modeling.h"
#include "vde/brep/brep_validate.h"
#include "vde/core/transform.h"
#include <cmath>
using namespace vde::brep;
using namespace vde::core;
using namespace vde::curves;
// ═══════════════════════════════════════════════════════════
// tweak_face — 面偏移测试
// ═══════════════════════════════════════════════════════════
TEST(DirectModelingTest, TweakFace_BoxTopOffset) {
auto box = make_box(2, 2, 2);
auto result = tweak_face(box, 0, 0.5);
EXPECT_TRUE(result.success) << "tweak_face should succeed for box top face";
EXPECT_GT(result.body.num_faces(), 0u);
// After tweak, model should still be valid
auto val = validate(result.body);
EXPECT_TRUE(val.valid) << "Offset model should still be valid";
}
TEST(DirectModelingTest, TweakFace_BoxInwardOffset) {
auto box = make_box(2, 2, 2);
auto result = tweak_face(box, 0, -0.3);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, TweakFace_ZeroDelta) {
auto box = make_box(2, 2, 2);
auto result = tweak_face(box, 0, 0.0);
EXPECT_TRUE(result.success);
// Zero delta should produce valid model (essentially unchanged)
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
EXPECT_EQ(result.body.num_faces(), box.num_faces());
}
TEST(DirectModelingTest, TweakFace_InvalidFaceId) {
auto box = make_box(2, 2, 2);
auto result = tweak_face(box, 99, 0.5);
EXPECT_FALSE(result.success);
EXPECT_FALSE(result.errors.empty());
}
TEST(DirectModelingTest, TweakFace_CylinderCap) {
auto cyl = make_cylinder(2, 4, 32);
auto result = tweak_face(cyl, 0, 0.5);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
// ═══════════════════════════════════════════════════════════
// move_face — 面移动测试
// ═══════════════════════════════════════════════════════════
TEST(DirectModelingTest, MoveFace_Translate) {
auto box = make_box(2, 2, 2);
auto T = translate(Vector3D(0.5, 0, 0));
auto result = move_face(box, 0, T);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, MoveFace_Rotate) {
auto box = make_box(2, 2, 2);
// Rotate around Z axis by small angle
auto T = rotate_z(M_PI / 12);
auto result = move_face(box, 0, T);
// Small rotation should succeed
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, MoveFace_TranslateAndRotate) {
auto box = make_box(2, 2, 2);
auto T = translate(Vector3D(0.2, 0.1, 0)) * rotate_z(0.1);
auto result = move_face(box, 0, T);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, MoveFace_LargeRotationFails) {
auto box = make_box(2, 2, 2);
// Large rotation should cause normal flip and be rejected
auto T = rotate_z(2.0); // ~115 degrees
auto result = move_face(box, 0, T);
// May succeed or fail depending on detection, but should not crash
// At minimum the result body should exist
EXPECT_GE(result.body.num_faces(), 0u);
}
TEST(DirectModelingTest, MoveFace_InvalidFaceId) {
auto box = make_box(2, 2, 2);
auto T = translate(Vector3D(0.5, 0, 0));
auto result = move_face(box, 99, T);
EXPECT_FALSE(result.success);
EXPECT_FALSE(result.errors.empty());
}
// ═══════════════════════════════════════════════════════════
// replace_face — 面替换测试
// ═══════════════════════════════════════════════════════════
TEST(DirectModelingTest, ReplaceFace_WithSameSurface) {
auto box = make_box(2, 2, 2);
// Replace face 0 with its own surface (should succeed)
const auto& orig_face = box.face(0);
const auto& orig_surf = box.surface(orig_face.surface_id);
auto result = replace_face(box, 0, orig_surf);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, ReplaceFace_WithCurvedSurface) {
auto box = make_box(2, 2, 2);
// Create a slightly curved surface matching the top face boundary
// Top face of box(2,2,2) has corners: (-1,-1,1), (1,-1,1), (1,1,1), (-1,1,1)
std::vector<std::vector<Point3D>> grid = {
{Point3D(-1, -1, 1.0), Point3D(-1, 1, 1.0)},
{Point3D( 1, -1, 1.2), Point3D( 1, 1, 1.2)} // curved up in middle
};
NurbsSurface curved(grid, {0,0,1,1}, {0,0,1,1}, {}, 1, 1);
auto result = replace_face(box, 0, curved);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, ReplaceFace_InvalidFaceId) {
auto box = make_box(2, 2, 2);
const auto& orig_face = box.face(0);
const auto& orig_surf = box.surface(orig_face.surface_id);
auto result = replace_face(box, 99, orig_surf);
EXPECT_FALSE(result.success);
}
// ═══════════════════════════════════════════════════════════
// push_pull — 推拉测试
// ═══════════════════════════════════════════════════════════
TEST(DirectModelingTest, PushPull_ExtrudePositive) {
auto box = make_box(2, 2, 2);
auto result = push_pull(box, 0, 1.0);
EXPECT_TRUE(result.success);
// Extrusion should add side faces
EXPECT_GT(result.new_faces, 0);
EXPECT_GT(result.body.num_faces(), box.num_faces());
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, PushPull_CutNegative) {
auto box = make_box(2, 2, 2);
auto result = push_pull(box, 0, -0.3);
EXPECT_TRUE(result.success);
// Cut should not add new faces, but reshape existing ones
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, PushPull_ZeroDistance) {
auto box = make_box(2, 2, 2);
auto result = push_pull(box, 0, 0.0);
// Zero distance push-pull: model unchanged (side faces degenerate)
EXPECT_GE(result.body.num_faces(), box.num_faces());
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, PushPull_InvalidFaceId) {
auto box = make_box(2, 2, 2);
auto result = push_pull(box, 99, 1.0);
EXPECT_FALSE(result.success);
}
// ═══════════════════════════════════════════════════════════
// offset_face — 面等距偏移测试
// ═══════════════════════════════════════════════════════════
TEST(DirectModelingTest, OffsetFace_Basic) {
auto box = make_box(2, 2, 2);
auto result = offset_face(box, 0, 0.3);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
TEST(DirectModelingTest, OffsetFace_Inward) {
auto box = make_box(2, 2, 2);
auto result = offset_face(box, 0, -0.2);
EXPECT_TRUE(result.success);
auto val = validate(result.body);
EXPECT_TRUE(val.valid);
}
// ═══════════════════════════════════════════════════════════
// Validation after operations — 操作后模型验证
// ═══════════════════════════════════════════════════════════
TEST(DirectModelingTest, AllOps_PreserveValidation) {
auto box = make_box(2, 2, 2);
// tweak_face
auto r1 = tweak_face(box, 0, 0.3);
EXPECT_TRUE(validate(r1.body).valid);
// move_face
auto r2 = move_face(box, 0, translate(Vector3D(0.1, 0, 0)));
EXPECT_TRUE(validate(r2.body).valid);
// push_pull
auto r3 = push_pull(box, 0, 0.5);
EXPECT_TRUE(validate(r3.body).valid);
// offset_face
auto r4 = offset_face(box, 0, 0.2);
EXPECT_TRUE(validate(r4.body).valid);
}
// ═══════════════════════════════════════════════════════════
// 退化情况 — 非法位置测试
// ═══════════════════════════════════════════════════════════
TEST(DirectModelingTest, Degenerate_NegativeFaceId) {
auto box = make_box(2, 2, 2);
auto result = tweak_face(box, -1, 0.5);
EXPECT_FALSE(result.success);
}
TEST(DirectModelingTest, Degenerate_MoveIntoSelfIntersection) {
auto box = make_box(2, 2, 2);
// Move a face far enough to flip the normal → should be detected
auto T = rotate_x(M_PI); // 180° rotation
auto result = move_face(box, 0, T);
// Should at minimum produce a body (even if invalid)
EXPECT_GE(result.body.num_faces(), 0u);
}
TEST(DirectModelingTest, Degenerate_ExcessivePushPull) {
auto box = make_box(2, 2, 2);
// Push-pull with very large negative distance (cut through)
auto result = push_pull(box, 0, -5.0);
EXPECT_TRUE(result.success || !result.errors.empty());
// Should not crash even with excessive distance
}