diff --git a/docs/23-剩余路线图.md b/docs/23-剩余路线图.md index 262b37a..96b7002 100644 --- a/docs/23-剩余路线图.md +++ b/docs/23-剩余路线图.md @@ -20,7 +20,7 @@ v4.1 ──→ v4.2 ──→ v4.3 ──→ v4.4 ──→ v4.5 ──→ v5.0 --- -## v4.5 — 实用增强 +## v4.5 — 实用增强 🚧 | 项目 | 工作量 | 说明 | |------|--------|------| diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9730b49..e395c60 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -172,6 +172,9 @@ add_library(vde_brep STATIC brep/dxf_import.cpp brep/assembly_instance.cpp brep/euler_op.cpp + brep/draft_analysis.cpp + brep/incremental_mesh.cpp + brep/kinematic_chain.cpp ) target_include_directories(vde_brep PUBLIC ${CMAKE_SOURCE_DIR}/include diff --git a/tests/brep/CMakeLists.txt b/tests/brep/CMakeLists.txt index 61bcce1..229b855 100644 --- a/tests/brep/CMakeLists.txt +++ b/tests/brep/CMakeLists.txt @@ -23,3 +23,6 @@ add_vde_test(test_incremental_update) add_vde_test(test_v4_1) add_vde_test(test_tolerance) add_vde_test(test_euler_op) +add_vde_test(test_draft_analysis) +add_vde_test(test_incremental_mesh) +add_vde_test(test_kinematic_chain) diff --git a/tests/brep/test_draft_analysis.cpp b/tests/brep/test_draft_analysis.cpp new file mode 100644 index 0000000..4d801cc --- /dev/null +++ b/tests/brep/test_draft_analysis.cpp @@ -0,0 +1,168 @@ +#include +#include "vde/brep/brep.h" +#include "vde/brep/modeling.h" +#include "vde/brep/draft_analysis.h" +#include + +using namespace vde::brep; +using namespace vde::core; + +// ═══════════════════════════════════════════════════════════ +// Draft Angle — Single Face +// ═══════════════════════════════════════════════════════════ + +TEST(DraftAnalysisTest, DraftAngle_BoxTopFace) { + auto box = make_box(2, 2, 2); // centered at origin, faces at ±1 + // Top face (z = 1): normal = (0, 0, 1) + // Pull direction (0, 0, 1): face normal aligned → angle ≈ 0 + double angle = draft_angle(box, 0, Vector3D(0, 0, 1)); + EXPECT_NEAR(std::abs(angle), 0.0, 0.1); +} + +TEST(DraftAnalysisTest, DraftAngle_BoxSideFace) { + auto box = make_box(2, 2, 2); + // Side face normal is perpendicular to pull direction (0,0,1) + // Draft angle ≈ 90° (π/2) + double angle = draft_angle(box, 0, Vector3D(0, 0, 1)); + // Side face: normal.x = ±1, dot(pull) ≈ 0 → angle ≈ π/2 + // We just verify it's finite and reasonable + EXPECT_TRUE(std::isfinite(angle)); +} + +TEST(DraftAnalysisTest, DraftAngle_CylinderTop) { + auto cyl = make_cylinder(2, 6, 32); + // Top cap face normal ≈ (0, 0, 1), pull direction (0, 0, 1) → angle ≈ 0 + double angle = draft_angle(cyl, 0, Vector3D(0, 0, 1)); + EXPECT_NEAR(std::abs(angle), 0.0, 0.15); +} + +TEST(DraftAnalysisTest, DraftAngle_Sphere) { + auto sphere = make_sphere(2, 16, 16); + // Sphere has varying normals; pull dir (0,0,1) → angle varies + double angle = draft_angle(sphere, 0, Vector3D(0, 0, 1)); + EXPECT_TRUE(std::isfinite(angle)); +} + +// ═══════════════════════════════════════════════════════════ +// Full Model Analysis +// ═══════════════════════════════════════════════════════════ + +TEST(DraftAnalysisTest, AnalyzeDraft_Box) { + auto box = make_box(2, 2, 2); + auto result = analyze_draft(box, Vector3D(0, 0, 1), 0.01745); // 1° min + + EXPECT_EQ(result.faces.size(), box.num_faces()); + EXPECT_GE(result.positive_count + result.negative_count + + result.zero_draft_count + result.undercut_count, 1); + EXPECT_TRUE(std::isfinite(result.area_weighted_angle)); +} + +TEST(DraftAnalysisTest, AnalyzeDraft_PullUp) { + auto box = make_box(2, 2, 2); + // Pull direction (0,0,1): top = zero draft, sides = positive/negative depending on normal + auto result = analyze_draft(box, Vector3D(0, 0, 1)); + + EXPECT_GE(result.faces.size(), 1u); + // Check that classifications sum to face count + int total = result.positive_count + result.negative_count + + result.zero_draft_count + result.undercut_count; + EXPECT_EQ(total, static_cast(result.faces.size())); +} + +TEST(DraftAnalysisTest, AnalyzeDraft_PullSide) { + auto box = make_box(2, 2, 2); + auto result = analyze_draft(box, Vector3D(1, 0, 0)); + + EXPECT_GE(result.faces.size(), 1u); +} + +TEST(DraftAnalysisTest, AnalyzeDraft_EmptyBody) { + BrepModel empty; + auto result = analyze_draft(empty, Vector3D(0, 0, 1)); + EXPECT_EQ(result.faces.size(), 0u); + EXPECT_TRUE(result.is_moldable()); +} + +TEST(DraftAnalysisTest, AnalyzeDraft_MinAngleLarger) { + auto box = make_box(2, 2, 2); + // With a very large min angle (45°), most faces should be classified as zero draft + auto result = analyze_draft(box, Vector3D(0, 0, 1), M_PI / 4.0); + EXPECT_GE(result.zero_draft_count, 0); +} + +// ═══════════════════════════════════════════════════════════ +// Classification +// ═══════════════════════════════════════════════════════════ + +TEST(DraftAnalysisTest, Classification_Positive) { + auto box = make_box(2, 2, 2); + auto result = analyze_draft(box, Vector3D(0, 0, 1), 0.001); + // At least some faces should be classified + bool has_positive = result.positive_count > 0; + bool has_negative = result.negative_count > 0; + bool has_zero = result.zero_draft_count > 0; + EXPECT_TRUE(has_positive || has_negative || has_zero); +} + +TEST(DraftAnalysisTest, HasUndercut_SimpleBox) { + auto box = make_box(2, 2, 2); + auto result = analyze_draft(box, Vector3D(0, 0, 1)); + // Simple box along Z should be moldable (no undercut) + EXPECT_TRUE(result.is_moldable()); +} + +// ═══════════════════════════════════════════════════════════ +// Report +// ═══════════════════════════════════════════════════════════ + +TEST(DraftAnalysisTest, Report_NotEmpty) { + auto box = make_box(2, 2, 2); + auto result = analyze_draft(box, Vector3D(0, 0, 1)); + auto report = draft_report(result); + EXPECT_FALSE(report.empty()); + EXPECT_NE(report.find("Draft Analysis Report"), std::string::npos); + EXPECT_NE(report.find("Moldability"), std::string::npos); +} + +// ═══════════════════════════════════════════════════════════ +// Angle Distribution +// ═══════════════════════════════════════════════════════════ + +TEST(DraftAnalysisTest, AngleDistribution_NotEmpty) { + auto box = make_box(2, 2, 2); + auto result = analyze_draft(box, Vector3D(0, 0, 1)); + auto dist = result.angle_distribution(10); + EXPECT_GT(dist.size(), 0u); +} + +TEST(DraftAnalysisTest, AngleDistribution_ZeroBins) { + auto box = make_box(2, 2, 2); + auto result = analyze_draft(box, Vector3D(0, 0, 1)); + auto dist = result.angle_distribution(0); + EXPECT_EQ(dist.size(), 0u); +} + +// ═══════════════════════════════════════════════════════════ +// Draft Face Creation (stub — returns false) +// ═══════════════════════════════════════════════════════════ + +TEST(DraftAnalysisTest, CreateDraftFace_Stub) { + auto box = make_box(2, 2, 2); + // create_draft_face is a stub (needs mutable surface access) + bool ok = create_draft_face(box, 0, Vector3D(0, 0, 1), 0.1); + // Stub returns false (not yet implemented at topology level) + EXPECT_FALSE(ok); +} + +TEST(DraftAnalysisTest, ApplyDraft_EmptyList) { + auto box = make_box(2, 2, 2); + int count = apply_draft(box, {}, Vector3D(0, 0, 1), 0.1); + EXPECT_EQ(count, 0); +} + +TEST(DraftAnalysisTest, ApplyDraft_Stub) { + auto box = make_box(2, 2, 2); + int count = apply_draft(box, {0, 1}, Vector3D(0, 0, 1), 0.1); + // create_draft_face returns false → count = 0 + EXPECT_EQ(count, 0); +} diff --git a/tests/brep/test_incremental_mesh.cpp b/tests/brep/test_incremental_mesh.cpp new file mode 100644 index 0000000..93521f3 --- /dev/null +++ b/tests/brep/test_incremental_mesh.cpp @@ -0,0 +1,183 @@ +#include +#include "vde/brep/brep.h" +#include "vde/brep/modeling.h" +#include "vde/brep/incremental_mesh.h" +#include + +using namespace vde::brep; +using namespace vde::core; + +// ═══════════════════════════════════════════════════════════ +// Basic Operations +// ═══════════════════════════════════════════════════════════ + +TEST(IncrementalMeshTest, EmptyMesher) { + IncrementalMesher mesher; + EXPECT_EQ(mesher.dirty_count(), 0); + EXPECT_EQ(mesher.total_faces(), 0); + EXPECT_EQ(mesher.hit_rate(), 0.0); + EXPECT_EQ(mesher.memory_estimate(), 0u); +} + +TEST(IncrementalMeshTest, RebuildAll_UnitBox) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + + int count = mesher.rebuild_all(box, 0.01); + EXPECT_GT(count, 0); + EXPECT_EQ(mesher.dirty_count(), 0); +} + +TEST(IncrementalMeshTest, RebuildAll_Cylinder) { + auto cyl = make_cylinder(2, 6, 32); + IncrementalMesher mesher; + + int count = mesher.rebuild_all(cyl); + EXPECT_GT(count, 0); +} + +// ═══════════════════════════════════════════════════════════ +// Invalidate & Incremental Rebuild +// ═══════════════════════════════════════════════════════════ + +TEST(IncrementalMeshTest, InvalidateFace_MarksDirty) { + IncrementalMesher mesher; + mesher.invalidate_face(0); + EXPECT_EQ(mesher.dirty_count(), 1); +} + +TEST(IncrementalMeshTest, InvalidateFace_ThenRebuild) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + + // Full rebuild first + mesher.rebuild_all(box); + + // Invalidate one face + mesher.invalidate_face(0); + EXPECT_EQ(mesher.dirty_count(), 1); + + // Rebuild only dirty + int count = mesher.rebuild_dirty(box); + EXPECT_GT(count, 0); + EXPECT_EQ(mesher.dirty_count(), 0); +} + +TEST(IncrementalMeshTest, RebuildDirty_Empty) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + + // No faces marked dirty → rebuild_dirty should do nothing + int count = mesher.rebuild_dirty(box); + EXPECT_EQ(count, 0); +} + +TEST(IncrementalMeshTest, MultipleInvalidates) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + mesher.invalidate_face(0); + mesher.invalidate_face(1); + mesher.invalidate_face(0); // duplicate — should still be 2 dirty + EXPECT_EQ(mesher.dirty_count(), 2); + + int count = mesher.rebuild_dirty(box); + EXPECT_GE(count, 2); + EXPECT_EQ(mesher.dirty_count(), 0); +} + +// ═══════════════════════════════════════════════════════════ +// Cache +// ═══════════════════════════════════════════════════════════ + +TEST(IncrementalMeshTest, GetMesh_AfterRebuild) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + const auto* mesh = mesher.get_mesh(0); + EXPECT_TRUE(mesh != nullptr); + EXPECT_GT(mesher.cache_hits(), 0); +} + +TEST(IncrementalMeshTest, GetMesh_AfterInvalidate) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + mesher.invalidate_face(0); + const auto* mesh = mesher.get_mesh(0); + EXPECT_TRUE(mesh == nullptr); // dirty face → cache miss + EXPECT_GT(mesher.cache_misses(), 0); +} + +TEST(IncrementalMeshTest, GetMesh_InvalidFaceId) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + const auto* mesh = mesher.get_mesh(999); + EXPECT_TRUE(mesh == nullptr); +} + +TEST(IncrementalMeshTest, HitRate_AfterRebuild) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + // Access a cached mesh → hit + mesher.get_mesh(0); + EXPECT_GT(mesher.hit_rate(), 0.0); +} + +// ═══════════════════════════════════════════════════════════ +// Merged Mesh +// ═══════════════════════════════════════════════════════════ + +TEST(IncrementalMeshTest, MergedMesh_AfterFullRebuild) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + auto merged = mesher.merged_mesh(); + EXPECT_GT(merged.num_vertices(), 0u); + EXPECT_GT(merged.num_faces(), 0u); +} + +TEST(IncrementalMeshTest, MergedMesh_AfterPartialInvalidate) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + // Invalidate one face → merged mesh should miss it + mesher.invalidate_face(0); + auto merged = mesher.merged_mesh(); + // Still should produce a valid (but partial) mesh + EXPECT_GE(merged.num_vertices(), 0u); +} + +// ═══════════════════════════════════════════════════════════ +// Clear & Memory +// ═══════════════════════════════════════════════════════════ + +TEST(IncrementalMeshTest, ClearAll) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + EXPECT_GT(mesher.total_faces(), 0); + + mesher.clear_all(); + EXPECT_EQ(mesher.total_faces(), 0); + EXPECT_EQ(mesher.dirty_count(), 0); + EXPECT_EQ(mesher.hit_rate(), 0.0); +} + +TEST(IncrementalMeshTest, MemoryEstimate) { + auto box = make_box(2, 2, 2); + IncrementalMesher mesher; + mesher.rebuild_all(box); + + size_t mem = mesher.memory_estimate(); + EXPECT_GT(mem, 0u); +} diff --git a/tests/brep/test_kinematic_chain.cpp b/tests/brep/test_kinematic_chain.cpp new file mode 100644 index 0000000..45d29f0 --- /dev/null +++ b/tests/brep/test_kinematic_chain.cpp @@ -0,0 +1,282 @@ +#include +#include "vde/brep/kinematic_chain.h" +#include + +using namespace vde::brep; +using namespace vde::core; + +// ═══════════════════════════════════════════════════════════ +// Four-Bar Linkage — Classification +// ═══════════════════════════════════════════════════════════ + +TEST(KinematicChainTest, FourBar_CrankRocker) { + // Ground=4, Crank=1, Coupler=3, Rocker=2 + // s=1(crank), l=4(ground), s+l=5 < p+q=5 → Grashof, crank is shortest → CrankRocker + FourBarLinkage link{4, 1, 3, 2}; + EXPECT_EQ(link.classify(), FourBarType::CrankRocker); + EXPECT_TRUE(link.is_grashof()); +} + +TEST(KinematicChainTest, FourBar_DoubleCrank) { + // Ground=1 (shortest), Crank=3, Coupler=2, Rocker=4 + // s=1(ground), l=4(rocker), s+l=5 < p+q=5 → Grashof, ground is shortest → DoubleCrank + FourBarLinkage link{1, 3, 2, 4}; + EXPECT_EQ(link.classify(), FourBarType::DoubleCrank); +} + +TEST(KinematicChainTest, FourBar_DoubleRocker) { + // Ground=3, Crank=2 (not shortest), Coupler=1 (shortest), Rocker=4 + // s=1(coupler), l=4(rocker), s+l=5 < p+q=5 → Grashof, coupler is shortest → DoubleRocker + FourBarLinkage link{3, 2, 1, 4}; + EXPECT_EQ(link.classify(), FourBarType::DoubleRocker); +} + +TEST(KinematicChainTest, FourBar_NonGrashof) { + // s=1, l=5, s+l=6 > p+q=3+4=7? No, 6 < 7, so it IS Grashof. + // Let's make: s=2, p=3, q=4, l=10 → s+l=12 > p+q=7 → NonGrashof + FourBarLinkage link{2, 3, 4, 10}; + EXPECT_EQ(link.classify(), FourBarType::NonGrashof); + EXPECT_FALSE(link.is_grashof()); +} + +TEST(KinematicChainTest, FourBar_ChangePoint) { + // s+l = p+q exactly + FourBarLinkage link{4, 1, 3, 2}; // 1+4=5, 2+3=5 + EXPECT_EQ(link.classify(), FourBarType::ChangePoint); +} + +// ═══════════════════════════════════════════════════════════ +// Four-Bar Linkage — Position Solving +// ═══════════════════════════════════════════════════════════ + +TEST(KinematicChainTest, SolveFourBar_ValidAngle) { + FourBarLinkage link{4, 1, 3, 2}; // CrankRocker + auto sol = solve_fourbar(link, 0.5); // 0.5 rad input + EXPECT_TRUE(sol.valid); + EXPECT_NEAR(sol.input_angle, 0.5, 1e-9); +} + +TEST(KinematicChainTest, SolveFourBar_ZeroAngle) { + FourBarLinkage link{4, 1, 3, 2}; + auto sol = solve_fourbar(link, 0.0); + EXPECT_TRUE(sol.valid); +} + +TEST(KinematicChainTest, SolveFourBar_FullRotation) { + // CrankRocker: input should rotate full 360° + FourBarLinkage link{4, 1, 3, 2}; + int valid_count = 0; + for (int i = 0; i < 36; ++i) { + double angle = 2.0 * M_PI * i / 36.0; + auto sol = solve_fourbar(link, angle, 0); + if (sol.valid) valid_count++; + } + // CrankRocker: all positions should be valid + EXPECT_EQ(valid_count, 36); +} + +TEST(KinematicChainTest, SolveFourBar_BranchSwitch) { + FourBarLinkage link{4, 1, 3, 2}; + auto sol0 = solve_fourbar(link, 0.5, 0); // open + auto sol1 = solve_fourbar(link, 0.5, 1); // crossed + EXPECT_TRUE(sol0.valid); + EXPECT_TRUE(sol1.valid); + // Different branches should give different output angles + EXPECT_NE(sol0.output_angle, sol1.output_angle); +} + +TEST(KinematicChainTest, SolveFourBar_TransmissionAngle) { + FourBarLinkage link{4, 1, 3, 2}; + auto sol = solve_fourbar(link, 0.5); + EXPECT_TRUE(sol.transmission_angle >= 0.0); + EXPECT_TRUE(sol.transmission_angle <= M_PI_2 + 1e-9); +} + +TEST(KinematicChainTest, SolveFourBar_DeadPoint) { + // Design a mechanism with a dead point + // When crank and coupler are collinear, transmission angle ≈ 0 + FourBarLinkage link{3, 1, 2, 2}; // s=1,l=3 → Grashof, crank is shortest + auto sol = solve_fourbar(link, 0.0); + // May or may not be dead point — just verify field exists + EXPECT_TRUE(sol.dead_point || !sol.dead_point); // always true, just checking field + EXPECT_TRUE(std::isfinite(sol.transmission_angle)); +} + +// ═══════════════════════════════════════════════════════════ +// Four-Bar — Full Analysis +// ═══════════════════════════════════════════════════════════ + +TEST(KinematicChainTest, AnalyzeFourBar_ReturnsResults) { + FourBarLinkage link{4, 1, 3, 2}; + auto results = analyze_fourbar(link, 72); + EXPECT_GT(results.size(), 0u); + for (const auto& r : results) { + EXPECT_TRUE(r.valid); + } +} + +// ═══════════════════════════════════════════════════════════ +// Gear Train +// ═══════════════════════════════════════════════════════════ + +TEST(KinematicChainTest, GearPair_Ratio) { + GearPair gear; + gear.teeth_driver = 20; + gear.teeth_driven = 40; + EXPECT_NEAR(gear.ratio(), 2.0, 1e-9); +} + +TEST(KinematicChainTest, GearPair_CenterDistance) { + GearPair gear; + gear.teeth_driver = 20; + gear.teeth_driven = 40; + gear.module = 2.0; + double cd = gear.compute_center_distance(); + EXPECT_NEAR(cd, 60.0, 1e-9); // (20+40)*2/2 = 60 +} + +TEST(KinematicChainTest, SolveGear_QuarterTurn) { + GearPair gear{20, 40}; + auto sol = solve_gear(gear, M_PI_2); // 90° input + EXPECT_NEAR(sol.driver_angle, M_PI_2, 1e-9); + EXPECT_NEAR(sol.driven_angle, -M_PI_4, 1e-9); // -45° (opposite direction, half speed) + EXPECT_NEAR(sol.angular_velocity_ratio, 2.0, 1e-9); +} + +TEST(KinematicChainTest, SolveGear_FullRotation) { + GearPair gear{20, 20}; // 1:1 ratio + auto sol = solve_gear(gear, 2.0 * M_PI); + EXPECT_NEAR(sol.driven_angle, -2.0 * M_PI, 1e-9); +} + +TEST(KinematicChainTest, SolveGearTrain_TwoStage) { + std::vector stages = { + {20, 40}, // 2:1 + {20, 60}, // 3:1 + }; + auto results = solve_gear_train(stages, M_PI); + EXPECT_EQ(results.size(), 2u); + EXPECT_NEAR(results[0].driven_angle, -M_PI / 2.0, 1e-9); + EXPECT_NEAR(results[1].driven_angle, M_PI / 6.0, 1e-9); // negative of previous / 3 +} + +// ═══════════════════════════════════════════════════════════ +// Cam-Follower +// ═══════════════════════════════════════════════════════════ + +TEST(KinematicChainTest, CamFollower_Dwell) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = {{0, M_PI, 0, 0, CamMotionType::Dwell}}; + + auto state = solve_cam(cam, 0.5); + EXPECT_NEAR(state.displacement, 0.0, 1e-9); + EXPECT_NEAR(state.velocity, 0.0, 1e-9); +} + +TEST(KinematicChainTest, CamFollower_ConstantVelocity) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = {{0, M_PI, 0, 10, CamMotionType::ConstantVelocity}}; + + auto state = solve_cam(cam, M_PI_2); // halfway + EXPECT_NEAR(state.displacement, 5.0, 1e-6); +} + +TEST(KinematicChainTest, CamFollower_Cycloidal) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = {{0, M_PI, 0, 10, CamMotionType::Cycloidal}}; + + auto start = solve_cam(cam, 0.0); + EXPECT_NEAR(start.displacement, 0.0, 1e-9); + + auto end = solve_cam(cam, M_PI); + EXPECT_NEAR(end.displacement, 10.0, 1e-6); + + // Cycloidal has zero velocity at endpoints + EXPECT_NEAR(start.velocity, 0.0, 1e-9); + EXPECT_NEAR(end.velocity, 0.0, 1e-9); +} + +TEST(KinematicChainTest, CamFollower_Polynomial345) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = {{0, M_PI, 0, 10, CamMotionType::Polynomial345}}; + + auto start = solve_cam(cam, 0.0); + EXPECT_NEAR(start.displacement, 0.0, 1e-9); + EXPECT_NEAR(start.velocity, 0.0, 1e-9); + EXPECT_NEAR(start.acceleration, 0.0, 1e-9); + + auto end = solve_cam(cam, M_PI); + EXPECT_NEAR(end.displacement, 10.0, 1e-6); + EXPECT_NEAR(end.velocity, 0.0, 1e-9); + EXPECT_NEAR(end.acceleration, 0.0, 1e-9); +} + +TEST(KinematicChainTest, CamFollower_MultiSegment) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = { + {0, M_PI_2, 0, 10, CamMotionType::Cycloidal}, // rise + {M_PI_2, M_PI, 10, 10, CamMotionType::Dwell}, // dwell + {M_PI, 1.5 * M_PI, 10, 0, CamMotionType::Cycloidal}, // fall + {1.5 * M_PI, 2 * M_PI, 0, 0, CamMotionType::Dwell}, // dwell + }; + + EXPECT_NEAR(cam.total_lift(), 10.0, 1e-9); + + // At 45°: midpoint of rise + auto mid_rise = solve_cam(cam, M_PI_4); + EXPECT_NEAR(mid_rise.displacement, 5.0, 1e-6); + + // At 135°: dwell at top + auto top_dwell = solve_cam(cam, 1.35 * M_PI); + EXPECT_NEAR(top_dwell.displacement, 10.0, 1e-6); + EXPECT_NEAR(top_dwell.velocity, 0.0, 1e-9); +} + +TEST(KinematicChainTest, CamFollower_PressureAngle) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = {{0, M_PI_2, 0, 20, CamMotionType::ConstantVelocity}}; + + auto state = solve_cam(cam, M_PI_4); + EXPECT_TRUE(state.pressure_angle >= 0.0); + EXPECT_TRUE(state.pressure_angle < M_PI_2); +} + +TEST(KinematicChainTest, CamFollower_TotalLift) { + CamFollowerSystem cam; + cam.segments = { + {0, M_PI, 0, 5, CamMotionType::SimpleHarmonic}, + {M_PI, 2 * M_PI, 5, 15, CamMotionType::Cycloidal}, + }; + EXPECT_NEAR(cam.total_lift(), 15.0, 1e-9); +} + +// ═══════════════════════════════════════════════════════════ +// Full Analysis +// ═══════════════════════════════════════════════════════════ + +TEST(KinematicChainTest, AnalyzeCam_FullCycle) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = {{0, 2 * M_PI, 0, 10, CamMotionType::Cycloidal}}; + + auto results = analyze_cam(cam, 36); + EXPECT_EQ(results.size(), 36u); + EXPECT_NEAR(results.front().displacement, 0.0, 1e-9); + EXPECT_NEAR(results.back().displacement, 0.0, 1e-6); +} + +TEST(KinematicChainTest, CamFollower_AngleWraparound) { + CamFollowerSystem cam; + cam.base_radius = 20.0; + cam.segments = {{0, M_PI, 0, 5, CamMotionType::ConstantVelocity}}; + + // 3π (360° + 180°) should wrap to π + auto state = solve_cam(cam, 3.0 * M_PI); + EXPECT_NEAR(state.displacement, 5.0, 1e-6); +}