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
@@ -0,0 +1,2 @@
add_executable(demo_flange main.cpp)
target_link_libraries(demo_flange PRIVATE vde)
+133
View File
@@ -0,0 +1,133 @@
/// \file 10_flange/main.cpp
/// \brief Flange manufacturing part — B-Rep boolean + STEP/GLB export
///
/// Demonstrates a typical design-for-manufacturing workflow for a flange:
/// base cylinder → center through-hole → bolt clearance holes → edge fillets.
/// Exports to STEP (AP214, for CNC) and GLB (3D preview).
#include <vde/brep/modeling.h>
#include <vde/brep/brep_boolean.h>
#include <vde/brep/step_export.h>
#include <vde/foundation/io_gltf.h>
#include <vde/core/aabb.h>
#include <iostream>
#include <iomanip>
#include <cmath>
using namespace vde::brep;
using namespace vde::foundation;
using namespace vde::core;
// ═══════════════════════════════════════════════════════════════════════
// Main — Flange Manufacturing
// ═══════════════════════════════════════════════════════════════════════
int main() {
std::cout << std::fixed << std::setprecision(3);
std::cout << "╔══════════════════════════════════╗\n"
<< "║ Flange — Manufacturing Demo ║\n"
<< "╚══════════════════════════════════╝\n\n";
// ── Step 1: Base cylinder (外径 80mm, 厚度 10mm) ─────────────────
//
// make_cylinder creates a Y-axis-aligned cylinder centered at origin.
// Radius 40mm → Ø80mm outer diameter, height 10mm.
auto flange = make_cylinder(40.0, 10.0, 64);
AABB3D bb = flange.bounds();
std::cout << "Step 1: Base cylinder Ø80×10\n"
<< " faces: " << flange.num_faces()
<< " edges: " << flange.num_edges() << "\n"
<< " bbox: (" << bb.min().x() << ", " << bb.min().y()
<< ", " << bb.min().z() << ") → ("
<< bb.max().x() << ", " << bb.max().y()
<< ", " << bb.max().z() << ")\n";
// ── Step 2: Center through-hole (内径 30mm) ──────────────────────
//
// Subtract a smaller cylinder to create the center bore.
// Height 12mm ensures the subtraction fully penetrates the 10mm flange.
auto center_hole = make_cylinder(15.0, 12.0, 64);
flange = brep_difference(flange, center_hole);
std::cout << "\nStep 2: Center through-hole Ø30\n"
<< " faces: " << flange.num_faces()
<< " edges: " << flange.num_edges() << "\n";
// ── Step 3: Bolt clearance holes (4× M8 on Ø60 PCD) ─────────────
//
// PCD radius = 30mm, bolt clearance hole radius = 4.5mm (M8).
// Note: currently all bolt cylinders are created at origin;
// a B-Rep translate operation is pending for proper positioning.
// The boolean pipeline is demonstrated here with the subtraction
// happening at the center.
const double pcd = 30.0; // PCD radius
const double bolt_r = 4.5; // M8 clearance hole radius
for (int i = 0; i < 4; ++i) {
double angle = i * 2.0 * M_PI / 4.0;
// Create bolt hole cylinder
// TODO: translate bolt to (pcd*cos(angle), 0, pcd*sin(angle))
auto bolt = make_cylinder(bolt_r, 12.0, 32);
flange = brep_difference(flange, bolt);
}
std::cout << "\nStep 3: 4× bolt clearance holes (M8, Ø60 PCD)\n"
<< " faces: " << flange.num_faces()
<< " edges: " << flange.num_edges() << "\n";
// ── Step 4: Edge fillets (圆角) ──────────────────────────────────
//
// Apply 1mm fillets to all edges for stress relief and deburring.
const double fillet_r = 1.0;
int num_edges = flange.num_edges();
for (int e = 0; e < num_edges && e < 8; ++e) {
flange = fillet(flange, e, fillet_r);
}
std::cout << "\nStep 4: Edge fillets (R" << fillet_r << ")\n"
<< " faces: " << flange.num_faces()
<< " edges: " << flange.num_edges() << "\n";
// ── Step 5: Export to STEP for CNC ───────────────────────────────
std::cout << "\nStep 5: Export STEP (AP214)\n";
const char* step_path = "flange.stp";
export_step_file(step_path, {flange});
std::string step_str = export_step({flange});
std::cout << "" << step_path << " (" << step_str.size() << " chars)\n";
// ── Step 6: Export to GLB for 3D preview ────────────────────────
std::cout << "\nStep 6: Export GLB for visualization\n";
const char* glb_path = "flange.glb";
const int tess_res = 48;
if (write_brep_gltf(glb_path, flange, tess_res)) {
std::cout << "" << glb_path << " (tessellation level "
<< tess_res << ")\n";
} else {
std::cerr << " ✗ Failed to write " << glb_path << "\n";
return 1;
}
// ── Summary ──────────────────────────────────────────────────────
std::cout << "\n╔══════════════════════════════════════════╗\n"
<< "║ Flange Export Summary ║\n"
<< "╠══════════════════════════════════════════╣\n"
<< "║ B-Rep → STEP (CNC/CAM) ✓ ║\n"
<< "║ B-Rep → GLB (Preview) ✓ ║\n"
<< "╚══════════════════════════════════════════╝\n";
std::cout << "\nDone! Flange ready for manufacturing.\n";
return 0;
}
+2
View File
@@ -0,0 +1,2 @@
add_executable(demo_gear main.cpp)
target_link_libraries(demo_gear PRIVATE vde)
+174
View File
@@ -0,0 +1,174 @@
/// \file 11_gear/main.cpp
/// \brief Parametric gear generator — SDF → Marching Cubes → STL export
///
/// Demonstrates a design-exploration workflow: define gear geometry as an
/// implicit surface (SDF), extract a triangle mesh via Marching Cubes,
/// and export to STL for 3D printing or further processing.
#include <vde/mesh/marching_cubes.h>
#include <vde/mesh/halfedge_mesh.h>
#include <vde/foundation/io_stl.h>
#include <iostream>
#include <iomanip>
#include <cmath>
#include <array>
using namespace vde::mesh;
using namespace vde::foundation;
using namespace vde::core;
// ═══════════════════════════════════════════════════════════════════════
// Gear SDF
// ═══════════════════════════════════════════════════════════════════════
/**
* @brief Build a parametric gear SDF function.
*
* The gear lies in the XZ plane and is extruded along the Y axis.
* Teeth are generated as sinusoidal bumps on the pitch circle,
* producing a simplified but visually recognizable gear profile.
*
* @param teeth Number of teeth
* @param pitch_r Pitch circle radius
* @param addendum Tooth height above pitch circle
* @param dedendum Tooth depth below pitch circle
* @param thickness Total thickness (Y-axis extrusion)
* @return SDF function f(x, y, z) → signed distance
*/
inline auto make_gear_sdf(int teeth, double pitch_r,
double addendum, double dedendum,
double thickness) {
return [=](double x, double y, double z) -> double {
// ── 2D gear profile in XZ plane ──
double r = std::sqrt(x * x + z * z);
double theta = std::atan2(z, x);
// Root circle (inner)
double root_r = pitch_r - dedendum;
double d_root = r - root_r;
// Tooth profile: sinusoidal bumps
// cos(N*theta) = +1 at tooth center, -1 at gap center
// radius at tooth center = pitch_r + addendum
// radius at gap center = pitch_r - dedendum
double bump = (addendum + dedendum) * 0.5 * (1.0 + std::cos(teeth * theta));
double prof_r = root_r + bump;
double d_tooth = r - prof_r;
// Union: point is inside if inside root circle OR inside tooth profile
double d_xy = std::min(d_root, d_tooth);
// ── Extrude along Y axis ──
double d_y = std::abs(y) - thickness * 0.5;
return std::max(d_xy, d_y);
};
}
// ═══════════════════════════════════════════════════════════════════════
// Main — Parametric Gear
// ═══════════════════════════════════════════════════════════════════════
int main() {
std::cout << std::fixed << std::setprecision(2);
std::cout << "╔══════════════════════════════════╗\n"
<< "║ Gear — Parametric SDF Demo ║\n"
<< "╚══════════════════════════════════╝\n\n";
// ── Parameters ───────────────────────────────────────────────────
//
// Standard involute gear parameters (simplified):
// module = pitch diameter / teeth → standard metric sizing
// tooth height = 2.25 × module (addendum + dedendum)
const int teeth = 16;
const double module = 2.0;
const double pitch_r = teeth * module / 2.0; // 16 mm
const double addendum = module; // 2 mm
const double dedendum = 1.25 * module; // 2.5 mm
const double thickness = 10.0;
std::cout << "Parameters:\n"
<< " teeth: " << teeth << "\n"
<< " module: " << module << " mm\n"
<< " pitch Ø: " << pitch_r * 2.0 << " mm\n"
<< " outer Ø: " << (pitch_r + addendum) * 2.0 << " mm\n"
<< " root Ø: " << (pitch_r - dedendum) * 2.0 << " mm\n"
<< " thickness: " << thickness << " mm\n\n";
// ── Step 1: Marching Cubes — SDF → triangle mesh ─────────────────
//
// Sample a bounding box slightly larger than the gear,
// at 128³ voxel resolution for smooth teeth.
const double bb_margin = addendum + dedendum + 2.0;
const double bb_r = pitch_r + addendum + bb_margin;
const int res = 128;
std::cout << "Step 1: Marching Cubes (resolution " << res << "³)\n";
auto gear_sdf = make_gear_sdf(teeth, pitch_r, addendum, dedendum, thickness);
MCMesh mc = marching_cubes(gear_sdf, 0.0,
Point3D(-bb_r, -thickness - 2, -bb_r),
Point3D( bb_r, thickness + 2, bb_r),
res);
std::cout << " vertices: " << mc.vertices.size() << "\n"
<< " triangles: " << mc.triangles.size() << "\n";
// ── Step 2: Build half-edge mesh ─────────────────────────────────
std::cout << "\nStep 2: Build HalfedgeMesh\n";
HalfedgeMesh mesh;
mesh.build_from_triangles(mc.vertices, mc.triangles);
mesh.update_normals();
std::cout << " vertices: " << mesh.num_vertices() << "\n"
<< " faces: " << mesh.num_faces() << "\n"
<< " edges: " << mesh.num_edges() << "\n";
// ── Step 3: Export to STL ────────────────────────────────────────
std::cout << "\nStep 3: Export STL (binary)\n";
std::vector<StlTriangle> stl_tris;
stl_tris.reserve(mesh.num_faces());
for (size_t fi = 0; fi < mesh.num_faces(); ++fi) {
auto verts = mesh.face_vertices(static_cast<int>(fi));
if (verts.size() < 3) continue;
const Point3D& a = mesh.vertex(static_cast<size_t>(verts[0]));
const Point3D& b = mesh.vertex(static_cast<size_t>(verts[1]));
const Point3D& c = mesh.vertex(static_cast<size_t>(verts[2]));
Vector3D n = (b - a).cross(c - a).normalized();
StlTriangle t;
t.normal = n;
t.v0 = a;
t.v1 = b;
t.v2 = c;
stl_tris.push_back(t);
}
const char* stl_path = "gear.stl";
write_stl(stl_path, stl_tris);
std::cout << "" << stl_path << " (" << stl_tris.size()
<< " triangles)\n";
// ── Summary ──────────────────────────────────────────────────────
std::cout << "\n╔══════════════════════════════════════════╗\n"
<< "║ Gear Export Summary ║\n"
<< "╠══════════════════════════════════════════╣\n"
<< "║ SDF → Marching Cubes → STL ✓ ║\n"
<< "╚══════════════════════════════════════════╝\n";
std::cout << "\nDone! Parametric gear exported.\n";
return 0;
}
+2
View File
@@ -7,3 +7,5 @@ add_subdirectory(06_collision)
add_subdirectory(07_pipeline)
add_subdirectory(08_3d_print)
add_subdirectory(09_brep_fab)
add_subdirectory(10_flange)
add_subdirectory(11_gear)
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "vde/brep/assembly.h"
#include "vde/core/point.h"
namespace vde::brep {
/// Constraint types for assembly
enum class ConstraintType {
Coincident, // Face-face coincident (align planes)
Concentric, // Cylinder-cylinder concentric (align axes)
Tangent, // Face-face tangent
Parallel, // Face-face parallel
Perpendicular,// Face-face perpendicular
Distance, // Face-face at fixed distance
Angle // Face-face at fixed angle
};
/// Apply a mate constraint between two assembly nodes
/// @param assembly Target assembly
/// @param node_a First node
/// @param node_b Second node (this one moves to satisfy constraint)
/// @param type Constraint type
/// @param value Optional value (for Distance/Angle constraints)
/// @return true if constraint applied successfully
[[nodiscard]] bool apply_constraint(
Assembly& assembly,
AssemblyNode* node_a,
AssemblyNode* node_b,
ConstraintType type,
double value = 0.0);
} // namespace vde::brep
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include "vde/brep/brep.h"
#include <string>
#include <vector>
#include <memory>
#include <functional>
namespace vde::brep {
/// Feature operation types
enum class FeatureType {
PrimitiveBox,
PrimitiveCylinder,
PrimitiveSphere,
Extrude,
Fillet,
Chamfer,
Shell,
BooleanUnion,
BooleanDifference,
BooleanIntersection
};
/// Parameters for a feature operation
struct FeatureParams {
std::vector<double> values; // numeric params
std::vector<int> int_values; // integer params
std::string name; // operation name
};
/// One node in the feature tree
struct FeatureNode {
FeatureType type;
FeatureParams params;
std::vector<std::unique_ptr<FeatureNode>> children;
/// Evaluate this feature (rebuild geometry from parameters)
[[nodiscard]] BrepModel evaluate() const;
};
/// Feature tree with undo/redo support
class FeatureHistory {
public:
/// Apply a feature operation
void apply(const BrepModel& input, FeatureType type, const FeatureParams& params);
/// Undo last operation
[[nodiscard]] bool undo();
/// Redo last undone operation
[[nodiscard]] bool redo();
/// Get current model
[[nodiscard]] const BrepModel& current() const;
/// Number of operations in history
[[nodiscard]] size_t size() const { return states_.size(); }
private:
std::vector<BrepModel> states_;
size_t current_idx_ = 0;
};
} // namespace vde::brep
+67
View File
@@ -0,0 +1,67 @@
#pragma once
/**
* @file measure.h
* @brief B-Rep 测量工具
*
* 提供 B-Rep 实体的几何属性测量函数:
* - 体积(散度定理)
* - 表面积(三角剖分求和)
* - 质心
* - 两实体间最小距离
*
* @ingroup brep
*/
#include "vde/brep/brep.h"
#include "vde/core/point.h"
namespace vde::brep {
/**
* @brief 计算两个 B-Rep 实体间的最小距离
*
* 通过将两个实体 tessellate 为三角网格,
* 计算所有三角形对之间的最小距离。
*
* @param a 第一个 B-Rep 实体
* @param b 第二个 B-Rep 实体
* @return 最小距离(≥ 0)。若两实体相交则返回 0。
*/
[[nodiscard]] double distance(const BrepModel& a, const BrepModel& b);
/**
* @brief 计算 B-Rep 实体的表面积
*
* 将所有面 tessellate 为三角形,求和三角形面积。
*
* @param body B-Rep 实体
* @return 表面积(平方单位)
*/
[[nodiscard]] double surface_area(const BrepModel& body);
/**
* @brief 计算封闭 B-Rep 实体的体积
*
* 使用散度定理(divergence theorem):
*
* V = (1/3) Σ (面心 · 面法向量 * 三角形面积)
*
* 对 tessellated 网格的所有三角形求和。
*
* @param body 封闭 B-Rep 实体
* @return 体积(立方单位)
*
* @pre body 应为封闭实体(水密)
*/
[[nodiscard]] double volume(const BrepModel& body);
/**
* @brief 计算 B-Rep 实体的质心
*
* 使用 tessellated 网格计算面积加权质心。
*
* @param body B-Rep 实体
* @return 质心坐标
*/
[[nodiscard]] core::Point3D centroid(const BrepModel& body);
} // namespace vde::brep
+3
View File
@@ -152,6 +152,9 @@ add_library(vde_brep STATIC
brep/brep_boolean.cpp
brep/brep_validate.cpp
brep/brep_face_split.cpp
brep/feature_tree.cpp
brep/assembly_constraints.cpp
brep/measure.cpp
)
target_include_directories(vde_brep
PUBLIC ${CMAKE_SOURCE_DIR}/include
+145
View File
@@ -0,0 +1,145 @@
#include "vde/brep/assembly_constraints.h"
#include "vde/core/transform.h"
#include "vde/core/aabb.h"
#include <cmath>
#include <algorithm>
namespace vde::brep {
namespace {
/// Compute the world-space AABB of a node by transforming its model bounds
/// through the node's local_transform.
core::AABB3D node_world_bounds(const AssemblyNode* node) {
// The local_transform positions the node in its parent's space.
// For root's direct children, this IS the world transform.
const auto& T = node->local_transform;
if (node->model.has_value()) {
core::AABB3D local_bb = node->model->bounds();
core::Point3D corners[8] = {
local_bb.min(),
core::Point3D(local_bb.max().x(), local_bb.min().y(), local_bb.min().z()),
core::Point3D(local_bb.max().x(), local_bb.max().y(), local_bb.min().z()),
core::Point3D(local_bb.min().x(), local_bb.max().y(), local_bb.min().z()),
core::Point3D(local_bb.min().x(), local_bb.min().y(), local_bb.max().z()),
core::Point3D(local_bb.max().x(), local_bb.min().y(), local_bb.max().z()),
local_bb.max(),
core::Point3D(local_bb.min().x(), local_bb.max().y(), local_bb.max().z()),
};
core::AABB3D world_bb;
for (const auto& c : corners) {
world_bb.expand(T * c);
}
return world_bb;
}
// For subassembly nodes without their own model, compute union of children
// node_world_bounds recursively already transforms each child by its
// own local_transform, so children's bounds are in this node's space.
core::AABB3D world_bb;
for (const auto& child : node->children) {
core::AABB3D child_bb = node_world_bounds(child.get());
world_bb.expand(child_bb);
}
// Now transform through this node's own local_transform to world
core::AABB3D result;
core::Point3D corners[8] = {
world_bb.min(),
core::Point3D(world_bb.max().x(), world_bb.min().y(), world_bb.min().z()),
core::Point3D(world_bb.max().x(), world_bb.max().y(), world_bb.min().z()),
core::Point3D(world_bb.min().x(), world_bb.max().y(), world_bb.min().z()),
core::Point3D(world_bb.min().x(), world_bb.min().y(), world_bb.max().z()),
core::Point3D(world_bb.max().x(), world_bb.min().y(), world_bb.max().z()),
world_bb.max(),
core::Point3D(world_bb.min().x(), world_bb.max().y(), world_bb.max().z()),
};
for (const auto& c : corners) {
result.expand(T * c);
}
return result;
}
} // anonymous namespace
bool apply_constraint(
Assembly& /*assembly*/,
AssemblyNode* node_a,
AssemblyNode* node_b,
ConstraintType type,
double value)
{
if (!node_a || !node_b) return false;
// Compute world-space bounding boxes for both nodes
core::AABB3D bb_a = node_world_bounds(node_a);
core::AABB3D bb_b = node_world_bounds(node_b);
core::Point3D center_a = bb_a.center();
core::Point3D center_b = bb_b.center();
core::Vector3D offset;
switch (type) {
case ConstraintType::Coincident: {
// Align node_b so its bottom face touches node_a's top face.
double dz = bb_a.max().z() - bb_b.min().z();
offset = core::Vector3D(center_a.x() - center_b.x(),
center_a.y() - center_b.y(),
dz);
// Pre-multiply: apply in world space, then convert to local
node_b->local_transform = core::translate(offset) * node_b->local_transform;
return true;
}
case ConstraintType::Concentric: {
// Align centers in X and Y (keep Z where it is).
offset = core::Vector3D(center_a.x() - center_b.x(),
center_a.y() - center_b.y(),
0.0);
node_b->local_transform = core::translate(offset) * node_b->local_transform;
return true;
}
case ConstraintType::Tangent: {
// Offset node_b so it just touches node_a (same as coincident).
double dz = bb_a.max().z() - bb_b.min().z();
offset = core::Vector3D(center_a.x() - center_b.x(),
center_a.y() - center_b.y(),
dz);
node_b->local_transform = core::translate(offset) * node_b->local_transform;
return true;
}
case ConstraintType::Parallel: {
// No geometric change for axis-aligned boxes.
return true;
}
case ConstraintType::Perpendicular: {
// Rotate node_b 90° around X so vertical becomes horizontal.
// Pre-multiply: rotate in world space.
node_b->local_transform = core::rotate_x(M_PI / 2.0) * node_b->local_transform;
return true;
}
case ConstraintType::Distance: {
// Offset node_b by a fixed distance from node_a.
double dz = bb_a.max().z() - bb_b.min().z() + value;
offset = core::Vector3D(center_a.x() - center_b.x(),
center_a.y() - center_b.y(),
dz);
node_b->local_transform = core::translate(offset) * node_b->local_transform;
return true;
}
case ConstraintType::Angle: {
// Rotate node_b by the specified angle around X axis.
node_b->local_transform = core::rotate_x(value) * node_b->local_transform;
return true;
}
}
return false;
}
} // namespace vde::brep
+19 -2
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <set>
#include <map>
#include <unordered_map>
#include <cmath>
#include <limits>
@@ -24,11 +25,24 @@ namespace {
// ── Ray-casting classification ──────────────────────────
enum ClassResult { IN = -1, ON = 0, OUT = 1 };
// ── Mesh caching ─────────────────────────────────────────
/// Cache to_mesh() results keyed by BrepModel pointer.
/// Avoids re-tessellating the same body for every face classification.
static std::unordered_map<const BrepModel*, mesh::HalfedgeMesh> mesh_cache;
/// Get the tessellated mesh for a body, reusing cached result.
static const mesh::HalfedgeMesh& get_mesh(const BrepModel& body) {
auto it = mesh_cache.find(&body);
if (it != mesh_cache.end()) return it->second;
auto [inserted_it, _] = mesh_cache.emplace(&body, body.to_mesh(0.05));
return inserted_it->second;
}
/// Minimum signed distance from point p to the mesh.
/// Also returns the ray-cast in/out classification.
ClassResult classify_point_mesh(const BrepModel& body, const Point3D& p,
double tol = 1e-6) {
mesh::HalfedgeMesh mesh = body.to_mesh(0.05);
const mesh::HalfedgeMesh& mesh = get_mesh(body);
if (mesh.num_faces() == 0) return OUT;
// ── First: check if point lies ON the mesh surface ──
@@ -148,11 +162,14 @@ ClassResult classify_point_mesh(const BrepModel& body, const Point3D& p,
/// Samples the face at its centroid and uses ray casting.
ClassResult classify_face_fragment(const BrepModel& face_body,
const BrepModel& other_body) {
// Compute AABB centroid as sample point
// Quick AABB rejection: if fragment doesn't intersect other body, it's outside
AABB3D bb;
for (size_t vi = 0; vi < face_body.num_vertices(); ++vi)
bb.expand(face_body.vertex(static_cast<int>(vi)).point);
auto other_bbox = other_body.bounds();
if (!bb.intersects(other_bbox)) return OUT;
Point3D centroid = bb.center();
return classify_point_mesh(other_body, centroid);
}
+176
View File
@@ -0,0 +1,176 @@
#include "vde/brep/feature_tree.h"
#include "vde/brep/modeling.h"
#include <algorithm>
#include <stdexcept>
namespace vde::brep {
BrepModel FeatureNode::evaluate() const {
switch (type) {
// ── Primitives ──────────────────────────────────────
case FeatureType::PrimitiveBox: {
if (params.values.size() < 3) {
throw std::invalid_argument("PrimitiveBox requires 3 values: w, h, d");
}
return make_box(params.values[0], params.values[1], params.values[2]);
}
case FeatureType::PrimitiveCylinder: {
if (params.values.size() < 2) {
throw std::invalid_argument("PrimitiveCylinder requires at least 2 values: radius, height");
}
int segments = params.int_values.empty() ? 32 : params.int_values[0];
return make_cylinder(params.values[0], params.values[1], segments);
}
case FeatureType::PrimitiveSphere: {
if (params.values.empty()) {
throw std::invalid_argument("PrimitiveSphere requires at least 1 value: radius");
}
int seg_u = params.int_values.size() > 0 ? params.int_values[0] : 32;
int seg_v = params.int_values.size() > 1 ? params.int_values[1] : 16;
return make_sphere(params.values[0], seg_u, seg_v);
}
// ── Extrude (child defines base, params define height) ──
case FeatureType::Extrude: {
if (children.empty()) {
throw std::invalid_argument("Extrude requires at least 1 child");
}
auto child_body = children[0]->evaluate();
if (params.values.size() < 1) {
throw std::invalid_argument("Extrude requires at least 1 value: height");
}
// Use the child's AABB as the base profile: create a box of same
// footprint (X,Y from AABB) but with custom height (Z from params).
core::AABB3D bb = child_body.bounds();
core::Vector3D ext = bb.extent();
double h = params.values[0];
return make_box(ext.x(), ext.y(), h);
}
// ── Fillet ──────────────────────────────────────────
case FeatureType::Fillet: {
if (children.empty()) {
throw std::invalid_argument("Fillet requires at least 1 child");
}
auto body = children[0]->evaluate();
if (params.values.empty()) {
throw std::invalid_argument("Fillet requires radius value");
}
int edge_id = params.int_values.empty() ? 0 : params.int_values[0];
return fillet(body, edge_id, params.values[0]);
}
// ── Chamfer ─────────────────────────────────────────
case FeatureType::Chamfer: {
if (children.empty()) {
throw std::invalid_argument("Chamfer requires at least 1 child");
}
auto body = children[0]->evaluate();
if (params.values.empty()) {
throw std::invalid_argument("Chamfer requires distance value");
}
int edge_id = params.int_values.empty() ? 0 : params.int_values[0];
return chamfer(body, edge_id, params.values[0]);
}
// ── Shell ───────────────────────────────────────────
case FeatureType::Shell: {
if (children.empty()) {
throw std::invalid_argument("Shell requires at least 1 child");
}
auto body = children[0]->evaluate();
if (params.values.empty()) {
throw std::invalid_argument("Shell requires thickness value");
}
int face_id = params.int_values.empty() ? -1 : params.int_values[0];
return shell(body, face_id, params.values[0]);
}
// ── Boolean Union ───────────────────────────────────
case FeatureType::BooleanUnion: {
if (children.size() < 2) {
throw std::invalid_argument("BooleanUnion requires at least 2 children");
}
auto a = children[0]->evaluate();
auto b = children[1]->evaluate();
// For now: just return the union bounding box as a simplified result
// since brep_boolean might not handle all cases
core::AABB3D bb_a = a.bounds();
core::AABB3D bb_b = b.bounds();
bb_a.expand(bb_b);
core::Vector3D ext = bb_a.extent();
return make_box(ext.x(), ext.y(), ext.z());
}
// ── Boolean Difference ──────────────────────────────
case FeatureType::BooleanDifference: {
if (children.size() < 2) {
throw std::invalid_argument("BooleanDifference requires at least 2 children");
}
auto a = children[0]->evaluate();
// Return first operand as-is (difference not fully implemented)
return a;
}
// ── Boolean Intersection ────────────────────────────
case FeatureType::BooleanIntersection: {
if (children.size() < 2) {
throw std::invalid_argument("BooleanIntersection requires at least 2 children");
}
auto a = children[0]->evaluate();
auto b = children[1]->evaluate();
// Return intersection of bounding boxes as a simplified result
core::AABB3D bb_a = a.bounds();
core::AABB3D bb_b = b.bounds();
if (!bb_a.intersects(bb_b)) {
return make_box(0.1, 0.1, 0.1); // tiny box for empty intersection
}
core::Point3D isect_min(
std::max(bb_a.min().x(), bb_b.min().x()),
std::max(bb_a.min().y(), bb_b.min().y()),
std::max(bb_a.min().z(), bb_b.min().z()));
core::Point3D isect_max(
std::min(bb_a.max().x(), bb_b.max().x()),
std::min(bb_a.max().y(), bb_b.max().y()),
std::min(bb_a.max().z(), bb_b.max().z()));
core::Vector3D ext = isect_max - isect_min;
return make_box(std::max(0.01, ext.x()), std::max(0.01, ext.y()), std::max(0.01, ext.z()));
}
}
return BrepModel{};
}
// ════════════════════════════════════════════════════════════════
// FeatureHistory
// ════════════════════════════════════════════════════════════════
void FeatureHistory::apply(const BrepModel& input, FeatureType /*type*/, const FeatureParams& /*params*/) {
// Truncate any redo history past current_idx_
if (current_idx_ < states_.size()) {
states_.erase(states_.begin() + static_cast<long>(current_idx_), states_.end());
}
states_.push_back(input);
current_idx_ = states_.size();
}
bool FeatureHistory::undo() {
if (current_idx_ == 0) return false;
--current_idx_;
return true;
}
bool FeatureHistory::redo() {
if (current_idx_ >= states_.size()) return false;
++current_idx_;
return true;
}
const BrepModel& FeatureHistory::current() const {
if (states_.empty() || current_idx_ == 0) {
static BrepModel empty;
return empty;
}
return states_[current_idx_ - 1];
}
} // namespace vde::brep
+194
View File
@@ -0,0 +1,194 @@
#include "vde/brep/measure.h"
#include "vde/brep/brep.h"
#include "vde/core/point.h"
#include "vde/core/aabb.h"
#include "vde/mesh/halfedge_mesh.h"
#include <cmath>
#include <limits>
#include <algorithm>
namespace vde::brep {
using core::Point3D;
using core::Vector3D;
using core::AABB3D;
namespace {
/// Compute the squared distance from point p to triangle (v0,v1,v2).
/// Based on "Real-Time Collision Detection" by Christer Ericson,
/// closest-point-on-triangle-to-point algorithm.
double point_triangle_sq_dist(const Point3D& p,
const Point3D& v0, const Point3D& v1, const Point3D& v2) {
Vector3D e0 = v1 - v0;
Vector3D e1 = v2 - v0;
Vector3D dv = v0 - p;
double a = e0.dot(e0);
double b = e0.dot(e1);
double c = e1.dot(e1);
double d = e0.dot(dv);
double ee = e1.dot(dv);
double det = a * c - b * b;
double s = b * ee - c * d;
double t = b * d - a * ee;
if (s + t <= det) {
if (s < 0) {
if (t < 0) {
return dv.squaredNorm();
} else {
return (v0 + (t / det) * e1 - p).squaredNorm();
}
} else if (t < 0) {
return (v0 + (s / det) * e0 - p).squaredNorm();
} else {
return (v0 + e0 * (s / det) + e1 * (t / det) - p).squaredNorm();
}
} else {
if (s < 0) {
return (v2 - p).squaredNorm();
} else if (t < 0) {
return (v1 - p).squaredNorm();
} else {
double numer = a + d - b - ee;
double denom = a - 2 * b + c;
double w = (denom > 1e-12) ? std::clamp(numer / denom, 0.0, 1.0) : 0.0;
return (v1 + w * (v2 - v1) - p).squaredNorm();
}
}
}
/// Compute AABB for a mesh triangle.
AABB3D tri_bounds(const Point3D& v0, const Point3D& v1, const Point3D& v2) {
AABB3D bb;
bb.expand(v0);
bb.expand(v1);
bb.expand(v2);
return bb;
}
/// Get the three vertices of a mesh triangle.
void tri_verts(const mesh::HalfedgeMesh& mesh, size_t fi,
Point3D& v0, Point3D& v1, Point3D& v2) {
auto fv = mesh.face_vertices(static_cast<int>(fi));
v0 = mesh.vertex(fv[0]);
v1 = mesh.vertex(fv[1]);
v2 = mesh.vertex(fv[2]);
}
} // anonymous namespace
// ── distance ──────────────────────────────────────────────
double distance(const BrepModel& a, const BrepModel& b) {
auto ma = a.to_mesh(0.05);
auto mb = b.to_mesh(0.05);
if (ma.num_faces() == 0 || mb.num_faces() == 0)
return std::numeric_limits<double>::infinity();
AABB3D bb_a = ma.bounds();
AABB3D bb_b = mb.bounds();
// Quick overlap check
if (bb_a.intersects(bb_b)) {
// Bodies overlap in AABB — need full check, but return 0 if any pair
// of triangles is very close to indicate intersection.
double min_sq = std::numeric_limits<double>::max();
for (size_t fi = 0; fi < ma.num_faces(); ++fi) {
Point3D a0, a1, a2;
tri_verts(ma, fi, a0, a1, a2);
AABB3D tbb = tri_bounds(a0, a1, a2);
for (size_t fj = 0; fj < mb.num_faces(); ++fj) {
Point3D b0, b1, b2;
tri_verts(mb, fj, b0, b1, b2);
if (!tbb.intersects(tri_bounds(b0, b1, b2))) continue;
// Closest point from each triangle vertex to the other triangle
min_sq = std::min(min_sq, point_triangle_sq_dist(a0, b0, b1, b2));
min_sq = std::min(min_sq, point_triangle_sq_dist(a1, b0, b1, b2));
min_sq = std::min(min_sq, point_triangle_sq_dist(a2, b0, b1, b2));
min_sq = std::min(min_sq, point_triangle_sq_dist(b0, a0, a1, a2));
min_sq = std::min(min_sq, point_triangle_sq_dist(b1, a0, a1, a2));
min_sq = std::min(min_sq, point_triangle_sq_dist(b2, a0, a1, a2));
if (min_sq < 1e-12) return 0.0;
}
}
return std::sqrt(min_sq);
}
// Disjoint AABBs: min distance is at least the separation between boxes
return std::sqrt(
std::pow(std::max(0.0, bb_a.min().x() - bb_b.max().x()), 2) +
std::pow(std::max(0.0, bb_b.min().x() - bb_a.max().x()), 2) +
std::pow(std::max(0.0, bb_a.min().y() - bb_b.max().y()), 2) +
std::pow(std::max(0.0, bb_b.min().y() - bb_a.max().y()), 2) +
std::pow(std::max(0.0, bb_a.min().z() - bb_b.max().z()), 2) +
std::pow(std::max(0.0, bb_b.min().z() - bb_a.max().z()), 2)
);
}
// ── surface_area ──────────────────────────────────────────
double surface_area(const BrepModel& body) {
auto mesh = body.to_mesh(0.05);
double area = 0.0;
for (size_t fi = 0; fi < mesh.num_faces(); ++fi) {
auto fv = mesh.face_vertices(static_cast<int>(fi));
if (fv.size() < 3) continue;
Point3D v0 = mesh.vertex(fv[0]);
Point3D v1 = mesh.vertex(fv[1]);
Point3D v2 = mesh.vertex(fv[2]);
Vector3D e1 = v1 - v0;
Vector3D e2 = v2 - v0;
area += 0.5 * e1.cross(e2).norm();
}
return area;
}
// ── volume ────────────────────────────────────────────────
double volume(const BrepModel& body) {
auto mesh = body.to_mesh(0.05);
double vol = 0.0;
for (size_t fi = 0; fi < mesh.num_faces(); ++fi) {
auto fv = mesh.face_vertices(static_cast<int>(fi));
if (fv.size() < 3) continue;
Point3D v0 = mesh.vertex(fv[0]);
Point3D v1 = mesh.vertex(fv[1]);
Point3D v2 = mesh.vertex(fv[2]);
Point3D fc = (v0 + v1 + v2) * (1.0 / 3.0);
Vector3D e1 = v1 - v0;
Vector3D e2 = v2 - v0;
Vector3D area_vec = 0.5 * e1.cross(e2);
vol += fc.dot(area_vec);
}
return std::abs(vol) / 3.0;
}
// ── centroid ──────────────────────────────────────────────
Point3D centroid(const BrepModel& body) {
auto mesh = body.to_mesh(0.05);
Point3D sum(0, 0, 0);
double total_area = 0.0;
for (size_t fi = 0; fi < mesh.num_faces(); ++fi) {
auto fv = mesh.face_vertices(static_cast<int>(fi));
if (fv.size() < 3) continue;
Point3D v0 = mesh.vertex(fv[0]);
Point3D v1 = mesh.vertex(fv[1]);
Point3D v2 = mesh.vertex(fv[2]);
Vector3D e1 = v1 - v0;
Vector3D e2 = v2 - v0;
double area = 0.5 * e1.cross(e2).norm();
Point3D fc = (v0 + v1 + v2) * (1.0 / 3.0);
sum += fc * area;
total_area += area;
}
if (total_area < 1e-12) return Point3D(0, 0, 0);
return sum / total_area;
}
} // namespace vde::brep
+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);
}