From a2b34733bf8c83df806db9127df4216cb0f8e775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8C=82=E4=B9=8B=E9=92=B3?= Date: Sat, 25 Jul 2026 03:18:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(v4.0):=20explode=20view=20=E2=80=94=20asse?= =?UTF-8?q?mbly=20part=20decomposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + Testing/Temporary/CTestCostData.txt | 1 + Testing/Temporary/LastTest.log | 3 + include/vde/brep/explode_view.h | 105 ++++++++++++++ src/CMakeLists.txt | 1 + src/brep/explode_view.cpp | 202 +++++++++++++++++++++++++++ tests/brep/CMakeLists.txt | 1 + tests/brep/test_explode_view.cpp | 205 ++++++++++++++++++++++++++++ 8 files changed, 519 insertions(+) create mode 100644 Testing/Temporary/CTestCostData.txt create mode 100644 Testing/Temporary/LastTest.log create mode 100644 include/vde/brep/explode_view.h create mode 100644 src/brep/explode_view.cpp create mode 100644 tests/brep/test_explode_view.cpp diff --git a/.gitignore b/.gitignore index d6beec7..a638a0f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ coverage_report/ __pycache__/ build_bench/ build/ +build_asan/ diff --git a/Testing/Temporary/CTestCostData.txt b/Testing/Temporary/CTestCostData.txt new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/Testing/Temporary/CTestCostData.txt @@ -0,0 +1 @@ +--- diff --git a/Testing/Temporary/LastTest.log b/Testing/Temporary/LastTest.log new file mode 100644 index 0000000..520664c --- /dev/null +++ b/Testing/Temporary/LastTest.log @@ -0,0 +1,3 @@ +Start testing: Jul 25 01:42 UTC +---------------------------------------------------------- +End testing: Jul 25 01:42 UTC diff --git a/include/vde/brep/explode_view.h b/include/vde/brep/explode_view.h new file mode 100644 index 0000000..d995365 --- /dev/null +++ b/include/vde/brep/explode_view.h @@ -0,0 +1,105 @@ +#pragma once +/** + * @file explode_view.h + * @brief 装配体爆炸视图 + * + * 将装配体零件沿约束/层级方向展开,生成爆炸视图。 + * 每个零件的世界变换被修改以产生分解效果。 + * + * @ingroup brep + */ + +#include "vde/brep/assembly.h" +#include "vde/core/point.h" +#include +#include +#include + +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 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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a9dbe84..0b146ef 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/brep/explode_view.cpp b/src/brep/explode_view.cpp new file mode 100644 index 0000000..1f26b3e --- /dev/null +++ b/src/brep/explode_view.cpp @@ -0,0 +1,202 @@ +#include "vde/brep/explode_view.h" +#include "vde/core/aabb.h" +#include +#include +#include + +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& 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 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 name_to_index; + std::vector 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 diff --git a/tests/brep/CMakeLists.txt b/tests/brep/CMakeLists.txt index 53f6e46..7424c62 100644 --- a/tests/brep/CMakeLists.txt +++ b/tests/brep/CMakeLists.txt @@ -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) diff --git a/tests/brep/test_explode_view.cpp b/tests/brep/test_explode_view.cpp new file mode 100644 index 0000000..a3ec518 --- /dev/null +++ b/tests/brep/test_explode_view.cpp @@ -0,0 +1,205 @@ +#include +#include "vde/brep/explode_view.h" +#include "vde/brep/modeling.h" +#include "vde/core/transform.h" +#include + +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 +}