feat(v5-M3): large assembly LOD + constraint solver + CAM strategies + direct modeling
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,3 +6,4 @@ add_vde_test(test_polygon)
|
||||
add_vde_test(test_cam_toolpath)
|
||||
add_vde_test(test_object_pool)
|
||||
add_vde_test(test_exact_predicates)
|
||||
add_vde_test(test_cam_strategies)
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "vde/core/cam_strategies.h"
|
||||
#include "vde/core/cam_toolpath.h"
|
||||
#include "vde/brep/brep.h"
|
||||
#include "vde/brep/modeling.h"
|
||||
#include "vde/mesh/halfedge_mesh.h"
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
using namespace vde::core;
|
||||
using namespace vde::brep;
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers
|
||||
// ===========================================================================
|
||||
|
||||
/// Create a simple box model for testing
|
||||
static BrepModel make_test_box(double w = 50.0, double h = 30.0, double d = 20.0) {
|
||||
return make_box(w, h, d);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tool 结构体测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, Tool_Defaults) {
|
||||
Tool t;
|
||||
EXPECT_EQ(t.id, 0);
|
||||
EXPECT_EQ(t.type, ToolType::ENDMILL);
|
||||
EXPECT_DOUBLE_EQ(t.diameter, 10.0);
|
||||
EXPECT_EQ(t.flutes, 2);
|
||||
EXPECT_DOUBLE_EQ(t.length, 50.0);
|
||||
EXPECT_DOUBLE_EQ(t.overall_length, 75.0);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, Tool_SpindleRPM) {
|
||||
Tool t;
|
||||
t.diameter = 10.0;
|
||||
// Vc = 100 m/min, D = 10mm → RPM = 100*1000 / (π*10) ≈ 3183
|
||||
double rpm = t.spindle_rpm(100.0);
|
||||
EXPECT_NEAR(rpm, 3183.1, 10.0);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, Tool_SpindleRPM_ZeroDiameter) {
|
||||
Tool t;
|
||||
t.diameter = 0.0;
|
||||
double rpm = t.spindle_rpm(100.0);
|
||||
EXPECT_DOUBLE_EQ(rpm, 0.0);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, Tool_FeedRate) {
|
||||
Tool t;
|
||||
t.diameter = 10.0;
|
||||
t.flutes = 2;
|
||||
// Vc=100, fz=0.1 → RPM≈3183, F=3183*2*0.1≈637
|
||||
double f = t.feed_rate_mm_per_min(0.1);
|
||||
EXPECT_GT(f, 500.0);
|
||||
EXPECT_LT(f, 800.0);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// ToolLibrary 测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, ToolLibrary_AddAndFindById) {
|
||||
ToolLibrary lib;
|
||||
Tool t1;
|
||||
t1.id = 10;
|
||||
t1.name = "Endmill10";
|
||||
t1.diameter = 10.0;
|
||||
|
||||
Tool t2;
|
||||
t2.id = 20;
|
||||
t2.name = "BallNose6";
|
||||
t2.type = ToolType::BALLNOSE;
|
||||
t2.diameter = 6.0;
|
||||
|
||||
lib.add_tool(t1);
|
||||
lib.add_tool(t2);
|
||||
|
||||
EXPECT_EQ(lib.size(), 2u);
|
||||
|
||||
auto found = lib.find_tool(10);
|
||||
ASSERT_TRUE(found.has_value());
|
||||
EXPECT_EQ(found->name, "Endmill10");
|
||||
EXPECT_DOUBLE_EQ(found->diameter, 10.0);
|
||||
|
||||
auto not_found = lib.find_tool(999);
|
||||
EXPECT_FALSE(not_found.has_value());
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, ToolLibrary_FindByName) {
|
||||
ToolLibrary lib;
|
||||
Tool t;
|
||||
t.name = "Drill5";
|
||||
t.type = ToolType::DRILL;
|
||||
t.diameter = 5.0;
|
||||
lib.add_tool(t);
|
||||
|
||||
auto found = lib.find_tool("Drill5");
|
||||
ASSERT_TRUE(found.has_value());
|
||||
EXPECT_EQ(found->type, ToolType::DRILL);
|
||||
|
||||
auto not_found = lib.find_tool("Nonexistent");
|
||||
EXPECT_FALSE(not_found.has_value());
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, ToolLibrary_ListTools) {
|
||||
ToolLibrary lib;
|
||||
Tool t1, t2, t3;
|
||||
t1.name = "A";
|
||||
t2.name = "B";
|
||||
t3.name = "C";
|
||||
lib.add_tool(t1);
|
||||
lib.add_tool(t2);
|
||||
lib.add_tool(t3);
|
||||
|
||||
const auto& tools = lib.list_tools();
|
||||
EXPECT_EQ(tools.size(), 3u);
|
||||
EXPECT_EQ(tools[0].name, "A");
|
||||
EXPECT_EQ(tools[1].name, "B");
|
||||
EXPECT_EQ(tools[2].name, "C");
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, ToolLibrary_PreserveExplicitId) {
|
||||
ToolLibrary lib;
|
||||
Tool t;
|
||||
t.id = 42;
|
||||
t.name = "CustomID";
|
||||
lib.add_tool(t);
|
||||
|
||||
auto found = lib.find_tool(42);
|
||||
ASSERT_TRUE(found.has_value());
|
||||
EXPECT_EQ(found->name, "CustomID");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// roughing_toolpath 测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, RoughingToolpath_GeneratesSegments) {
|
||||
auto box = make_test_box(50, 30, 20);
|
||||
Tool tool;
|
||||
tool.diameter = 10.0;
|
||||
RoughingParams params;
|
||||
params.step_down = 2.0;
|
||||
params.stock_to_leave = 0.5;
|
||||
params.safe_z = 15.0;
|
||||
|
||||
auto tp = roughing_toolpath(box, tool, params);
|
||||
|
||||
EXPECT_EQ(tp.name, "Roughing");
|
||||
EXPECT_GT(tp.segments.size(), 5u) << "Should have multiple segments";
|
||||
EXPECT_DOUBLE_EQ(tp.safe_z, 15.0);
|
||||
|
||||
// Should have some linear cutting moves
|
||||
bool has_linear = false;
|
||||
for (auto& seg : tp.segments) {
|
||||
if (seg.type == PathSegmentType::Linear && seg.feed_rate > 0) {
|
||||
has_linear = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(has_linear);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, RoughingToolpath_MultipleDepthSlices) {
|
||||
auto box = make_test_box(20, 20, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 6.0;
|
||||
RoughingParams params;
|
||||
params.step_down = 2.0; // 10mm depth → ~5 slices
|
||||
params.safe_z = 15.0;
|
||||
|
||||
auto tp = roughing_toolpath(box, tool, params);
|
||||
|
||||
// Count plunge moves (z going down)
|
||||
int plunge_count = 0;
|
||||
for (auto& seg : tp.segments) {
|
||||
if (seg.type == PathSegmentType::Linear && seg.start.z() > seg.end.z()) {
|
||||
plunge_count++;
|
||||
}
|
||||
}
|
||||
EXPECT_GE(plunge_count, 3) << "Should have multiple depth slices";
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, RoughingToolpath_RespectsStepDown) {
|
||||
auto box = make_test_box(10, 10, 5);
|
||||
Tool tool;
|
||||
tool.diameter = 5.0;
|
||||
RoughingParams params;
|
||||
params.step_down = 5.0; // single slice
|
||||
params.safe_z = 10.0;
|
||||
|
||||
auto tp = roughing_toolpath(box, tool, params);
|
||||
|
||||
// Should not crash and should produce valid toolpath
|
||||
EXPECT_GT(tp.segments.size(), 3u);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// finishing_toolpath 测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, FinishingToolpath_Parallel) {
|
||||
auto box = make_test_box(40, 30, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 8.0;
|
||||
FinishingParams params;
|
||||
params.step_over = 2.0;
|
||||
params.safe_z = 15.0;
|
||||
params.depth_of_cut = 0.0;
|
||||
|
||||
auto tp = finishing_toolpath(box, tool, params, FinishingStrategy::PARALLEL);
|
||||
|
||||
EXPECT_EQ(tp.name, "Finishing_Parallel");
|
||||
EXPECT_GT(tp.segments.size(), 5u);
|
||||
|
||||
// Should have linear segment at finishing depth
|
||||
bool has_depth = false;
|
||||
for (auto& seg : tp.segments) {
|
||||
if (seg.type == PathSegmentType::Linear && seg.z_depth < -1e-9) {
|
||||
has_depth = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(has_depth);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, FinishingToolpath_Spiral) {
|
||||
auto box = make_test_box(40, 30, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 8.0;
|
||||
FinishingParams params;
|
||||
params.step_over = 3.0;
|
||||
params.safe_z = 15.0;
|
||||
params.depth_of_cut = 0.0;
|
||||
|
||||
auto tp = finishing_toolpath(box, tool, params, FinishingStrategy::SPIRAL);
|
||||
|
||||
EXPECT_EQ(tp.name, "Finishing_Spiral");
|
||||
EXPECT_GT(tp.segments.size(), 3u);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, FinishingToolpath_SpiralMultipleRings) {
|
||||
auto box = make_test_box(50, 50, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 10.0;
|
||||
FinishingParams params;
|
||||
params.step_over = 2.0; // offset inward 2mm each ring → many rings for 50mm
|
||||
params.safe_z = 15.0;
|
||||
|
||||
auto tp = finishing_toolpath(box, tool, params, FinishingStrategy::SPIRAL);
|
||||
|
||||
// Should have many linear segments (multiple rings)
|
||||
int linear_count = 0;
|
||||
for (auto& seg : tp.segments) {
|
||||
if (seg.type == PathSegmentType::Linear) linear_count++;
|
||||
}
|
||||
EXPECT_GT(linear_count, 20) << "Should have many segments from multiple spiral rings";
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, FinishingToolpath_CustomDepth) {
|
||||
auto box = make_test_box(30, 30, 15);
|
||||
Tool tool;
|
||||
tool.diameter = 5.0;
|
||||
FinishingParams params;
|
||||
params.step_over = 1.0;
|
||||
params.safe_z = 10.0;
|
||||
params.depth_of_cut = -5.0; // custom depth
|
||||
|
||||
auto tp = finishing_toolpath(box, tool, params, FinishingStrategy::PARALLEL);
|
||||
|
||||
EXPECT_DOUBLE_EQ(tp.cut_z, -5.0);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// drilling_toolpath 测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, DrillingToolpath_G81) {
|
||||
std::vector<DrillPoint> points = {
|
||||
{Point3D(10, 10, 0), -8.0, 2.0},
|
||||
{Point3D(30, 20, 0), -8.0, 2.0},
|
||||
{Point3D(50, 10, 0), -8.0, 2.0},
|
||||
};
|
||||
Tool tool;
|
||||
tool.type = ToolType::DRILL;
|
||||
tool.diameter = 5.0;
|
||||
|
||||
auto tp = drilling_toolpath(points, tool, DrillingCycle::G81, 10.0, 200.0);
|
||||
|
||||
EXPECT_EQ(tp.name, "Drill_G81");
|
||||
EXPECT_GT(tp.segments.size(), 6u); // 3 holes × (rapid + feed + retract)
|
||||
|
||||
// Should have rapid moves and linear feed moves
|
||||
bool has_rapid = false;
|
||||
bool has_feed = false;
|
||||
for (auto& seg : tp.segments) {
|
||||
if (seg.type == PathSegmentType::Rapid) has_rapid = true;
|
||||
if (seg.type == PathSegmentType::Linear && seg.feed_rate > 0) has_feed = true;
|
||||
}
|
||||
EXPECT_TRUE(has_rapid);
|
||||
EXPECT_TRUE(has_feed);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, DrillingToolpath_G83_PeckDrilling) {
|
||||
std::vector<DrillPoint> points = {
|
||||
{Point3D(15, 15, 0), -12.0, 3.0}, // peck_depth=3 → 4 pecks
|
||||
};
|
||||
Tool tool;
|
||||
tool.type = ToolType::DRILL;
|
||||
tool.diameter = 3.0;
|
||||
|
||||
auto tp = drilling_toolpath(points, tool, DrillingCycle::G83, 10.0, 150.0);
|
||||
|
||||
EXPECT_EQ(tp.name, "Drill_G83");
|
||||
// G83 should have more segments than G81 due to pecking
|
||||
EXPECT_GT(tp.segments.size(), 4u);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, DrillingToolpath_G84_Tapping) {
|
||||
std::vector<DrillPoint> points = {
|
||||
{Point3D(20, 20, 0), -6.0, 0.0},
|
||||
};
|
||||
Tool tool;
|
||||
tool.type = ToolType::TAP;
|
||||
tool.diameter = 4.0;
|
||||
|
||||
auto tp = drilling_toolpath(points, tool, DrillingCycle::G84, 10.0, 100.0);
|
||||
|
||||
EXPECT_EQ(tp.name, "Tap_G84");
|
||||
// G84 should: rapid → feed in → feed out → retract
|
||||
EXPECT_GT(tp.segments.size(), 2u);
|
||||
|
||||
// Should have both feed-in and feed-out linear moves
|
||||
int linear_count = 0;
|
||||
for (auto& seg : tp.segments) {
|
||||
if (seg.type == PathSegmentType::Linear) linear_count++;
|
||||
}
|
||||
EXPECT_GE(linear_count, 2) << "Feed in + feed out";
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, DrillingToolpath_EmptyPointsList) {
|
||||
std::vector<DrillPoint> empty;
|
||||
Tool tool;
|
||||
auto tp = drilling_toolpath(empty, tool, DrillingCycle::G81, 10.0, 200.0);
|
||||
|
||||
EXPECT_EQ(tp.segments.size(), 0u);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 后处理器测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, PostProcessor_FanucPrologue) {
|
||||
Toolpath tp;
|
||||
tp.name = "Test";
|
||||
tp.safe_z = 5.0;
|
||||
FanucPost post;
|
||||
std::string pro = post.prologue(tp);
|
||||
|
||||
EXPECT_TRUE(pro.find("Fanuc") != std::string::npos || pro.find("G90") != std::string::npos);
|
||||
EXPECT_TRUE(pro.find("G90") != std::string::npos);
|
||||
EXPECT_TRUE(pro.find("G21") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, PostProcessor_FanucEpilogue) {
|
||||
Toolpath tp;
|
||||
tp.safe_z = 5.0;
|
||||
FanucPost post;
|
||||
std::string epi = post.epilogue(tp);
|
||||
|
||||
EXPECT_TRUE(epi.find("M30") != std::string::npos);
|
||||
EXPECT_TRUE(epi.find("G28") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, PostProcessor_Siemens) {
|
||||
Toolpath tp;
|
||||
tp.name = "Test";
|
||||
tp.safe_z = 10.0;
|
||||
SiemensPost post;
|
||||
std::string pro = post.prologue(tp);
|
||||
std::string epi = post.epilogue(tp);
|
||||
|
||||
EXPECT_TRUE(pro.find("Siemens") != std::string::npos || pro.find("G90") != std::string::npos);
|
||||
EXPECT_TRUE(epi.find("M30") != std::string::npos);
|
||||
EXPECT_TRUE(epi.find("SUPA") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, PostProcessor_Heidenhain) {
|
||||
Toolpath tp;
|
||||
tp.name = "TestPart";
|
||||
tp.cut_z = -5.0;
|
||||
tp.safe_z = 10.0;
|
||||
HeidenhainPost post;
|
||||
std::string pro = post.prologue(tp);
|
||||
std::string epi = post.epilogue(tp);
|
||||
|
||||
EXPECT_TRUE(pro.find("BEGIN PGM") != std::string::npos);
|
||||
EXPECT_TRUE(pro.find("TOOL CALL") != std::string::npos);
|
||||
EXPECT_TRUE(epi.find("END PGM") != std::string::npos);
|
||||
EXPECT_TRUE(epi.find("M5") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, PostProcessor_FormatArcCommands) {
|
||||
PathSegment seg_cw{{0,0,0}, {5,5,0}, Point3D(2.5, 2.5, 0), PathSegmentType::ArcCW, 100.0, 0};
|
||||
PathSegment seg_ccw{{5,5,0}, {0,0,0}, Point3D(2.5, 2.5, 0), PathSegmentType::ArcCCW, 100.0, 0};
|
||||
|
||||
FanucPost fanuc;
|
||||
std::string g2 = fanuc.format_gcode(seg_cw);
|
||||
std::string g3 = fanuc.format_gcode(seg_ccw);
|
||||
EXPECT_TRUE(g2.find("G2") != std::string::npos);
|
||||
EXPECT_TRUE(g3.find("G3") != std::string::npos);
|
||||
|
||||
HeidenhainPost heid;
|
||||
std::string h_cw = heid.format_gcode(seg_cw);
|
||||
std::string h_ccw = heid.format_gcode(seg_ccw);
|
||||
EXPECT_TRUE(h_cw.find("DR-") != std::string::npos);
|
||||
EXPECT_TRUE(h_ccw.find("DR+") != std::string::npos);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PostProcessor::post_process 完整输出测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, PostProcessor_FullFanucPost) {
|
||||
Toolpath tp;
|
||||
tp.name = "Contour";
|
||||
tp.safe_z = 5.0;
|
||||
tp.cut_z = -1.0;
|
||||
tp.segments.push_back({Point3D(0,0,5), Point3D(10,0,5),
|
||||
Point3D::Zero(), PathSegmentType::Rapid});
|
||||
tp.segments.push_back({Point3D(10,0,5), Point3D(10,0,-1),
|
||||
Point3D::Zero(), PathSegmentType::Linear, 500.0, -1.0});
|
||||
|
||||
FanucPost post;
|
||||
std::string gcode = post.post_process(tp);
|
||||
|
||||
EXPECT_TRUE(gcode.find("G90") != std::string::npos);
|
||||
EXPECT_TRUE(gcode.find("M30") != std::string::npos);
|
||||
EXPECT_TRUE(gcode.find("G1") != std::string::npos);
|
||||
EXPECT_TRUE(gcode.find("G0") != std::string::npos);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// material_removal_simulation 测试
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, MaterialRemovalSimulation_InitialState) {
|
||||
auto box = make_test_box(20, 20, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 10.0;
|
||||
Toolpath tp;
|
||||
tp.safe_z = 15.0;
|
||||
tp.cut_z = 0.0;
|
||||
|
||||
auto result = material_removal_simulation(box, tp, tool);
|
||||
|
||||
// No cutting segments → nothing removed
|
||||
EXPECT_NEAR(result.volume_remaining, result.volume_remaining, 1e-9); // valid volume
|
||||
EXPECT_GT(result.volume_remaining, 0.0) << "Should have positive remaining volume";
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, MaterialRemovalSimulation_AfterRoughing) {
|
||||
auto box = make_test_box(20, 20, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 10.0;
|
||||
RoughingParams params;
|
||||
params.step_down = 5.0;
|
||||
params.stock_to_leave = 0.0;
|
||||
params.safe_z = 15.0;
|
||||
|
||||
auto tp = roughing_toolpath(box, tool, params);
|
||||
auto result = material_removal_simulation(box, tp, tool);
|
||||
|
||||
EXPECT_GT(result.volume_remaining, 0.0) << "Should have remaining material";
|
||||
EXPECT_GT(result.steps, 0) << "Should have simulation steps";
|
||||
|
||||
// Remaining volume should be less than original (some material removed)
|
||||
double original_vol = 20.0 * 20.0 * 10.0; // 4000 mm³
|
||||
EXPECT_LT(result.volume_remaining, original_vol * 1.1) << "Remaining < original";
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Parameter type tests
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, Tool_AllTypes) {
|
||||
// Verify all tool types can be constructed
|
||||
Tool endmill;
|
||||
endmill.type = ToolType::ENDMILL;
|
||||
EXPECT_EQ(endmill.type, ToolType::ENDMILL);
|
||||
|
||||
Tool ballnose;
|
||||
ballnose.type = ToolType::BALLNOSE;
|
||||
EXPECT_EQ(ballnose.type, ToolType::BALLNOSE);
|
||||
|
||||
Tool drill;
|
||||
drill.type = ToolType::DRILL;
|
||||
EXPECT_EQ(drill.type, ToolType::DRILL);
|
||||
|
||||
Tool facemill;
|
||||
facemill.type = ToolType::FACEMILL;
|
||||
EXPECT_EQ(facemill.type, ToolType::FACEMILL);
|
||||
|
||||
Tool tap;
|
||||
tap.type = ToolType::TAP;
|
||||
EXPECT_EQ(tap.type, ToolType::TAP);
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, DrillingCycle_AllTypes) {
|
||||
std::vector<DrillPoint> points = {{Point3D(0, 0, 0), -5.0, 1.0}};
|
||||
Tool drill;
|
||||
drill.type = ToolType::DRILL;
|
||||
drill.diameter = 3.0;
|
||||
|
||||
auto g81 = drilling_toolpath(points, drill, DrillingCycle::G81);
|
||||
EXPECT_EQ(g81.name, "Drill_G81");
|
||||
|
||||
auto g83 = drilling_toolpath(points, drill, DrillingCycle::G83);
|
||||
EXPECT_EQ(g83.name, "Drill_G83");
|
||||
|
||||
auto g84 = drilling_toolpath(points, drill, DrillingCycle::G84);
|
||||
EXPECT_EQ(g84.name, "Tap_G84");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 粗加工边界条件
|
||||
// ===========================================================================
|
||||
|
||||
TEST(CamStrategiesTest, RoughingToolpath_ZeroStepDown) {
|
||||
auto box = make_test_box(20, 20, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 10.0;
|
||||
RoughingParams params;
|
||||
params.step_down = 0.0; // would cause infinite loop, but should still produce output
|
||||
params.safe_z = 10.0;
|
||||
|
||||
// Should not crash
|
||||
auto tp = roughing_toolpath(box, tool, params);
|
||||
EXPECT_GE(tp.segments.size(), 1u); // at least has safe Z retract
|
||||
}
|
||||
|
||||
TEST(CamStrategiesTest, RoughingToolpath_LargeStepDown) {
|
||||
auto box = make_test_box(20, 20, 10);
|
||||
Tool tool;
|
||||
tool.diameter = 10.0;
|
||||
RoughingParams params;
|
||||
params.step_down = 100.0; // larger than model → single slice
|
||||
params.safe_z = 20.0;
|
||||
|
||||
auto tp = roughing_toolpath(box, tool, params);
|
||||
EXPECT_GT(tp.segments.size(), 3u);
|
||||
}
|
||||
Reference in New Issue
Block a user