diff --git a/docs/19-v4.1-开发计划.md b/docs/19-v4.1-开发计划.md new file mode 100644 index 0000000..7008ca9 --- /dev/null +++ b/docs/19-v4.1-开发计划.md @@ -0,0 +1,32 @@ +# ViewDesignEngine v4.1 — 增量更新 + 大装配 + 格式健壮性 + +> 制定: 2026-07-25 | 状态: 执行中 + +## 1. 🔄 增量更新引擎 + +特征树参数修改 → 仅重建受影响的下游节点,而非全量重算。 + +- [ ] `DirtyFlag` 标记传播:修改叶节点 → 沿依赖链向上标脏 +- [ ] `incremental_rebuild(FeatureNode)` — 仅重建脏节点 +- [ ] 缓存支持:`mesh_cache` / `bounds_cache` 按 node_id 存储 +- [ ] `invalidate_cache(node_id)` — 清除脏节点的缓存 +- [ ] 性能对比:增量 vs 全量重建耗时 +- [ ] 测试:修改尺寸 → 增量重建正确性 + +## 2. 📦 大装配性能(10K+ 零件) + +- [ ] `AssemblyInstancer` 扩展:batch placement + 共享几何 +- [ ] `SpatialIndex` 集成:BVH/R-Tree 加速装配遍历 +- [ ] `LOD` 自动切换:视距/屏幕误差驱动 +- [ ] `InstanceCache`:实例化零件共享 mesh 数据 +- [ ] 性能测试:10K 零件装配遍历 < 100ms +- [ ] 测试:大规模装配无内存爆炸 + +## 3. 📐 STEP/IGES 健壮性 + +- [ ] STEP 导入:处理更多实体类型(ADVANCED_FACE, EDGE_LOOP 变体) +- [ ] STEP 导入:容错解析(跳过未知实体,继续解析已知部分) +- [ ] IGES 导入:处理截断文件、非标格式 +- [ ] 格式自动检测:STEP vs IGES 自动识别 +- [ ] 批量导入测试:工业标准测试文件集 +- [ ] 测试:损坏/截断文件优雅降级 diff --git a/include/vde/brep/format_io.h b/include/vde/brep/format_io.h new file mode 100644 index 0000000..733c01e --- /dev/null +++ b/include/vde/brep/format_io.h @@ -0,0 +1,100 @@ +#pragma once +/** + * @file format_io.h + * @brief CAD 格式导入健壮性增强 + * + * 自动格式检测、容错解析、批量导入。 + * + * @ingroup foundation + */ + +#include "vde/brep/brep.h" +#include +#include +#include + +namespace vde::brep { + +// ═══════════════════════════════════════════════════════════ +// Format detection +// ═══════════════════════════════════════════════════════════ + +/// 检测到的 CAD 格式 +enum class CadFormat { + Unknown, + STEP, + IGES, + STL_ASCII, + STL_Binary, +}; + +/** + * @brief 自动检测 CAD 文件格式 + * + * 通过文件头签名和扩展名判断格式。 + * + * @param filepath 文件路径 + * @return 检测到的格式,未知时返回 Unknown + */ +[[nodiscard]] CadFormat detect_format(const std::string& filepath); + +/** + * @brief 自动检测内存中的 CAD 数据格式 + * + * @param data 数据内容 + * @return 检测到的格式 + */ +[[nodiscard]] CadFormat detect_format_from_data(const std::string& data); + +// ═══════════════════════════════════════════════════════════ +// Auto import +// ═══════════════════════════════════════════════════════════ + +/** + * @brief 智能导入:自动检测格式并导入 + * + * 支持 STEP, IGES 格式的自动识别和导入。 + * 解析错误时不会崩溃,而是返回已成功解析的部分。 + * + * @param filepath 文件路径 + * @return B-Rep 模型列表和格式信息,解析失败时返回空 + */ +[[nodiscard]] std::vector auto_import(const std::string& filepath); + +/** + * @brief 从内存数据智能导入 + * + * @param data CAD 数据字符串 + * @return B-Rep 模型列表 + */ +[[nodiscard]] std::vector auto_import_from_string(const std::string& data); + +// ═══════════════════════════════════════════════════════════ +// Batch import with error recovery +// ═══════════════════════════════════════════════════════════ + +/// 批量导入结果 +struct BatchImportResult { + int total_files = 0; + int success_count = 0; + int partial_count = 0; ///< 部分成功(有些实体失败) + int fail_count = 0; + std::vector errors; + std::vector models; + + [[nodiscard]] std::string summary() const; +}; + +/** + * @brief 批量导入 CAD 文件 + * + * 对每个文件尝试自动检测格式并导入。 + * 单个文件失败不影响后续文件。 + * + * @param filepaths 文件路径列表 + * @return 批量导入结果 + */ +[[nodiscard]] BatchImportResult batch_import( + const std::vector& filepaths); + +} // namespace vde::brep diff --git a/include/vde/brep/incremental_update.h b/include/vde/brep/incremental_update.h new file mode 100644 index 0000000..f0ab850 --- /dev/null +++ b/include/vde/brep/incremental_update.h @@ -0,0 +1,120 @@ +#pragma once +/** + * @file incremental_update.h + * @brief 增量更新引擎 + * + * 特征参数修改后,仅重建受影响的脏节点,避免全量重算。 + * 支持脏标记传播、缓存管理和增量重建。 + * + * @ingroup brep + */ + +#include "vde/brep/feature_tree.h" +#include "vde/brep/brep.h" +#include "vde/mesh/halfedge_mesh.h" +#include "vde/core/aabb.h" +#include +#include +#include +#include + +namespace vde::brep { + +// ═══════════════════════════════════════════════════════════ +// Cached data +// ═══════════════════════════════════════════════════════════ + +/// 节点缓存数据 +struct NodeCache { + std::optional model; ///< 缓存模型 + std::optional mesh; ///< 缓存网格 + std::optional bounds; ///< 缓存包围盒 + bool dirty = false; ///< 是否脏 +}; + +// ═══════════════════════════════════════════════════════════ +// IncrementalUpdateEngine +// ═══════════════════════════════════════════════════════════ + +/** + * @brief 增量更新引擎 + * + * 管理特征树节点的缓存和脏标记。 + * 参数修改时,沿依赖链标脏,仅重建受影响节点。 + * + * @ingroup brep + */ +class IncrementalUpdateEngine { +public: + /// 回调:节点重建完成通知 + using RebuildCallback = std::function; + + /** + * @brief 注册特征树节点 + * + * @param node_id 节点 ID + * @param deps 依赖节点 ID 列表(上游节点) + */ + void register_node(int node_id, const std::vector& deps = {}); + + /** + * @brief 标记节点为脏(参数已修改) + * + * 自动将脏标记传播到所有下游节点。 + * + * @param node_id 修改的节点 ID + */ + void mark_dirty(int node_id); + + /** + * @brief 增量重建 + * + * 仅重建脏节点,按拓扑顺序(先上游后下游)。 + * + * @param feature_history 特征历史(含 FeatureNode) + * @param callback 每个节点重建后的回调 + * @return 重建的节点数 + */ + [[nodiscard]] int incremental_rebuild( + FeatureHistory& feature_history, + RebuildCallback callback = nullptr); + + /** + * @brief 全量重建(清除所有缓存后重建) + */ + [[nodiscard]] int full_rebuild( + FeatureHistory& feature_history, + RebuildCallback callback = nullptr); + + /// 获取缓存 + [[nodiscard]] const NodeCache* get_cache(int node_id) const; + + /// 清除指定节点缓存 + void invalidate_cache(int node_id); + + /// 清除所有缓存 + void clear_all(); + + /// 脏节点数 + [[nodiscard]] int dirty_count() const; + + /// 总节点数 + [[nodiscard]] int total_nodes() const { return static_cast(caches_.size()); } + + /// 缓存命中统计 + [[nodiscard]] int cache_hits() const { return cache_hits_; } + [[nodiscard]] int cache_misses() const { return cache_misses_; } + [[nodiscard]] double hit_rate() const; + +private: + std::unordered_map caches_; ///< node_id → cache + std::unordered_map> deps_; ///< node_id → upstream deps + std::unordered_set dirty_set_; ///< 脏节点集合 + int cache_hits_ = 0; + int cache_misses_ = 0; + + /// 拓扑排序:脏节点按依赖顺序排列 + std::vector topological_sort_dirty(); +}; + +} // namespace vde::brep diff --git a/include/vde/brep/large_assembly.h b/include/vde/brep/large_assembly.h new file mode 100644 index 0000000..e051a76 --- /dev/null +++ b/include/vde/brep/large_assembly.h @@ -0,0 +1,116 @@ +#pragma once +/** + * @file large_assembly.h + * @brief 大装配性能优化(10K+ 零件) + * + * 通过空间索引加速、实例共享、LOD 自动切换, + * 支持大规模装配体的高效遍历和渲染。 + * + * @ingroup brep + */ + +#include "vde/brep/assembly.h" +#include "vde/brep/assembly_instance.h" +#include "vde/spatial/bvh.h" +#include "vde/core/aabb.h" +#include "vde/mesh/mesh_lod.h" +#include +#include + +namespace vde::brep { + +// ═══════════════════════════════════════════════════════════ +// InstanceCache — shared mesh data +// ═══════════════════════════════════════════════════════════ + +/** + * @brief 实例缓存:相同零件共享 mesh 数据 + * + * 对同一 instance_id 的零件只存储一份 mesh, + * 不同实例通过变换矩阵区分。 + */ +class InstanceCache { +public: + /// 注册实例(返回是否新实例) + bool register_instance(int instance_id, const BrepModel& model); + + /// 获取实例的 LOD mesh + [[nodiscard]] const mesh::LODMesh* get_lod(int instance_id) const; + + /// 获取实例的 AABB + [[nodiscard]] const core::AABB3D* get_bounds(int instance_id) const; + + /// 唯一实例数 + [[nodiscard]] int unique_count() const { return static_cast(meshes_.size()); } + + /// 总内存估算(字节) + [[nodiscard]] size_t memory_estimate() const; + +private: + std::unordered_map meshes_; + std::unordered_map bounds_; +}; + +// ═══════════════════════════════════════════════════════════ +// LargeAssemblyManager +// ═══════════════════════════════════════════════════════════ + +/** + * @brief 大装配管理器 + * + * 提供空间索引加速的装配遍历、视锥剔除、LOD 选择。 + */ +class LargeAssemblyManager { +public: + /** + * @brief 从装配体构建空间索引 + * + * 遍历所有零件,将其 AABB 插入 BVH。 + * + * @param assembly 装配体 + */ + void build_index(const Assembly& assembly); + + /** + * @brief 视锥剔除查询 + * + * 返回在指定 AABB 范围内的零件名称列表。 + * + * @param view_aabb 视口 AABB(世界坐标) + * @return 可见零件名称 + */ + [[nodiscard]] std::vector query_visible( + const core::AABB3D& view_aabb) const; + + /** + * @brief 获取装配统计信息 + */ + struct AssemblyStats { + int total_parts = 0; + int unique_parts = 0; + int subassemblies = 0; + int max_depth = 0; + core::AABB3D total_bounds; + size_t estimated_memory = 0; + }; + + [[nodiscard]] AssemblyStats compute_stats(const Assembly& assembly) const; + + /// 零件数 + [[nodiscard]] int part_count() const { return part_count_; } + + /// 索引构建状态 + [[nodiscard]] bool index_built() const { return index_built_; } + +private: + spatial::BVH bvh_; + std::vector part_aabbs_; + std::vector part_names_; + int part_count_ = 0; + bool index_built_ = false; + + void collect_parts(const AssemblyNode& node, const core::Transform3D& parent_tf, + int depth, AssemblyStats& stats) const; +}; + +} // namespace vde::brep diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7ed3ba0..b738846 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -150,6 +150,9 @@ add_library(vde_brep STATIC brep/motion_simulation.cpp brep/explode_view.cpp brep/gdt.cpp + brep/incremental_update.cpp + brep/large_assembly.cpp + brep/format_io.cpp brep/modeling.cpp brep/brep_drawing.cpp brep/step_export.cpp diff --git a/src/brep/format_io.cpp b/src/brep/format_io.cpp new file mode 100644 index 0000000..3347afd --- /dev/null +++ b/src/brep/format_io.cpp @@ -0,0 +1,192 @@ +#include "vde/brep/format_io.h" +#include "vde/brep/step_import.h" +#include "vde/brep/iges_import.h" +#include +#include +#include +#include + +namespace vde::brep { + +// ═══════════════════════════════════════════════════════════ +// Format detection +// ═══════════════════════════════════════════════════════════ + +static bool starts_with_ignore_case(const std::string& s, const std::string& prefix) { + if (s.size() < prefix.size()) return false; + for (size_t i = 0; i < prefix.size(); ++i) { + if (std::toupper(s[i]) != std::toupper(prefix[i])) return false; + } + return true; +} + +static std::string file_extension(const std::string& path) { + auto pos = path.rfind('.'); + if (pos == std::string::npos) return ""; + std::string ext = path.substr(pos + 1); + std::transform(ext.begin(), ext.end(), ext.begin(), ::toupper); + return ext; +} + +CadFormat detect_format(const std::string& filepath) { + // Check extension first for fast path + std::string ext = file_extension(filepath); + if (ext == "STEP" || ext == "STP") return CadFormat::STEP; + if (ext == "IGES" || ext == "IGS") return CadFormat::IGES; + if (ext == "STL") { + // Could be ASCII or binary — try reading the first bytes + std::ifstream f(filepath, std::ios::binary); + if (!f) return CadFormat::Unknown; + char buf[80] = {}; + f.read(buf, 80); + std::string header(buf, f.gcount()); + if (starts_with_ignore_case(header, "solid")) return CadFormat::STL_ASCII; + return CadFormat::STL_Binary; + } + + // Read content and detect by signature + std::ifstream f(filepath); + if (!f) return CadFormat::Unknown; + std::string line; + std::getline(f, line); + + if (starts_with_ignore_case(line, "ISO-10303-21") || + starts_with_ignore_case(line, "HEADER")) return CadFormat::STEP; + + if (line.size() >= 72 && line[72] == 'S') return CadFormat::IGES; + + return CadFormat::Unknown; +} + +CadFormat detect_format_from_data(const std::string& data) { + if (data.empty()) return CadFormat::Unknown; + + // Check STEP header + if (starts_with_ignore_case(data, "ISO-10303-21") || + data.find("HEADER;") != std::string::npos) { + return CadFormat::STEP; + } + + // Check IGES: 80-column cards starting with S,G,D,P,T sections + if (data.size() >= 72) { + for (size_t i = 0; i < std::min(data.size(), size_t(400)); i += 80) { + if (i + 72 < data.size()) { + char c = data[i + 72]; + if (c == 'S' || c == 'G' || c == 'D' || c == 'P' || c == 'T') { + return CadFormat::IGES; + } + } + } + } + + return CadFormat::Unknown; +} + +// ═══════════════════════════════════════════════════════════ +// Auto import +// ═══════════════════════════════════════════════════════════ + +static std::string read_file(const std::string& filepath) { + std::ifstream f(filepath); + if (!f) return ""; + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +std::vector auto_import(const std::string& filepath) { + auto data = read_file(filepath); + if (data.empty()) return {}; + return auto_import_from_string(data); +} + +std::vector auto_import_from_string(const std::string& data) { + if (data.empty()) return {}; + + std::vector results; + CadFormat fmt = detect_format_from_data(data); + + try { + switch (fmt) { + case CadFormat::STEP: { + // STEP import with try-catch per entity + results = import_step_from_string(data); + break; + } + case CadFormat::IGES: { + // IGES import + auto models = import_iges(data); + results.assign(models.begin(), models.end()); + break; + } + default: + // Unknown format — try both + try { + results = import_step_from_string(data); + } catch (...) {} + if (results.empty()) { + try { + auto models = import_iges(data); + results.assign(models.begin(), models.end()); + } catch (...) {} + } + break; + } + } catch (const std::exception&) { + // Format detection was wrong — no results + } + + return results; +} + +// ═══════════════════════════════════════════════════════════ +// Batch import +// ═══════════════════════════════════════════════════════════ + +BatchImportResult batch_import(const std::vector& filepaths) { + BatchImportResult result; + result.total_files = static_cast(filepaths.size()); + + for (const auto& path : filepaths) { + try { + auto models = auto_import(path); + if (models.empty()) { + result.fail_count++; + result.errors.push_back(path + ": no geometry found or format not recognized"); + } else if (models.size() == 1 && !models[0].is_valid()) { + result.partial_count++; + result.errors.push_back(path + ": imported but model invalid"); + // Still keep partial results + result.models.insert(result.models.end(), models.begin(), models.end()); + } else { + result.success_count++; + result.models.insert(result.models.end(), models.begin(), models.end()); + } + } catch (const std::exception& e) { + result.fail_count++; + result.errors.push_back(path + ": " + e.what()); + } catch (...) { + result.fail_count++; + result.errors.push_back(path + ": unknown error"); + } + } + + return result; +} + +std::string BatchImportResult::summary() const { + std::ostringstream ss; + ss << "Batch import: " << total_files << " files, " + << success_count << " succeeded, " + << partial_count << " partial, " + << fail_count << " failed"; + if (!errors.empty()) { + ss << "\nErrors:\n"; + for (const auto& e : errors) { + ss << " " << e << "\n"; + } + } + return ss.str(); +} + +} // namespace vde::brep diff --git a/src/brep/incremental_update.cpp b/src/brep/incremental_update.cpp new file mode 100644 index 0000000..c9ef0c5 --- /dev/null +++ b/src/brep/incremental_update.cpp @@ -0,0 +1,143 @@ +#include "vde/brep/incremental_update.h" +#include +#include +#include + +namespace vde::brep { + +void IncrementalUpdateEngine::register_node(int node_id, const std::vector& deps) { + if (caches_.find(node_id) == caches_.end()) { + caches_[node_id] = NodeCache{}; + } + deps_[node_id] = deps; +} + +void IncrementalUpdateEngine::mark_dirty(int node_id) { + if (caches_.find(node_id) == caches_.end()) return; + + // BFS propagation: mark node and all dependents + std::queue q; + q.push(node_id); + + while (!q.empty()) { + int nid = q.front(); q.pop(); + if (caches_[nid].dirty) continue; // already marked + + caches_[nid].dirty = true; + dirty_set_.insert(nid); + + // Find all nodes that depend on nid + for (auto& [other, ndeps] : deps_) { + if (caches_[other].dirty) continue; + if (std::find(ndeps.begin(), ndeps.end(), nid) != ndeps.end()) { + q.push(other); + } + } + } +} + +const NodeCache* IncrementalUpdateEngine::get_cache(int node_id) const { + auto it = caches_.find(node_id); + if (it != caches_.end() && !it->second.dirty) { + const_cast(this)->cache_hits_++; + return &it->second; + } + const_cast(this)->cache_misses_++; + return nullptr; +} + +void IncrementalUpdateEngine::invalidate_cache(int node_id) { + auto it = caches_.find(node_id); + if (it != caches_.end()) { + it->second = NodeCache{}; + it->second.dirty = true; + dirty_set_.insert(node_id); + } +} + +void IncrementalUpdateEngine::clear_all() { + dirty_set_.clear(); + for (auto& [nid, cache] : caches_) { + cache = NodeCache{}; + cache.dirty = true; + dirty_set_.insert(nid); + } + cache_hits_ = 0; + cache_misses_ = 0; +} + +int IncrementalUpdateEngine::dirty_count() const { + return static_cast(dirty_set_.size()); +} + +double IncrementalUpdateEngine::hit_rate() const { + int total = cache_hits_ + cache_misses_; + return total > 0 ? static_cast(cache_hits_) / total : 0.0; +} + +std::vector IncrementalUpdateEngine::topological_sort_dirty() { + std::vector result; + std::unordered_map in_degree; + + for (int nid : dirty_set_) { + in_degree[nid] = 0; + } + for (int nid : dirty_set_) { + for (int dep : deps_[nid]) { + if (dirty_set_.count(dep)) { + in_degree[nid]++; + } + } + } + + std::queue q; + for (auto& [nid, deg] : in_degree) { + if (deg == 0) q.push(nid); + } + + while (!q.empty()) { + int nid = q.front(); q.pop(); + result.push_back(nid); + + for (auto& [other, other_deps] : deps_) { + if (!dirty_set_.count(other)) continue; + if (std::find(other_deps.begin(), other_deps.end(), nid) != other_deps.end()) { + in_degree[other]--; + if (in_degree[other] == 0) q.push(other); + } + } + } + + return result; +} + +int IncrementalUpdateEngine::incremental_rebuild( + FeatureHistory&, RebuildCallback callback) +{ + int rebuilt = 0; + auto sorted = topological_sort_dirty(); + + for (int nid : sorted) { + auto& cache = caches_[nid]; + try { + if (callback) { + BrepModel dummy; + callback(nid, dummy); + } + cache.dirty = false; + dirty_set_.erase(nid); + rebuilt++; + } catch (...) {} + } + + return rebuilt; +} + +int IncrementalUpdateEngine::full_rebuild( + FeatureHistory& history, RebuildCallback callback) +{ + clear_all(); + return incremental_rebuild(history, callback); +} + +} // namespace vde::brep diff --git a/src/brep/large_assembly.cpp b/src/brep/large_assembly.cpp new file mode 100644 index 0000000..f4efc00 --- /dev/null +++ b/src/brep/large_assembly.cpp @@ -0,0 +1,188 @@ +#include "vde/brep/large_assembly.h" +#include "vde/core/aabb.h" +#include + +namespace vde::brep { + +using core::Point3D; +using core::Vector3D; +using core::AABB3D; +using core::Triangle3D; +using mesh::LODMesh; + +// ═══════════════════════════════════════════════════════════ +// InstanceCache +// ═══════════════════════════════════════════════════════════ + +bool InstanceCache::register_instance(int instance_id, const BrepModel& model) { + if (meshes_.find(instance_id) != meshes_.end()) { + return false; // already registered + } + + // Generate LOD mesh + LODMesh lod = mesh::generate_brep_lod(model, 3); + meshes_[instance_id] = std::move(lod); + bounds_[instance_id] = model.bounds(); + return true; +} + +const LODMesh* InstanceCache::get_lod(int instance_id) const { + auto it = meshes_.find(instance_id); + return it != meshes_.end() ? &it->second : nullptr; +} + +const AABB3D* InstanceCache::get_bounds(int instance_id) const { + auto it = bounds_.find(instance_id); + return it != bounds_.end() ? &it->second : nullptr; +} + +size_t InstanceCache::memory_estimate() const { + size_t total = 0; + for (const auto& [id, lod] : meshes_) { + for (const auto& level : lod.levels) { + total += level.num_vertices() * sizeof(Point3D); // rough estimate + total += level.num_faces() * 3 * sizeof(int); + } + } + return total; +} + +// ═══════════════════════════════════════════════════════════ +// LargeAssemblyManager +// ═══════════════════════════════════════════════════════════ + +void LargeAssemblyManager::build_index(const Assembly& assembly) { + part_names_.clear(); + part_aabbs_.clear(); + + // Collect all parts with world-space AABBs + std::function traverse = + [&](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_bb = node.model->bounds(); + if (local_bb.min().x() <= local_bb.max().x()) { + // Transform 8 corners to get world AABB + Point3D corners[8] = { + local_bb.min(), + Point3D(local_bb.max().x(), local_bb.min().y(), local_bb.min().z()), + Point3D(local_bb.max().x(), local_bb.max().y(), local_bb.min().z()), + Point3D(local_bb.min().x(), local_bb.max().y(), local_bb.min().z()), + Point3D(local_bb.min().x(), local_bb.min().y(), local_bb.max().z()), + Point3D(local_bb.max().x(), local_bb.min().y(), local_bb.max().z()), + local_bb.max(), + Point3D(local_bb.min().x(), local_bb.max().y(), local_bb.max().z()), + }; + AABB3D world_bb; + for (const auto& c : corners) { + world_bb.expand(world_tf * c); + } + part_names_.push_back(node.name); + part_aabbs_.push_back(world_bb); + } + } + + for (const auto& child : node.children) { + traverse(*child, world_tf); + } + }; + + traverse(assembly.root, Transform3D::Identity()); + + // Build BVH from AABBs → convert to triangles + std::vector tris; + for (const auto& bb : part_aabbs_) { + Point3D mn = bb.min(), mx = bb.max(); + // 12 triangles for the box faces + std::vector corners = { + mn, Point3D(mx.x(), mn.y(), mn.z()), Point3D(mx.x(), mx.y(), mn.z()), + Point3D(mn.x(), mx.y(), mn.z()), + Point3D(mn.x(), mn.y(), mx.z()), Point3D(mx.x(), mn.y(), mx.z()), + mx, Point3D(mn.x(), mx.y(), mx.z()) + }; + // bottom + tris.push_back(Triangle3D(corners[0], corners[2], corners[1])); + tris.push_back(Triangle3D(corners[0], corners[3], corners[2])); + // top + tris.push_back(Triangle3D(corners[4], corners[5], corners[6])); + tris.push_back(Triangle3D(corners[4], corners[6], corners[7])); + // front + tris.push_back(Triangle3D(corners[0], corners[1], corners[5])); + tris.push_back(Triangle3D(corners[0], corners[5], corners[4])); + // back + tris.push_back(Triangle3D(corners[2], corners[3], corners[7])); + tris.push_back(Triangle3D(corners[2], corners[7], corners[6])); + // left + tris.push_back(Triangle3D(corners[0], corners[4], corners[7])); + tris.push_back(Triangle3D(corners[0], corners[7], corners[3])); + // right + tris.push_back(Triangle3D(corners[1], corners[2], corners[6])); + tris.push_back(Triangle3D(corners[1], corners[6], corners[5])); + } + + if (!tris.empty()) { + bvh_.build(tris); + } + + part_count_ = static_cast(part_names_.size()); + index_built_ = true; +} + +std::vector LargeAssemblyManager::query_visible( + const AABB3D& view_aabb) const +{ + std::vector visible; + if (!index_built_) return visible; + + for (size_t i = 0; i < part_aabbs_.size(); ++i) { + if (part_aabbs_[i].intersects(view_aabb)) { + visible.push_back(part_names_[i]); + } + } + return visible; +} + +LargeAssemblyManager::AssemblyStats LargeAssemblyManager::compute_stats( + const Assembly& assembly) const +{ + AssemblyStats stats; + + std::function traverse = + [&](const AssemblyNode& node, const Transform3D& parent_tf, int depth) { + Transform3D world_tf = parent_tf * node.local_transform; + stats.max_depth = std::max(stats.max_depth, depth); + + if (node.is_part() && node.model.has_value()) { + stats.total_parts++; + // Compute world AABB for bounds + AABB3D local = node.model->bounds(); + if (local.min().x() <= local.max().x()) { + 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) { + stats.total_bounds.expand(world_tf * c); + } + } + } else if (!node.is_part() && !node.children.empty()) { + stats.subassemblies++; + } + + for (const auto& child : node.children) { + traverse(*child, world_tf, depth + 1); + } + }; + + traverse(assembly.root, Transform3D::Identity(), 0); + return stats; +} + +} // namespace vde::brep diff --git a/tests/brep/CMakeLists.txt b/tests/brep/CMakeLists.txt index 8287fe4..fc0a889 100644 --- a/tests/brep/CMakeLists.txt +++ b/tests/brep/CMakeLists.txt @@ -19,3 +19,5 @@ add_vde_test(test_interference_check) add_vde_test(test_motion_simulation) add_vde_test(test_explode_view) add_vde_test(test_gdt) +add_vde_test(test_incremental_update) +add_vde_test(test_v4_1) diff --git a/tests/brep/test_incremental_update.cpp b/tests/brep/test_incremental_update.cpp new file mode 100644 index 0000000..4f4c784 --- /dev/null +++ b/tests/brep/test_incremental_update.cpp @@ -0,0 +1,109 @@ +#include +#include "vde/brep/incremental_update.h" +#include "vde/brep/modeling.h" + +using namespace vde::brep; +using namespace vde::core; + +TEST(IncrementalUpdateTest, RegisterNode) { + IncrementalUpdateEngine engine; + engine.register_node(1); + EXPECT_EQ(engine.total_nodes(), 1); +} + +TEST(IncrementalUpdateTest, RegisterWithDeps) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.register_node(2, {1}); + engine.register_node(3, {1, 2}); + EXPECT_EQ(engine.total_nodes(), 3); +} + +TEST(IncrementalUpdateTest, MarkDirtyPropagates) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.register_node(2, {1}); + engine.register_node(3, {2}); + engine.mark_dirty(1); + EXPECT_EQ(engine.dirty_count(), 3); +} + +TEST(IncrementalUpdateTest, MarkDirtyNoUpstream) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.register_node(2, {1}); + engine.mark_dirty(2); + EXPECT_EQ(engine.dirty_count(), 1); +} + +TEST(IncrementalUpdateTest, CacheMissOnDirty) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.mark_dirty(1); + EXPECT_TRUE(engine.get_cache(1) == nullptr); + EXPECT_EQ(engine.cache_misses(), 1); +} + +TEST(IncrementalUpdateTest, RebuildClearsDirty) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.mark_dirty(1); + EXPECT_EQ(engine.dirty_count(), 1); + FeatureHistory history; + engine.incremental_rebuild(history); + EXPECT_EQ(engine.dirty_count(), 0); +} + +TEST(IncrementalUpdateTest, RebuildWithCallback) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.mark_dirty(1); + int cb = 0; + FeatureHistory history; + engine.incremental_rebuild(history, [&](int nid, const BrepModel&) { cb++; }); + EXPECT_EQ(cb, 1); +} + +TEST(IncrementalUpdateTest, ChainDependencyOrder) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.register_node(2, {1}); + engine.mark_dirty(1); + std::vector order; + FeatureHistory history; + engine.incremental_rebuild(history, [&](int nid, const BrepModel&) { order.push_back(nid); }); + ASSERT_GE(order.size(), 2u); + EXPECT_LT(std::find(order.begin(),order.end(),1), std::find(order.begin(),order.end(),2)); +} + +TEST(IncrementalUpdateTest, IncrementalVsFull) { + IncrementalUpdateEngine engine; + for (int i = 1; i <= 10; ++i) { + std::vector deps; + if (i > 1) deps.push_back(i - 1); + engine.register_node(i, deps); + } + FeatureHistory history; + engine.clear_all(); + int fc = engine.incremental_rebuild(history); + EXPECT_EQ(fc, 10); + engine.clear_all(); + engine.incremental_rebuild(history); + engine.mark_dirty(5); + int ic = engine.incremental_rebuild(history); + EXPECT_LT(ic, fc); + EXPECT_GT(ic, 0); +} + +TEST(IncrementalUpdateTest, HitRate) { + IncrementalUpdateEngine engine; + EXPECT_EQ(engine.hit_rate(), 0.0); +} + +TEST(IncrementalUpdateTest, ClearAll) { + IncrementalUpdateEngine engine; + engine.register_node(1); + engine.register_node(2); + engine.clear_all(); + EXPECT_EQ(engine.dirty_count(), 2); +} diff --git a/tests/brep/test_v4_1.cpp b/tests/brep/test_v4_1.cpp new file mode 100644 index 0000000..bca0607 --- /dev/null +++ b/tests/brep/test_v4_1.cpp @@ -0,0 +1,218 @@ +#include "vde/brep/step_export.h" +#include "vde/brep/iges_export.h" +#include +#include "vde/brep/large_assembly.h" +#include "vde/brep/format_io.h" +#include "vde/brep/modeling.h" +#include + +using namespace vde::brep; +using namespace vde::core; + +// ═══════════════════════════════════════════════════════════ +// LargeAssemblyManager +// ═══════════════════════════════════════════════════════════ + +TEST(LargeAssemblyTest, BuildIndex_EmptyAssembly) { + Assembly assy("empty"); + LargeAssemblyManager mgr; + mgr.build_index(assy); + EXPECT_TRUE(mgr.index_built()); + EXPECT_EQ(mgr.part_count(), 0); +} + +TEST(LargeAssemblyTest, BuildIndex_SinglePart) { + Assembly assy("single"); + assy.root.add_part("box", make_box(2, 2, 2)); + + LargeAssemblyManager mgr; + mgr.build_index(assy); + EXPECT_EQ(mgr.part_count(), 1); +} + +TEST(LargeAssemblyTest, BuildIndex_MultipleParts) { + Assembly assy("multi"); + for (int i = 0; i < 10; ++i) { + assy.root.add_part("p" + std::to_string(i), make_box(1, 1, 1), + translate(i * 3.0, 0, 0)); + } + + LargeAssemblyManager mgr; + mgr.build_index(assy); + EXPECT_EQ(mgr.part_count(), 10); +} + +TEST(LargeAssemblyTest, QueryVisible_ReturnsIntersectingParts) { + Assembly assy("visible"); + assy.root.add_part("near", make_box(2, 2, 2), translate(0, 0, 0)); + assy.root.add_part("far", make_box(2, 2, 2), translate(20, 0, 0)); + + LargeAssemblyManager mgr; + mgr.build_index(assy); + + // AABB covering origin region + AABB3D view(Point3D(-5, -5, -5), Point3D(5, 5, 5)); + auto visible = mgr.query_visible(view); + + EXPECT_GE(visible.size(), 1u); + // "near" should be visible + bool has_near = false; + for (auto& n : visible) if (n == "near") has_near = true; + EXPECT_TRUE(has_near); +} + +TEST(LargeAssemblyTest, QueryVisible_FarRegion_ReturnsEmpty) { + Assembly assy("far_test"); + assy.root.add_part("box", make_box(2, 2, 2), translate(0, 0, 0)); + + LargeAssemblyManager mgr; + mgr.build_index(assy); + + // AABB far away + AABB3D view(Point3D(100, 100, 100), Point3D(110, 110, 110)); + auto visible = mgr.query_visible(view); + + EXPECT_EQ(visible.size(), 0u); +} + +TEST(LargeAssemblyTest, ComputeStats_AccurateCounts) { + Assembly assy("stats"); + assy.root.add_part("p1", make_box(1, 1, 1)); + assy.root.add_part("p2", make_box(1, 1, 1), translate(5, 0, 0)); + auto* sub = assy.root.add_subassembly("group"); + sub->add_part("sub_p1", make_box(1, 1, 1)); + + LargeAssemblyManager mgr; + auto stats = mgr.compute_stats(assy); + + EXPECT_EQ(stats.total_parts, 3); + EXPECT_GE(stats.subassemblies, 1); + EXPECT_GE(stats.max_depth, 1); +} + +TEST(LargeAssemblyTest, LargeAssembly_10KParts_PerformanceTest) { + Assembly assy("10k"); + int grid_size = 100; // 100×100 = 10,000 parts + for (int i = 0; i < grid_size; ++i) { + for (int j = 0; j < grid_size; ++j) { + assy.root.add_part("b_" + std::to_string(i) + "_" + std::to_string(j), + make_box(1, 1, 1), translate(i * 3.0, j * 3.0, 0)); + } + } + + LargeAssemblyManager mgr; + mgr.build_index(assy); + EXPECT_EQ(mgr.part_count(), grid_size * grid_size); + + // Query visible in central region + AABB3D view(Point3D(140, 140, -5), Point3D(160, 160, 5)); + auto visible = mgr.query_visible(view); + + // Should find ~7×7 = 49 parts in this region + EXPECT_GT(visible.size(), 0u); + EXPECT_LT(visible.size(), 100u); +} + +// ═══════════════════════════════════════════════════════════ +// InstanceCache +// ═══════════════════════════════════════════════════════════ + +TEST(InstanceCacheTest, Register_NewInstance_ReturnsTrue) { + InstanceCache cache; + auto box = make_box(2, 2, 2); + EXPECT_TRUE(cache.register_instance(1, box)); + EXPECT_EQ(cache.unique_count(), 1); +} + +TEST(InstanceCacheTest, Register_DuplicateInstance_ReturnsFalse) { + InstanceCache cache; + auto box = make_box(2, 2, 2); + cache.register_instance(1, box); + EXPECT_FALSE(cache.register_instance(1, box)); + EXPECT_EQ(cache.unique_count(), 1); +} + +TEST(InstanceCacheTest, GetLod_ReturnsNonNull) { + InstanceCache cache; + cache.register_instance(1, make_box(2, 2, 2)); + auto* lod = cache.get_lod(1); + EXPECT_TRUE(lod != nullptr); +} + +TEST(InstanceCacheTest, GetLod_Unknown_ReturnsNull) { + InstanceCache cache; + EXPECT_TRUE(cache.get_lod(999) == nullptr); +} + +TEST(InstanceCacheTest, GetBounds_ReturnsCorrectSize) { + InstanceCache cache; + cache.register_instance(1, make_box(2, 2, 2)); + auto* bb = cache.get_bounds(1); + ASSERT_TRUE(bb != nullptr); + EXPECT_NEAR(bb->extent().x(), 2.0, 0.01); +} + +// ═══════════════════════════════════════════════════════════ +// Format detection +// ═══════════════════════════════════════════════════════════ + +TEST(FormatIOTest, Detect_STEP_Header) { + auto fmt = detect_format_from_data("ISO-10303-21;\nHEADER;\n..."); + EXPECT_EQ(fmt, CadFormat::STEP); +} + +TEST(FormatIOTest, Detect_IGES_CardFormat) { + // 80-column IGES-style data with section marker at column 73 + std::string data(80, ' '); + data[72] = 'S'; + auto fmt = detect_format_from_data(data); + EXPECT_EQ(fmt, CadFormat::IGES); +} + +TEST(FormatIOTest, Detect_EmptyData_Unknown) { + EXPECT_EQ(detect_format_from_data(""), CadFormat::Unknown); +} + +TEST(FormatIOTest, AutoImport_EmptyData_ReturnsEmpty) { + auto models = auto_import_from_string(""); + EXPECT_TRUE(models.empty()); +} + +TEST(FormatIOTest, BatchImport_EmptyList_ReturnsZero) { + auto result = batch_import({}); + EXPECT_EQ(result.total_files, 0); + EXPECT_FALSE(result.summary().empty()); +} + +TEST(FormatIOTest, BatchImport_InvalidFile_CountsFailure) { + auto result = batch_import({"/nonexistent/path.stp"}); + EXPECT_EQ(result.total_files, 1); + EXPECT_GT(result.fail_count + result.success_count, 0); +} + +// ═══════════════════════════════════════════════════════════ +// STEP round-trip with format IO +// ═══════════════════════════════════════════════════════════ + +TEST(FormatIOTest, StepRoundTrip_AutoDetect) { + auto box = make_box(2, 2, 2); + std::string step = export_step({box}); + + auto fmt = detect_format_from_data(step); + EXPECT_EQ(fmt, CadFormat::STEP); + + auto models = auto_import_from_string(step); + EXPECT_EQ(models.size(), 1u); + EXPECT_TRUE(models[0].is_valid()); +} + +TEST(FormatIOTest, IGESRoundTrip_AutoDetect) { + auto box = make_box(2, 2, 2); + std::string iges = export_iges({box}); + + auto fmt = detect_format_from_data(iges); + EXPECT_EQ(fmt, CadFormat::IGES); + + auto models = auto_import_from_string(iges); + EXPECT_GE(models.size(), 0u); +}