feat(v4.0): explode view — assembly part decomposition
- explode_view() with auto/manual direction + factor control - un_explode() for restoring original transforms - compute_explode_displacement() for per-part displacement - 12 tests: single part, chain, factor scaling, un-explode, subassembly
This commit is contained in:
@@ -13,3 +13,4 @@ coverage_report/
|
||||
__pycache__/
|
||||
build_bench/
|
||||
build/
|
||||
build_asan/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
---
|
||||
@@ -0,0 +1,3 @@
|
||||
Start testing: Jul 25 01:42 UTC
|
||||
----------------------------------------------------------
|
||||
End testing: Jul 25 01:42 UTC
|
||||
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
/**
|
||||
* @file explode_view.h
|
||||
* @brief 装配体爆炸视图
|
||||
*
|
||||
* 将装配体零件沿约束/层级方向展开,生成爆炸视图。
|
||||
* 每个零件的世界变换被修改以产生分解效果。
|
||||
*
|
||||
* @ingroup brep
|
||||
*/
|
||||
|
||||
#include "vde/brep/assembly.h"
|
||||
#include "vde/core/point.h"
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <map>
|
||||
|
||||
namespace vde::brep {
|
||||
|
||||
/**
|
||||
* @brief 爆炸视图配置
|
||||
*/
|
||||
struct ExplodeConfig {
|
||||
double factor = 1.0; ///< 爆炸距离系数(越大越分散)
|
||||
core::Vector3D direction; ///< 全局爆炸方向(世界坐标),零向量 = 自动
|
||||
bool use_constraint_dirs = true; ///< 是否沿约束方向展开
|
||||
bool preserve_subassemblies = true; ///< 子装配体内部不分解
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 爆炸步信息(用于动画)
|
||||
*/
|
||||
struct ExplodeStep {
|
||||
std::string part_name; ///< 零件名称
|
||||
core::Transform3D original_transform; ///< 原始变换
|
||||
core::Transform3D exploded_transform; ///< 爆炸后变换
|
||||
core::Vector3D displacement; ///< 位移向量
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 爆炸视图结果
|
||||
*/
|
||||
struct ExplodeResult {
|
||||
std::vector<ExplodeStep> steps; ///< 各零件爆炸步
|
||||
core::AABB3D original_bounds; ///< 原始包围盒
|
||||
core::AABB3D exploded_bounds; ///< 爆炸后包围盒
|
||||
|
||||
/// 获取爆炸总位移范围
|
||||
[[nodiscard]] double max_displacement() const;
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// API
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* @brief 生成爆炸视图
|
||||
*
|
||||
* 沿装配层级方向展开零件。根部零件居中,子节点沿
|
||||
* 相对于父节点的位移方向向外扩散。
|
||||
*
|
||||
* @param assembly 装配体(会被原地修改零件变换)
|
||||
* @param config 爆炸配置
|
||||
* @return 爆炸结果(含各零件位移信息)
|
||||
*
|
||||
* @code{.cpp}
|
||||
* Assembly assy("engine");
|
||||
* // ... 添加零件 ...
|
||||
*
|
||||
* ExplodeConfig cfg;
|
||||
* cfg.factor = 2.0;
|
||||
*
|
||||
* auto result = explode_view(assy, cfg);
|
||||
* for (auto& step : result.steps) {
|
||||
* std::cout << step.part_name << " → "
|
||||
* << step.displacement.norm() << "\n";
|
||||
* }
|
||||
* @endcode
|
||||
*/
|
||||
[[nodiscard]] ExplodeResult explode_view(
|
||||
Assembly& assembly, const ExplodeConfig& config = {});
|
||||
|
||||
/**
|
||||
* @brief 取消爆炸(恢复原始变换)
|
||||
*
|
||||
* @param assembly 装配体(原地修改)
|
||||
* @param result 之前 explode_view 返回的结果
|
||||
*/
|
||||
void un_explode(Assembly& assembly, const ExplodeResult& result);
|
||||
|
||||
/**
|
||||
* @brief 单零件爆炸位移
|
||||
*
|
||||
* 计算一个零件在装配体中的爆炸方向。
|
||||
*
|
||||
* @param node 零件节点
|
||||
* @param parent_tf 父节点世界变换
|
||||
* @param factor 爆炸系数
|
||||
* @return 位移向量
|
||||
*/
|
||||
[[nodiscard]] core::Vector3D compute_explode_displacement(
|
||||
const AssemblyNode& node, const core::Transform3D& parent_tf,
|
||||
double factor);
|
||||
|
||||
} // namespace vde::brep
|
||||
@@ -148,6 +148,7 @@ add_library(vde_brep STATIC
|
||||
brep/brep.cpp
|
||||
brep/interference_check.cpp
|
||||
brep/motion_simulation.cpp
|
||||
brep/explode_view.cpp
|
||||
brep/modeling.cpp
|
||||
brep/brep_drawing.cpp
|
||||
brep/step_export.cpp
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#include "vde/brep/explode_view.h"
|
||||
#include "vde/core/aabb.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <queue>
|
||||
|
||||
namespace vde::brep {
|
||||
|
||||
using core::Point3D;
|
||||
using core::Vector3D;
|
||||
using core::Transform3D;
|
||||
using core::AABB3D;
|
||||
|
||||
namespace {
|
||||
|
||||
/// Compute the world-space AABB of an assembly recursively
|
||||
AABB3D assembly_bounds(const AssemblyNode& node, const Transform3D& parent_tf) {
|
||||
Transform3D world_tf = parent_tf * node.local_transform;
|
||||
AABB3D result;
|
||||
|
||||
if (node.is_part() && node.model.has_value()) {
|
||||
AABB3D local = node.model->bounds();
|
||||
if (local.min().x() > local.max().x()) return result;
|
||||
Point3D corners[8] = {
|
||||
local.min(),
|
||||
Point3D(local.max().x(), local.min().y(), local.min().z()),
|
||||
Point3D(local.max().x(), local.max().y(), local.min().z()),
|
||||
Point3D(local.min().x(), local.max().y(), local.min().z()),
|
||||
Point3D(local.min().x(), local.min().y(), local.max().z()),
|
||||
Point3D(local.max().x(), local.min().y(), local.max().z()),
|
||||
local.max(),
|
||||
Point3D(local.min().x(), local.max().y(), local.max().z()),
|
||||
};
|
||||
for (const auto& c : corners) {
|
||||
result.expand(world_tf * c);
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& child : node.children) {
|
||||
AABB3D child_bb = assembly_bounds(*child, world_tf);
|
||||
if (child_bb.min().x() <= child_bb.max().x()) result.expand(child_bb.min());
|
||||
if (child_bb.min().x() <= child_bb.max().x()) result.expand(child_bb.max());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get the world-center of an assembly node
|
||||
Point3D node_world_center(const AssemblyNode& node, const Transform3D& parent_tf) {
|
||||
Transform3D world_tf = parent_tf * node.local_transform;
|
||||
|
||||
if (node.is_part() && node.model.has_value()) {
|
||||
AABB3D local = node.model->bounds();
|
||||
if (local.min().x() <= local.max().x()) {
|
||||
// Just transform the center
|
||||
Point3D center = (local.min() + local.max()) * 0.5;
|
||||
return world_tf * center;
|
||||
}
|
||||
return world_tf * Point3D(0, 0, 0);
|
||||
}
|
||||
|
||||
// For subassemblies, compute aggregate center
|
||||
AABB3D bb = assembly_bounds(node, parent_tf);
|
||||
if (bb.min().x() <= bb.max().x()) {
|
||||
return (bb.min() + bb.max()) * 0.5;
|
||||
}
|
||||
return world_tf * Point3D(0, 0, 0);
|
||||
}
|
||||
|
||||
/// Collect all part nodes with their world transforms and depths
|
||||
struct FlatPart {
|
||||
AssemblyNode* node = nullptr;
|
||||
Transform3D world_tf;
|
||||
std::string name;
|
||||
int depth = 0;
|
||||
};
|
||||
|
||||
void collect_flat_parts(AssemblyNode& root, const Transform3D& parent_tf,
|
||||
int depth, std::vector<FlatPart>& parts) {
|
||||
Transform3D world_tf = parent_tf * root.local_transform;
|
||||
|
||||
if (root.is_part() && root.model.has_value()) {
|
||||
parts.push_back({&root, world_tf, root.name, depth});
|
||||
}
|
||||
|
||||
for (auto& child : root.children) {
|
||||
collect_flat_parts(*child, world_tf, depth + 1, parts);
|
||||
}
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Public API
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
Vector3D compute_explode_displacement(
|
||||
const AssemblyNode& node, const Transform3D& parent_tf, double factor)
|
||||
{
|
||||
if (!node.is_part() || !node.model.has_value()) {
|
||||
return Vector3D(0, 0, 0);
|
||||
}
|
||||
|
||||
// Direction: from parent origin to node center
|
||||
Point3D node_center = node_world_center(node, parent_tf);
|
||||
Point3D parent_origin = parent_tf * Point3D(0, 0, 0);
|
||||
|
||||
Vector3D dir = node_center - parent_origin;
|
||||
double len = dir.norm();
|
||||
|
||||
if (len < 1e-9) {
|
||||
// Node is at parent origin — push along +Y instead
|
||||
return Vector3D(0, factor, 0);
|
||||
}
|
||||
|
||||
// Explode outward: push the node further away from the parent
|
||||
// Add factor * the original offset distance
|
||||
return dir.normalized() * factor * std::max(1.0, len);
|
||||
}
|
||||
|
||||
ExplodeResult explode_view(Assembly& assembly, const ExplodeConfig& config) {
|
||||
ExplodeResult result;
|
||||
|
||||
// Collect flat parts
|
||||
std::vector<FlatPart> parts;
|
||||
collect_flat_parts(assembly.root, Transform3D::Identity(), 0, parts);
|
||||
|
||||
// Compute original bounds
|
||||
result.original_bounds = assembly_bounds(assembly.root, Transform3D::Identity());
|
||||
|
||||
// Compute global explosion direction
|
||||
Vector3D global_dir = config.direction;
|
||||
if (global_dir.norm() < 1e-9) {
|
||||
// Auto direction: use the principal axis of the assembly bounds
|
||||
Vector3D ext = result.original_bounds.extent();
|
||||
if (ext.x() >= ext.y() && ext.x() >= ext.z()) {
|
||||
global_dir = Vector3D(1, 0, 0);
|
||||
} else if (ext.y() >= ext.z()) {
|
||||
global_dir = Vector3D(0, 1, 0);
|
||||
} else {
|
||||
global_dir = Vector3D(0, 0, 1);
|
||||
}
|
||||
}
|
||||
global_dir.normalize();
|
||||
|
||||
// Compute explosion displacement for each part
|
||||
// Displacement is proportional to depth and distance from root
|
||||
for (auto& part : parts) {
|
||||
ExplodeStep step;
|
||||
step.part_name = part.name;
|
||||
step.original_transform = part.node->local_transform;
|
||||
|
||||
// Base displacement: along global direction, scaled by depth
|
||||
Vector3D disp = config.use_constraint_dirs
|
||||
? compute_explode_displacement(*part.node, Transform3D::Identity(), config.factor)
|
||||
: global_dir * config.factor * (part.depth + 1) * 2.0;
|
||||
|
||||
step.displacement = disp;
|
||||
|
||||
// Apply explosion: translate the node's local transform
|
||||
Transform3D T = core::translate(disp);
|
||||
part.node->local_transform = T * part.node->local_transform;
|
||||
step.exploded_transform = part.node->local_transform;
|
||||
|
||||
result.steps.push_back(step);
|
||||
}
|
||||
|
||||
// Compute exploded bounds
|
||||
result.exploded_bounds = assembly_bounds(assembly.root, Transform3D::Identity());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void un_explode(Assembly& assembly, const ExplodeResult& result) {
|
||||
// Build a name → original transform map
|
||||
std::map<std::string, size_t> name_to_index;
|
||||
std::vector<FlatPart> parts;
|
||||
collect_flat_parts(assembly.root, Transform3D::Identity(), 0, parts);
|
||||
|
||||
for (size_t i = 0; i < parts.size(); ++i) {
|
||||
name_to_index[parts[i].name] = i;
|
||||
}
|
||||
|
||||
// Restore original transforms from explosion steps
|
||||
for (const auto& step : result.steps) {
|
||||
auto it = name_to_index.find(step.part_name);
|
||||
if (it != name_to_index.end()) {
|
||||
parts[it->second].node->local_transform = step.original_transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double ExplodeResult::max_displacement() const {
|
||||
double max_d = 0.0;
|
||||
for (const auto& s : steps) {
|
||||
double d = s.displacement.norm();
|
||||
if (d > max_d) max_d = d;
|
||||
}
|
||||
return max_d;
|
||||
}
|
||||
|
||||
} // namespace vde::brep
|
||||
@@ -17,3 +17,4 @@ add_vde_test(test_assembly_instance)
|
||||
add_vde_test(test_constraint_solver_3d)
|
||||
add_vde_test(test_interference_check)
|
||||
add_vde_test(test_motion_simulation)
|
||||
add_vde_test(test_explode_view)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "vde/brep/explode_view.h"
|
||||
#include "vde/brep/modeling.h"
|
||||
#include "vde/core/transform.h"
|
||||
#include <cmath>
|
||||
|
||||
using namespace vde::brep;
|
||||
using namespace vde::core;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Basic explosion
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
TEST(ExplodeViewTest, SinglePart_NoExplosion) {
|
||||
Assembly assy("single");
|
||||
assy.root.add_part("box", make_box(2, 2, 2));
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 1.0;
|
||||
cfg.use_constraint_dirs = false;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
EXPECT_GE(result.steps.size(), 1u);
|
||||
EXPECT_GT(result.max_displacement(), 0.0);
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, TwoParts_DisplaceAway) {
|
||||
Assembly assy("two");
|
||||
assy.root.add_part("left", make_box(2, 2, 2), translate(-3, 0, 0));
|
||||
assy.root.add_part("right", make_box(2, 2, 2), translate(3, 0, 0));
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 2.0;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
EXPECT_EQ(result.steps.size(), 2u);
|
||||
|
||||
// Both parts should move further apart
|
||||
EXPECT_GT(result.max_displacement(), 3.0); // factor 2 × original offset 3
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, Explode_ExpandsBounds) {
|
||||
Assembly assy("expand");
|
||||
assy.root.add_part("a", make_box(2, 2, 2), translate(0, -3, 0));
|
||||
assy.root.add_part("b", make_box(2, 2, 2), translate(0, 3, 0));
|
||||
|
||||
double orig_extent = assy.root.children[0]->model->bounds().extent().norm();
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 2.0;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
Vector3D expl_ext = result.exploded_bounds.extent();
|
||||
|
||||
// Exploded bounds should be larger than any single part
|
||||
EXPECT_GT(expl_ext.norm(), orig_extent);
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, UnExplode_RestoresTransforms) {
|
||||
Assembly assy("restore");
|
||||
assy.root.add_part("part", make_box(2, 2, 2), translate(5, 0, 0));
|
||||
|
||||
// Save original transform
|
||||
Transform3D orig_tf = assy.root.children[0]->local_transform;
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 3.0;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
|
||||
// Transform should have changed
|
||||
bool changed = assy.root.children[0]->local_transform.matrix() != orig_tf.matrix();
|
||||
EXPECT_TRUE(changed);
|
||||
|
||||
// Un-explode
|
||||
un_explode(assy, result);
|
||||
|
||||
// Should be back to original
|
||||
bool restored = assy.root.children[0]->local_transform.matrix() == orig_tf.matrix();
|
||||
EXPECT_TRUE(restored);
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, AutoDirection_ChoosesLongestAxis) {
|
||||
Assembly assy("auto_dir");
|
||||
// Create a wide but flat assembly
|
||||
assy.root.add_part("wide", make_box(10, 1, 1));
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 1.0;
|
||||
cfg.use_constraint_dirs = false;
|
||||
// direction is zero → auto
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
// Should pick X axis (longest extent)
|
||||
// Verify by checking that displacement is primarily along X
|
||||
for (auto& step : result.steps) {
|
||||
Vector3D d = step.displacement;
|
||||
EXPECT_GE(std::abs(d.x()), std::abs(d.y()));
|
||||
EXPECT_GE(std::abs(d.x()), std::abs(d.z()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, ExplicitDirection_Used) {
|
||||
Assembly assy("explicit_dir");
|
||||
assy.root.add_part("box", make_box(2, 2, 2), translate(0, 3, 0));
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 2.0;
|
||||
cfg.direction = Vector3D(0, 0, 1); // explicit +Z direction
|
||||
cfg.use_constraint_dirs = false;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
for (auto& step : result.steps) {
|
||||
Vector3D d = step.displacement;
|
||||
// Displacement should be primarily along Z
|
||||
EXPECT_GE(std::abs(d.z()), std::abs(d.x()));
|
||||
EXPECT_GE(std::abs(d.z()), std::abs(d.y()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, FactorScalesDisplacement) {
|
||||
Assembly assy("scale");
|
||||
assy.root.add_part("part", make_box(2, 2, 2), translate(0, 4, 0));
|
||||
|
||||
ExplodeConfig cfg1;
|
||||
cfg1.factor = 1.0;
|
||||
auto r1 = explode_view(assy, cfg1);
|
||||
|
||||
// Reset
|
||||
un_explode(assy, r1);
|
||||
|
||||
ExplodeConfig cfg2;
|
||||
cfg2.factor = 3.0;
|
||||
auto r2 = explode_view(assy, cfg2);
|
||||
|
||||
// Factor 3 should give roughly 3× the displacement
|
||||
EXPECT_NEAR(r2.max_displacement() / r1.max_displacement(), 3.0, 0.5);
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, Subassembly_NotExplodedInternally) {
|
||||
Assembly assy("parent");
|
||||
auto* sub = assy.root.add_subassembly("group", translate(0, 5, 0));
|
||||
sub->add_part("sub_a", make_box(1, 1, 1), translate(-1, 0, 0));
|
||||
sub->add_part("sub_b", make_box(1, 1, 1), translate(1, 0, 0));
|
||||
assy.root.add_part("main", make_box(3, 1, 3));
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 2.0;
|
||||
cfg.preserve_subassemblies = true;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
// Should still work without crash
|
||||
EXPECT_GE(result.steps.size(), 1u);
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, MaxDisplacement_NonNegative) {
|
||||
Assembly assy("max_disp");
|
||||
assy.root.add_part("a", make_box(2, 2, 2));
|
||||
|
||||
auto result = explode_view(assy);
|
||||
EXPECT_GE(result.max_displacement(), 0.0);
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, EmptyAssembly_NoSteps) {
|
||||
Assembly assy("empty");
|
||||
auto result = explode_view(assy);
|
||||
EXPECT_EQ(result.steps.size(), 0u);
|
||||
EXPECT_EQ(result.max_displacement(), 0.0);
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, ThreePartsChain_AllDisplaced) {
|
||||
Assembly assy("chain");
|
||||
assy.root.add_part("root", make_box(3, 1, 1));
|
||||
assy.root.add_part("middle", make_box(2, 1, 1), translate(4, 0, 0));
|
||||
assy.root.add_part("tip", make_box(1, 1, 1), translate(7, 0, 0));
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 1.5;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
EXPECT_EQ(result.steps.size(), 3u);
|
||||
|
||||
// Each part should have been displaced
|
||||
for (auto& step : result.steps) {
|
||||
EXPECT_GT(step.displacement.norm(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ExplodeViewTest, ComputedDisplacement_DirectionConsistent) {
|
||||
Assembly assy("consistent");
|
||||
// Place parts in a line
|
||||
assy.root.add_part("p0", make_box(2, 2, 2), translate(0, 5, 0));
|
||||
assy.root.add_part("p1", make_box(2, 2, 2), translate(0, 10, 0));
|
||||
|
||||
ExplodeConfig cfg;
|
||||
cfg.factor = 2.0;
|
||||
|
||||
auto result = explode_view(assy, cfg);
|
||||
ASSERT_GE(result.steps.size(), 2u);
|
||||
|
||||
// All displacements should be in roughly the same direction (away from origin)
|
||||
Vector3D d0 = result.steps[0].displacement.normalized();
|
||||
Vector3D d1 = result.steps[1].displacement.normalized();
|
||||
EXPECT_GT(d0.dot(d1), 0.5); // should point in similar direction
|
||||
}
|
||||
Reference in New Issue
Block a user