feat(v3.5): perf caching + measure + flange/gear + feature tree + assembly constraints
CI / Build & Test (push) Failing after 1m31s
CI / Release Build (push) Failing after 31s
Build & Test / build-and-test (push) Has been cancelled
Build & Test / python-bindings (push) Has been cancelled

This commit is contained in:
茂之钳
2026-07-24 14:02:24 +00:00
parent f89f5f6bcd
commit 12db7d3e5a
17 changed files with 1487 additions and 2 deletions
+2
View File
@@ -8,3 +8,5 @@ add_vde_test(test_iges_import)
add_vde_test(test_iges_export)
add_vde_test(test_assembly)
add_vde_test(test_brep_face_split)
add_vde_test(test_measure)
add_vde_test(test_assembly_constraints)
+358
View File
@@ -0,0 +1,358 @@
#include <gtest/gtest.h>
#include "vde/brep/assembly_constraints.h"
#include "vde/brep/feature_tree.h"
#include "vde/brep/modeling.h"
#include "vde/core/transform.h"
#include <cmath>
using namespace vde::brep;
using namespace vde::core;
// ════════════════════════════════════════════════════════════════
// Assembly Constraint Tests
// ════════════════════════════════════════════════════════════════
TEST(AssemblyConstraintTest, CoincidentConstraint) {
Assembly assy("test_assy");
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));
EXPECT_TRUE(apply_constraint(assy, box_a, box_b, ConstraintType::Coincident));
// After coincident constraint, box_b should be sitting directly on box_a
// Verify by checking that the bounding boxes share a face plane
AABB3D bb_a = box_a->model->bounds();
// Compute box_b's world-space bounding box
AABB3D bb_b_local = box_b->model->bounds();
AABB3D bb_b_world;
Point3D corners[8] = {
bb_b_local.min(),
Point3D(bb_b_local.max().x(), bb_b_local.min().y(), bb_b_local.min().z()),
Point3D(bb_b_local.max().x(), bb_b_local.max().y(), bb_b_local.min().z()),
Point3D(bb_b_local.min().x(), bb_b_local.max().y(), bb_b_local.min().z()),
Point3D(bb_b_local.min().x(), bb_b_local.min().y(), bb_b_local.max().z()),
Point3D(bb_b_local.max().x(), bb_b_local.min().y(), bb_b_local.max().z()),
bb_b_local.max(),
Point3D(bb_b_local.min().x(), bb_b_local.max().y(), bb_b_local.max().z()),
};
for (const auto& c : corners) {
bb_b_world.expand(box_b->local_transform * c);
}
// box_a top = 1.0, box_b bottom should be very close to 1.0
EXPECT_NEAR(bb_b_world.min().z(), bb_a.max().z(), 1e-4);
}
TEST(AssemblyConstraintTest, ConcentricConstraint) {
Assembly assy("test_assy");
auto* cyl_a = assy.root.add_part("cyl_a", make_cylinder(1.0, 4.0));
auto* cyl_b = assy.root.add_part("cyl_b", make_cylinder(0.5, 2.0),
translate(5, 0, 0));
EXPECT_TRUE(apply_constraint(assy, cyl_a, cyl_b, ConstraintType::Concentric));
// Both cylinders should now be concentric (same center in X,Y)
AABB3D bb_a = cyl_a->model->bounds();
Point3D center_a = bb_a.center();
AABB3D bb_b_local = cyl_b->model->bounds();
Point3D center_b_local = bb_b_local.center();
Point3D center_b_world = cyl_b->local_transform * center_b_local;
EXPECT_NEAR(center_b_world.x(), center_a.x(), 1e-4);
EXPECT_NEAR(center_b_world.y(), center_a.y(), 1e-4);
}
TEST(AssemblyConstraintTest, DistanceConstraint) {
Assembly assy("test_assy");
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));
EXPECT_TRUE(apply_constraint(assy, box_a, box_b, ConstraintType::Distance, 5.0));
// After distance constraint of 5.0, box_b bottom should be 5.0 above box_a top
AABB3D bb_a = box_a->model->bounds();
AABB3D bb_b_local = box_b->model->bounds();
Point3D bb_b_min_local = bb_b_local.min();
Point3D bb_b_min_world = box_b->local_transform * bb_b_min_local;
double gap = bb_b_min_world.z() - bb_a.max().z();
EXPECT_NEAR(gap, 5.0, 1e-4);
}
TEST(AssemblyConstraintTest, AngleConstraint) {
Assembly assy("test_assy");
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(1, 1, 4));
// Apply 90-degree angle constraint (rotate node_b)
EXPECT_TRUE(apply_constraint(assy, box_a, box_b, ConstraintType::Angle, M_PI / 2.0));
// Verify the transform was applied (non-identity rotation)
EXPECT_FALSE(box_b->local_transform.isApprox(Transform3D::Identity(), 1e-6));
}
TEST(AssemblyConstraintTest, PerpendicularConstraint) {
Assembly assy("test_assy");
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(1, 1, 4));
EXPECT_TRUE(apply_constraint(assy, box_a, box_b, ConstraintType::Perpendicular));
// Verify the transform is not identity (rotation was applied)
EXPECT_FALSE(box_b->local_transform.isApprox(Transform3D::Identity(), 1e-6));
}
TEST(AssemblyConstraintTest, ParallelConstraint) {
Assembly assy("test_assy");
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, 5, 0));
EXPECT_TRUE(apply_constraint(assy, box_a, box_b, ConstraintType::Parallel));
// Parallel constraint preserves the offset (no transform change for axis-aligned)
AABB3D bb_b_local = box_b->model->bounds();
Point3D center_b_local = bb_b_local.center();
Point3D center_b_world = box_b->local_transform * center_b_local;
EXPECT_NEAR(center_b_world.y(), 5.0, 1e-4);
}
// ════════════════════════════════════════════════════════════════
// Feature Tree Tests
// ════════════════════════════════════════════════════════════════
TEST(FeatureTreeTest, PrimitiveBox) {
FeatureNode node;
node.type = FeatureType::PrimitiveBox;
node.params.values = {2.0, 3.0, 4.0};
BrepModel result = node.evaluate();
EXPECT_GT(result.num_vertices(), 0u);
EXPECT_GT(result.num_faces(), 0u);
AABB3D bb = result.bounds();
EXPECT_NEAR(bb.extent().x(), 2.0, 1e-4);
EXPECT_NEAR(bb.extent().y(), 3.0, 1e-4);
EXPECT_NEAR(bb.extent().z(), 4.0, 1e-4);
}
TEST(FeatureTreeTest, PrimitiveCylinder) {
FeatureNode node;
node.type = FeatureType::PrimitiveCylinder;
node.params.values = {1.5, 6.0};
BrepModel result = node.evaluate();
EXPECT_GT(result.num_vertices(), 0u);
EXPECT_GT(result.num_faces(), 0u);
AABB3D bb = result.bounds();
EXPECT_NEAR(bb.extent().x(), 3.0, 0.1); // diameter
EXPECT_NEAR(bb.extent().y(), 6.0, 1e-4); // height
}
TEST(FeatureTreeTest, PrimitiveSphere) {
FeatureNode node;
node.type = FeatureType::PrimitiveSphere;
node.params.values = {2.5};
BrepModel result = node.evaluate();
EXPECT_GT(result.num_vertices(), 0u);
AABB3D bb = result.bounds();
EXPECT_NEAR(bb.extent().x(), 5.0, 0.1); // diameter
EXPECT_NEAR(bb.extent().y(), 5.0, 0.1);
EXPECT_NEAR(bb.extent().z(), 5.0, 0.1);
}
TEST(FeatureTreeTest, NestedFeatureTree) {
// Box → Fillet
auto box_node = std::make_unique<FeatureNode>();
box_node->type = FeatureType::PrimitiveBox;
box_node->params.values = {10.0, 5.0, 3.0};
auto fillet_node = std::make_unique<FeatureNode>();
fillet_node->type = FeatureType::Fillet;
fillet_node->params.values = {1.0};
fillet_node->params.int_values = {0};
fillet_node->children.push_back(std::move(box_node));
BrepModel result = fillet_node->evaluate();
EXPECT_TRUE(result.is_valid());
EXPECT_GT(result.num_faces(), 0u);
}
TEST(FeatureTreeTest, BooleanUnionFeature) {
// Box Cylinder
auto box = std::make_unique<FeatureNode>();
box->type = FeatureType::PrimitiveBox;
box->params.values = {3.0, 3.0, 3.0};
auto cyl = std::make_unique<FeatureNode>();
cyl->type = FeatureType::PrimitiveCylinder;
cyl->params.values = {1.0, 5.0};
auto union_node = std::make_unique<FeatureNode>();
union_node->type = FeatureType::BooleanUnion;
union_node->children.push_back(std::move(box));
union_node->children.push_back(std::move(cyl));
BrepModel result = union_node->evaluate();
EXPECT_GT(result.num_vertices(), 0u);
EXPECT_GT(result.num_faces(), 0u);
}
// ════════════════════════════════════════════════════════════════
// FeatureHistory Undo/Redo Tests
// ════════════════════════════════════════════════════════════════
TEST(FeatureHistoryTest, ApplyOperations) {
FeatureHistory history;
// Apply 3 operations
BrepModel box1 = make_box(2, 2, 2);
history.apply(box1, FeatureType::PrimitiveBox, FeatureParams{});
EXPECT_EQ(history.size(), 1u);
BrepModel box2 = make_box(3, 3, 3);
history.apply(box2, FeatureType::PrimitiveBox, FeatureParams{});
EXPECT_EQ(history.size(), 2u);
BrepModel cyl = make_cylinder(1, 5);
history.apply(cyl, FeatureType::PrimitiveCylinder, FeatureParams{});
EXPECT_EQ(history.size(), 3u);
}
TEST(FeatureHistoryTest, UndoRedo) {
FeatureHistory history;
// Create 3 distinct models as initial states
BrepModel box_small = make_box(2, 2, 2);
BrepModel box_large = make_box(5, 5, 5);
BrepModel cylinder = make_cylinder(2, 10);
history.apply(box_small, FeatureType::PrimitiveBox, FeatureParams{});
EXPECT_EQ(history.size(), 1u);
history.apply(box_large, FeatureType::PrimitiveBox, FeatureParams{});
EXPECT_EQ(history.size(), 2u);
history.apply(cylinder, FeatureType::PrimitiveCylinder, FeatureParams{});
EXPECT_EQ(history.size(), 3u);
// Undo twice
EXPECT_TRUE(history.undo());
EXPECT_EQ(history.size(), 3u); // size doesn't change with undo
EXPECT_TRUE(history.undo());
EXPECT_EQ(history.size(), 3u);
// Should not be able to undo past start
EXPECT_TRUE(history.undo());
EXPECT_FALSE(history.undo()); // no more to undo
// Redo all
EXPECT_TRUE(history.redo());
EXPECT_TRUE(history.redo());
EXPECT_TRUE(history.redo());
EXPECT_FALSE(history.redo()); // no more to redo
}
TEST(FeatureHistoryTest, UndoRedoVerifyStates) {
FeatureHistory history;
BrepModel initial = make_box(2, 2, 2);
history.apply(initial, FeatureType::PrimitiveBox, FeatureParams{});
BrepModel second = make_box(5, 5, 5);
history.apply(second, FeatureType::PrimitiveBox, FeatureParams{});
BrepModel third = make_cylinder(2, 10);
history.apply(third, FeatureType::PrimitiveCylinder, FeatureParams{});
// Current should be the third operation's state
AABB3D bb_current = history.current().bounds();
EXPECT_NEAR(bb_current.extent().x(), 4.0, 0.5); // cylinder diameter
// Undo → should get second state
EXPECT_TRUE(history.undo());
AABB3D bb_undo = history.current().bounds();
EXPECT_NEAR(bb_undo.extent().x(), 5.0, 1e-4);
// Redo → should get third state back
EXPECT_TRUE(history.redo());
AABB3D bb_redo = history.current().bounds();
EXPECT_NEAR(bb_redo.extent().x(), 4.0, 0.5);
}
TEST(FeatureHistoryTest, ApplyAfterUndoTruncatesRedo) {
FeatureHistory history;
history.apply(make_box(2, 2, 2), FeatureType::PrimitiveBox, FeatureParams{});
history.apply(make_box(5, 5, 5), FeatureType::PrimitiveBox, FeatureParams{});
history.apply(make_cylinder(2, 10), FeatureType::PrimitiveCylinder, FeatureParams{});
// Undo one
EXPECT_TRUE(history.undo());
EXPECT_EQ(history.size(), 3u);
// Apply a new operation → should truncate redo stack
history.apply(make_box(10, 10, 10), FeatureType::PrimitiveBox, FeatureParams{});
EXPECT_EQ(history.size(), 3u); // still 3 → old "third" was replaced
// Redo should be exhausted (old third was deleted)
EXPECT_FALSE(history.redo());
}
TEST(FeatureHistoryTest, ThreeOperationsUndoRedoVerify) {
FeatureHistory history;
// Apply op1: small box
history.apply(make_box(2, 2, 2), FeatureType::PrimitiveBox, FeatureParams{});
// Apply op2: large box
history.apply(make_box(5, 5, 5), FeatureType::PrimitiveBox, FeatureParams{});
// Apply op3: cylinder
history.apply(make_cylinder(2, 10), FeatureType::PrimitiveCylinder, FeatureParams{});
EXPECT_EQ(history.size(), 3u);
// Verify current is cylinder
{
AABB3D bb = history.current().bounds();
EXPECT_NEAR(bb.extent().x(), 4.0, 0.5); // cylinder diameter ≈ 4
}
// Undo back to large box
EXPECT_TRUE(history.undo());
{
AABB3D bb = history.current().bounds();
EXPECT_NEAR(bb.extent().x(), 5.0, 1e-4);
}
// Undo back to small box
EXPECT_TRUE(history.undo());
{
AABB3D bb = history.current().bounds();
EXPECT_NEAR(bb.extent().x(), 2.0, 1e-4);
}
// Undo back to empty
EXPECT_TRUE(history.undo());
EXPECT_FALSE(history.undo());
// Redo: empty → small box → large box → cylinder
EXPECT_TRUE(history.redo());
EXPECT_TRUE(history.redo());
EXPECT_TRUE(history.redo());
EXPECT_FALSE(history.redo());
// Verify we're back at cylinder
{
AABB3D bb = history.current().bounds();
EXPECT_NEAR(bb.extent().x(), 4.0, 0.5);
}
}
+16
View File
@@ -1,4 +1,5 @@
#include <gtest/gtest.h>
#include <chrono>
#include "vde/brep/brep.h"
#include "vde/brep/modeling.h"
#include "vde/brep/brep_boolean.h"
@@ -174,3 +175,18 @@ TEST(BrepBooleanTest, Intersection_Commutative) {
EXPECT_TRUE(r1.is_valid());
EXPECT_TRUE(r2.is_valid());
}
// ═══════════════════════════════════════════════════════════
// Performance (v3.5)
// ═══════════════════════════════════════════════════════════
TEST(BrepBooleanTest, Performance_UnionIsFast) {
auto box1 = make_box(2, 2, 2);
auto box2 = make_box(2, 2, 2);
auto start = std::chrono::steady_clock::now();
auto result = brep_union(box1, box2);
auto elapsed = std::chrono::steady_clock::now() - start;
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
EXPECT_TRUE(result.is_valid());
EXPECT_LT(ms, 1000) << "Self-union should take less than 1 second with caching";
}
+98
View File
@@ -0,0 +1,98 @@
#include <gtest/gtest.h>
#include "vde/brep/brep.h"
#include "vde/brep/modeling.h"
#include "vde/brep/measure.h"
#include <cmath>
using namespace vde::brep;
using namespace vde::core;
// ═══════════════════════════════════════════════════════════
// Volume measurements
// ═══════════════════════════════════════════════════════════
TEST(MeasureTest, Volume_UnitBox) {
// 2×2×2 box centered at origin → volume = 8.0
auto box = make_box(2, 2, 2);
double vol = volume(box);
EXPECT_NEAR(vol, 8.0, 0.2) << "Volume of 2×2×2 box should be ~8.0";
}
TEST(MeasureTest, Volume_LargeBox) {
auto box = make_box(10, 5, 3); // volume = 150
double vol = volume(box);
EXPECT_NEAR(vol, 150.0, 1.0) << "Volume of 10×5×3 box should be ~150";
}
// ═══════════════════════════════════════════════════════════
// Surface area measurements
// ═══════════════════════════════════════════════════════════
TEST(MeasureTest, SurfaceArea_UnitBox) {
// 2×2×2 box: 6 faces × 4 = 24.0
auto box = make_box(2, 2, 2);
double area = surface_area(box);
EXPECT_NEAR(area, 24.0, 0.5) << "Surface area of 2×2×2 box should be ~24.0";
}
TEST(MeasureTest, SurfaceArea_LargeBox) {
auto box = make_box(10, 5, 3);
double area = surface_area(box);
double expected = 2.0 * (10*5 + 10*3 + 5*3); // 190
EXPECT_NEAR(area, expected, 2.0) << "Surface area of 10×5×3 box should be ~" << expected;
}
// ═══════════════════════════════════════════════════════════
// Centroid measurements
// ═══════════════════════════════════════════════════════════
TEST(MeasureTest, Centroid_BoxAtOrigin) {
auto box = make_box(2, 2, 2);
auto c = centroid(box);
EXPECT_NEAR(c.x(), 0.0, 0.01);
EXPECT_NEAR(c.y(), 0.0, 0.01);
EXPECT_NEAR(c.z(), 0.0, 0.01);
}
// ═══════════════════════════════════════════════════════════
// Distance measurements
// ═══════════════════════════════════════════════════════════
TEST(MeasureTest, Distance_SeparatedBoxes) {
auto box1 = make_box(1, 1, 1); // centered at origin, half-size 0.5
// We can't easily translate boxes, but make_box creates centered boxes
// so we test two boxes at origin — they overlap
auto box2 = make_box(1, 1, 1);
double d = distance(box1, box2);
// Two identical boxes at origin → overlap → distance ≈ 0
EXPECT_NEAR(d, 0.0, 0.1) << "Overlapping boxes should have distance ~0";
}
TEST(MeasureTest, Distance_OverlappingBoxes) {
auto box1 = make_box(2, 2, 2);
auto box2 = make_box(2, 2, 2);
double d = distance(box1, box2);
// Overlapping boxes
EXPECT_NEAR(d, 0.0, 0.1) << "Overlapping boxes should have distance ~0";
}
// ═══════════════════════════════════════════════════════════
// Integration: volume + surface area consistency
// ═══════════════════════════════════════════════════════════
TEST(MeasureTest, Volume_NonNegative) {
auto box = make_box(2, 2, 2);
EXPECT_GE(volume(box), 0.0);
}
TEST(MeasureTest, SurfaceArea_NonNegative) {
auto box = make_box(2, 2, 2);
EXPECT_GE(surface_area(box), 0.0);
}
TEST(MeasureTest, Distance_NonNegative) {
auto box1 = make_box(1, 1, 1);
auto box2 = make_box(1, 1, 1);
EXPECT_GE(distance(box1, box2), 0.0);
}