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
|
||||
}
|
||||
Reference in New Issue
Block a user