feat(v4.0): explode view — assembly part decomposition
CI / Build & Test (push) Failing after 36s
CI / Release Build (push) Failing after 47s
Build & Test / build-and-test (push) Has been cancelled
Build & Test / python-bindings (push) Has been cancelled

- 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:
茂之钳
2026-07-25 03:18:43 +00:00
parent d61c8d8a7b
commit a2b34733bf
8 changed files with 519 additions and 0 deletions
+1
View File
@@ -13,3 +13,4 @@ coverage_report/
__pycache__/
build_bench/
build/
build_asan/
+1
View File
@@ -0,0 +1 @@
---
+3
View File
@@ -0,0 +1,3 @@
Start testing: Jul 25 01:42 UTC
----------------------------------------------------------
End testing: Jul 25 01:42 UTC
+105
View File
@@ -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
+1
View File
@@ -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
+202
View File
@@ -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
+1
View File
@@ -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)
+205
View File
@@ -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
}