diff --git a/app/CentralRenderArea.qml b/app/CentralRenderArea.qml index 5cc6df724d0fc63e2690fd046dff7cd154634a9c..153a5a2a4075b742042af678daf2fe965fd46c44 100644 --- a/app/CentralRenderArea.qml +++ b/app/CentralRenderArea.qml @@ -15,6 +15,10 @@ Page { property bool faceSelectByAngle: false property real faceSelectAngle: 30.0 + // 当前渲染区域的拓扑诊断开关和二面角筛选范围。 + property real dihedralMinimumAngle: 0.0 + property real dihedralMaximumAngle: 150.0 + // 标记由功能自动开启的属性渲染,避免影响用户手动打开的属性渲染面板。 property bool featureAttributeRenderingActive: false property int featureAttributeRenderingComponentId: -1 @@ -201,7 +205,7 @@ Page { Layout.fillHeight: true onClicked: meshMenu.open() - Timer { id: meshSubCloseTimer; interval: meshBtn.subMenuCloseDelay; onTriggered: { meshFaceMenu.close(); meshTransMenu.close(); meshWireMenu.close() } } + Timer { id: meshSubCloseTimer; interval: meshBtn.subMenuCloseDelay; onTriggered: { meshFaceMenu.close(); meshTransMenu.close(); meshWireMenu.close(); meshTopologyMenu.close()} } Menu { id: meshMenu @@ -211,6 +215,7 @@ Page { meshFaceMenu.close() meshTransMenu.close() meshWireMenu.close() + meshTopologyMenu.close() } MenuItem { @@ -221,6 +226,7 @@ Page { meshSubCloseTimer.stop() meshTransMenu.close() meshWireMenu.close() + meshTopologyMenu.close() meshFaceMenu.popup(meshBtn, meshMenu.width, meshMenu.y + meshMenu.topPadding) } else { meshSubCloseTimer.restart() @@ -236,6 +242,7 @@ Page { meshSubCloseTimer.stop() meshFaceMenu.close() meshWireMenu.close() + meshTopologyMenu.close() meshTransMenu.popup(meshBtn, meshMenu.width, meshMenu.y + meshMenu.topPadding + meshBtn.menuItemHeight) } else { meshSubCloseTimer.restart() @@ -251,6 +258,7 @@ Page { meshSubCloseTimer.stop() meshFaceMenu.close() meshTransMenu.close() + meshTopologyMenu.close() meshWireMenu.popup(meshBtn, meshMenu.width, meshMenu.y + meshMenu.topPadding + 2 * meshBtn.menuItemHeight) } else { meshSubCloseTimer.restart() @@ -258,6 +266,24 @@ Page { } onTriggered: meshWireMenu.popup(meshBtn, meshMenu.width, meshMenu.y + meshMenu.topPadding + 2 * meshBtn.menuItemHeight) } + MenuItem { + id: meshTopologyCat + text: "拓扑诊断 ▶" + onHoveredChanged: { + if (hovered) { + meshSubCloseTimer.stop() + meshFaceMenu.close() + meshTransMenu.close() + meshWireMenu.close() + meshTopologyMenu.popup(meshBtn, meshMenu.width, + meshMenu.y + meshMenu.topPadding + 3 * meshBtn.menuItemHeight) + } else { + meshSubCloseTimer.restart() + } + } + onTriggered: meshTopologyMenu.popup(meshBtn, meshMenu.width, + meshMenu.y + meshMenu.topPadding + 3 * meshBtn.menuItemHeight) + } MenuItem { text: "隐" checkable: true @@ -324,6 +350,95 @@ Page { onTriggered: myItem.setMeshStyle(6) } } + Menu { + id: meshTopologyMenu + + MenuItem { + text: "边界边" + checkable: true + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + onToggled: myItem.setTopologyDiagnosticVisible(0, checked) + } + MenuItem { + text: "边界面" + checkable: true + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + onToggled: myItem.setTopologyDiagnosticVisible(1, checked) + } + MenuItem { + text: "非流形边" + checkable: true + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + onToggled: myItem.setTopologyDiagnosticVisible(2, checked) + } + MenuItem { + text: "非流形点" + checkable: true + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + onToggled: myItem.setTopologyDiagnosticVisible(3, checked) + } + MenuItem { + text: "孤立边" + checkable: true + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + onToggled: myItem.setTopologyDiagnosticVisible(4, checked) + } + MenuItem { + text: "孤立点" + checkable: true + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + onToggled: myItem.setTopologyDiagnosticVisible(5, checked) + } + MenuSeparator {} + MenuItem { + text: "二面角边" + checkable: true + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + onToggled: myItem.setTopologyDiagnosticVisible(6, checked) + } + MenuItem { + id: minimumDihedralItem + text: "最小二面角" + contentItem: RowLayout { + Label { text: minimumDihedralItem.text; Layout.fillWidth: true } + SpinBox { + from: 0 + to: 180 + editable: true + value: root.dihedralMinimumAngle + onValueModified: { + root.dihedralMinimumAngle = value + if (root.dihedralMaximumAngle < value) + root.dihedralMaximumAngle = value + myItem.setDihedralAngleRange(root.dihedralMinimumAngle, + root.dihedralMaximumAngle) + } + } + } + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + } + MenuItem { + id: maximumDihedralItem + text: "最大二面角" + contentItem: RowLayout { + Label { text: maximumDihedralItem.text; Layout.fillWidth: true } + SpinBox { + from: 0 + to: 180 + editable: true + value: root.dihedralMaximumAngle + onValueModified: { + root.dihedralMaximumAngle = value + if (root.dihedralMinimumAngle > value) + root.dihedralMinimumAngle = value + myItem.setDihedralAngleRange(root.dihedralMinimumAngle, + root.dihedralMaximumAngle) + } + } + } + onHoveredChanged: { if (hovered) meshSubCloseTimer.stop() } + } + } } ToolButton { diff --git a/app/model/QModelQuery.cpp b/app/model/QModelQuery.cpp index 244d61d668a20a61c52ec35c1c930c0a80d0fa12..1618eaed5a7b941a164e467f6d8a61b0f4a3d562 100644 --- a/app/model/QModelQuery.cpp +++ b/app/model/QModelQuery.cpp @@ -1,4 +1,5 @@ #include "QModelQuery.h" +#include "MeshTopologyDiagnostics.h" #include "ModelLayer.h" #include "MeshData.h" #include "GeometryData.h" @@ -80,7 +81,8 @@ std::optional QModelQuery::getMeshData(Index model_id) md->face_vertices_, md->face_vertices_offset_, md->edge_vertices_, md->vertex_positions_, - md->vertex_attributes_,md->edge_attributes_,md->face_attributes_,md->solid_attributes_,{},-1 }; + md->vertex_attributes_,md->edge_attributes_,md->face_attributes_,md->solid_attributes_,{},-1, + std::make_shared(MeshTopologyDiagnostics::analyze(comp->mesh_adjacency, *md)) }; // 添加所有块 auto block_datas = std::make_shared(); @@ -123,7 +125,8 @@ std::optional QModelQuery::getMeshDataByComponent(Index component_i md->face_attributes_, md->solid_attributes_, {}, - component_id + component_id, + std::make_shared(MeshTopologyDiagnostics::analyze(comp->mesh_adjacency, *md)) }; auto block_datas = std::make_shared(); diff --git a/app/render/CMakeLists.txt b/app/render/CMakeLists.txt index 262acffa6aab4b9f48f6464e19a560e6677a8f99..88518091ed7841291f616a1ddf02407cba5d0110 100644 --- a/app/render/CMakeLists.txt +++ b/app/render/CMakeLists.txt @@ -18,6 +18,7 @@ add_library(vtkPart STATIC MeshActorManagerSelectOp.cpp GeometryActorManagerSelectOp.cpp InteractionService.cpp + TopologyDiagnosticActor.cpp renderStrategy/AttributeOperator.cpp renderStrategy/AttriRenderStrategyScalar.cpp renderStrategy/AttriRenderStrategyVector.cpp @@ -54,4 +55,4 @@ target_link_libraries(renderQml PRIVATE vtk_module_autoinit(TARGETS renderQml MODULES VTK::GUISupportQtQuick VTK::CommonColor VTK::RenderingAnnotation ) -add_subdirectory(test) \ No newline at end of file +add_subdirectory(test) diff --git a/app/render/MeshActor.cpp b/app/render/MeshActor.cpp index a3c53aa5d1bad7910d0b4b235165d2a90f1d4db0..f3769486c42bfbe72ee8fc041454c890ab7fb4e8 100644 --- a/app/render/MeshActor.cpp +++ b/app/render/MeshActor.cpp @@ -1,4 +1,5 @@ #include "MeshActor.h" +#include "TopologyDiagnosticActor.h" #include "renderStrategy/AttributeOperator.h" #include "Core.h" #include @@ -114,6 +115,7 @@ MeshActor::MeshActor(vtkRenderer* renderer) this->face_actor_->SetMapper(face_mapper_); this->edge_actor_->SetMapper(edge_mapper_); this->glyph3D_actor_->SetMapper(glyph3D_mapper_); + topology_diagnostics_ = std::make_unique(renderer_); } MeshActor::~MeshActor() @@ -252,6 +254,7 @@ void MeshActor::loadModelData(const MeshDataVtk& model_data) edge_mapper_->SetScalarVisibility(0); face_mapper_->SetScalarVisibility(0); solid_mapper_->SetScalarVisibility(0); + topology_diagnostics_->loadModelData(model_data); } void MeshActor::setVisibility(bool visibility) @@ -267,6 +270,7 @@ bool MeshActor::isVisible() const void MeshActor::setClipPlane(vtkPlane* plane) { + topology_diagnostics_->setClipPlane(plane); if (plane) { if (!clip_plane_) { solid_clipper_->SetInputData(this->solid_data_); @@ -301,6 +305,7 @@ MeshRenderStyle MeshActor::getRenderStyle() const void MeshActor::applyStyle() { + topology_diagnostics_->setMeshVisible(visibility_ && style_ != MeshRenderStyle::Hidden); if (style_ == MeshRenderStyle::Hidden || !visibility_) { solid_actor_->SetVisibility(false); face_actor_->SetVisibility(false); @@ -396,6 +401,11 @@ void MeshActor::applyStyle() } } +TopologyDiagnosticActor& MeshActor::topologyDiagnostics() +{ + return *topology_diagnostics_; +} + void MeshActor::_createSolidUGird(const MeshDataVtk& model_data, vtkPoints& points, vtkUnstructuredGrid& solid_data) { // cells diff --git a/app/render/MeshActor.h b/app/render/MeshActor.h index 7de099413f34efc57f0e811a7f5af42393b7ffbd..aab6eb9d35eaf043bfbfdeffb32161bc85709a5c 100644 --- a/app/render/MeshActor.h +++ b/app/render/MeshActor.h @@ -22,6 +22,7 @@ class vtkUnstructuredGrid; class vtkRenderer; class MeshActorSelectOp; class AttributeOperator; +class TopologyDiagnosticActor; // 保存面属性渲染临时修改 face_mapper_ 前的相对偏移参数 struct FaceAttributeOffsetState { @@ -72,6 +73,9 @@ public: const std::string& attr_name, std::map args); + /** @brief 获取本组件独立的拓扑诊断渲染对象 */ + TopologyDiagnosticActor& topologyDiagnostics(); + private: void applyStyle(); @@ -79,6 +83,7 @@ private: MeshRenderStyle style_ { MeshRenderStyle::FaceWithEdges }; bool visibility_ { true }; std::unique_ptr model_data_; + std::unique_ptr topology_diagnostics_; vtkPlane* clip_plane_ {}; vtkNew edge_clipper_; diff --git a/app/render/MeshActorManager.cpp b/app/render/MeshActorManager.cpp index 1f35a9888c38d5949fd2379be011e3807e82f00e..765df80701665aee37452af0fef82d47aca4006d 100644 --- a/app/render/MeshActorManager.cpp +++ b/app/render/MeshActorManager.cpp @@ -4,6 +4,7 @@ #include "renderStrategy/AttriRenderStrategyVector.h" #include "renderStrategy/AttriRenderStrategyUV.h" #include "renderStrategy/AttriRenderStrategyRGB.h" +#include "TopologyDiagnosticActor.h" #include #include #include @@ -72,6 +73,11 @@ void MeshActorManager::loadMesh(Index component_id, const MeshDataVtk& model_dat auto& actor = this->component_actors_[component_id]; actor->loadModelData(model_data); actor->setRenderStyle(current_style_); + for (size_t category = 0; category < topology_diagnostic_visibility_.size(); ++category) { + actor->topologyDiagnostics().setCategoryVisible( + static_cast(category), topology_diagnostic_visibility_[category]); + } + actor->topologyDiagnostics().setDihedralAngleRange(dihedral_minimum_, dihedral_maximum_); op_.registerProps(component_id, actor); } @@ -152,3 +158,22 @@ void MeshActorManager::cancelAttri(Index component_id) } } } + +void MeshActorManager::setTopologyDiagnosticVisible(int category, bool visible) +{ + if (category < 0 || category >= static_cast(topology_diagnostic_visibility_.size())) + return; + topology_diagnostic_visibility_[static_cast(category)] = visible; + for (auto& [id, actor] : component_actors_) { + actor->topologyDiagnostics().setCategoryVisible( + static_cast(category), visible); + } +} + +void MeshActorManager::setDihedralAngleRange(double minimum, double maximum) +{ + dihedral_minimum_ = minimum; + dihedral_maximum_ = maximum; + for (auto& [id, actor] : component_actors_) + actor->topologyDiagnostics().setDihedralAngleRange(minimum, maximum); +} diff --git a/app/render/MeshActorManager.h b/app/render/MeshActorManager.h index ac44352a20349d3b0e4bbc930147a84162d9ba03..b31e5e6d64ff3fcb5759492cbc9149451c418abf 100644 --- a/app/render/MeshActorManager.h +++ b/app/render/MeshActorManager.h @@ -3,7 +3,9 @@ #include "Core.h" #include "MeshActor.h" #include "MeshActorManagerSelectOp.h" +#include "TopologyDiagnosticCategory.h" #include +#include #include class vtkRenderer; @@ -32,6 +34,11 @@ public: std::map args); void cancelAttri(Index component_id); + /** @brief 设置一种拓扑诊断类别的窗口级显示状态 */ + void setTopologyDiagnosticVisible(int category, bool visible); + /** @brief 设置二面角诊断边的筛选范围,单位为度 */ + void setDihedralAngleRange(double minimum, double maximum); + MeshActorManagerSelectOp& op() { return op_; } const MeshActorManagerSelectOp& op() const { return op_; } @@ -40,6 +47,9 @@ private: std::unordered_map> component_actors_; vtkRenderer* renderer_ {}; MeshRenderStyle current_style_ { MeshRenderStyle::FaceWithEdges }; + std::array topology_diagnostic_visibility_ {}; + double dihedral_minimum_ { 30.0 }; + double dihedral_maximum_ { 180.0 }; // 渲染窗口共享的标量颜色表,同一时刻只显示当前标量属性的颜色和值域。 vtkNew scalar_bar_; diff --git a/app/render/MeshActorManagerSelectOp.cpp b/app/render/MeshActorManagerSelectOp.cpp index 85c17e54fd61c314a6e9c8f66d221cdbfe715e34..d51b2d817edcc713bc241147b44050d7d1dc19dc 100644 --- a/app/render/MeshActorManagerSelectOp.cpp +++ b/app/render/MeshActorManagerSelectOp.cpp @@ -67,19 +67,16 @@ void MeshActorManagerSelectOp::unregisterProps(std::shared_ptr actor) void MeshActorManagerSelectOp::addToAllLists(vtkProp* prop) { - for (auto& list : pick_lists_) - list->AddItem(prop); + for (auto& list : pick_lists_) { + if (!list->IsItemPresent(prop)) + list->AddItem(prop); + } } void MeshActorManagerSelectOp::removeFromAllLists(const std::set& props) { for (auto& list : pick_lists_) { - list->InitTraversal(); - vtkProp* prop; - while ((prop = list->GetNextProp()) != nullptr) { - if (props.count(prop)) { - list->RemoveItem(prop); - } - } + for (vtkProp* prop : props) + list->RemoveItem(prop); } } diff --git a/app/render/QRenderWindow.cpp b/app/render/QRenderWindow.cpp index c504452b6c065f6a9b1b3c153daf86f53fc8c0ff..825fe97c404c385e4952127c1ad56c376afc72d2 100644 --- a/app/render/QRenderWindow.cpp +++ b/app/render/QRenderWindow.cpp @@ -269,9 +269,28 @@ QQuickVTKItem::vtkUserData QRenderWindow::initializeVTK(vtkRenderWindow* renderW void QRenderWindow::destroyingVTK(vtkRenderWindow* renderWindow, vtkUserData userData) { auto* vtk = Data::SafeDownCast(userData); - if (vtk->renderer_) { - vtk->renderer_->RemoveAllViewProps(); + if (!vtk) + return; + + // 选择与交互服务持有 renderer、manager op 和 pick list,必须在 VTK Data 资源前释放。 + if (vtk->style_) { + vtk->style_->SetInteractionService(nullptr); + vtk->style_->SetSelectManager(nullptr); + } + interaction_service_.reset(); + select_manager_.reset(); + + // 裁剪回调保存 MeshActorManager 裸指针,先停用并移除回调,再销毁 manager。 + if (vtk->plane_widget_) { + vtk->plane_widget_->Off(); + vtk->plane_widget_->RemoveObservers(vtkCommand::InteractionEvent); } + vtk->geometry_actor_manager_.reset(); + vtk->mesh_actor_manager_.reset(); + + if (vtk->renderer_) + vtk->renderer_->RemoveAllViewProps(); + data_ = nullptr; } void QRenderWindow::resetCamera() @@ -676,6 +695,24 @@ int QRenderWindow::getMeshStyle() return static_cast(mesh_style_); } +void QRenderWindow::setTopologyDiagnosticVisible(int category, bool visible) +{ + dispatch_async([category, visible](vtkRenderWindow* renderWindow, vtkUserData userData) -> void { + Data* vtk = Data::SafeDownCast(userData); + if (vtk->mesh_actor_manager_) + vtk->mesh_actor_manager_->setTopologyDiagnosticVisible(category, visible); + }); +} + +void QRenderWindow::setDihedralAngleRange(double minimum, double maximum) +{ + dispatch_async([minimum, maximum](vtkRenderWindow* renderWindow, vtkUserData userData) -> void { + Data* vtk = Data::SafeDownCast(userData); + if (vtk->mesh_actor_manager_) + vtk->mesh_actor_manager_->setDihedralAngleRange(minimum, maximum); + }); +} + vtkStandardNewMacro(QRenderWindow::Data); void QRenderWindow::injectRenderRefreshCallback() diff --git a/app/render/QRenderWindow.h b/app/render/QRenderWindow.h index e9aec5dba2a4c4fc78973399427db3fb406bac5d..b133afe4ab1f6a66cca8c30b66e03a12254b029d 100644 --- a/app/render/QRenderWindow.h +++ b/app/render/QRenderWindow.h @@ -127,6 +127,16 @@ public: Q_INVOKABLE void setMeshStyle(int style); int getMeshStyle(); + /** + * @brief 显示或隐藏一种网格拓扑诊断实体 + * @param category 类别序号,依次为边界边、边界面、非流形边、非流形点、孤立边、孤立点、二面角边 + * @param visible 是否显示 + */ + Q_INVOKABLE void setTopologyDiagnosticVisible(int category, bool visible); + + /** @brief 设置二面角诊断边的角度范围,单位为度 */ + Q_INVOKABLE void setDihedralAngleRange(double minimum, double maximum); + Q_INVOKABLE void onModelChanged(Index model_id); Q_INVOKABLE void onComponentChanged(Index component_id); diff --git a/app/render/TopologyDiagnosticActor.cpp b/app/render/TopologyDiagnosticActor.cpp new file mode 100644 index 0000000000000000000000000000000000000000..3c535e4aeab6b6807212cd6ab905bc8304dfa360 --- /dev/null +++ b/app/render/TopologyDiagnosticActor.cpp @@ -0,0 +1,187 @@ +#include "TopologyDiagnosticActor.h" + +#include "CoincidentTopology.h" +#include "MeshTopologyDiagnostics.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +size_t categoryIndex(TopologyDiagnosticCategory category) +{ + return static_cast(category); +} + +//! @brief 由端点索引集合构造线数据,复用组件局部点坐标 +void buildEdgeData(vtkPolyData& output, vtkPoints& points, + const std::vector& edges) +{ + vtkNew lines; + for (const TopologyDiagnosticEdge& edge : edges) { + const vtkIdType ids[2] { edge.endpoints[0], edge.endpoints[1] }; + lines->InsertNextCell(2, ids); + } + output.SetPoints(&points); + output.SetLines(lines); + output.Modified(); +} + +//! @brief 由局部点索引集合构造顶点数据 +void buildPointData(vtkPolyData& output, vtkPoints& points, const std::vector& point_ids) +{ + vtkNew vertices; + for (Index point_id : point_ids) { + const vtkIdType id = point_id; + vertices->InsertNextCell(1, &id); + } + output.SetPoints(&points); + output.SetVerts(vertices); + output.Modified(); +} + +//! @brief 由面索引集合构造半透明边界面数据 +void buildFaceData(vtkPolyData& output, vtkPoints& points, + const MeshDataVtk& model_data, const std::vector& face_ids) +{ + vtkNew polygons; + for (Index face : face_ids) { + if (face < 0 || face + 1 >= static_cast(model_data.vtk_face_cells_offset_.size())) + continue; + const Index begin = model_data.vtk_face_cells_offset_[face]; + const Index end = model_data.vtk_face_cells_offset_[face + 1]; + if (end - begin < 3) + continue; + std::vector ids; + ids.reserve(static_cast(end - begin)); + for (Index i = begin; i < end; ++i) + ids.push_back(model_data.vtk_face_cells_[i]); + polygons->InsertNextCell(static_cast(ids.size()), ids.data()); + } + output.SetPoints(&points); + output.SetPolys(polygons); + output.Modified(); +} +} + +TopologyDiagnosticActor::TopologyDiagnosticActor(vtkRenderer* renderer) + : renderer_(renderer) +{ + if (!renderer_) + throw std::invalid_argument("TopologyDiagnosticActor: renderer cannot be null"); + + const std::array, category_count_> colors { { + { 0.1, 0.8, 0.2 }, // 边界边:绿色 + { 0.55, 1.0, 0.65 }, // 边界面:淡绿色 + { 1.0, 0.0, 0.0 }, // 非流形边:红色 + { 1.0, 0.0, 0.8 }, // 非流形点:品红色 + { 1.0, 0.5, 0.0 }, // 孤立边:橙色 + { 1.0, 0.5, 0.0 }, // 孤立点:橙色 + { 1.0, 0.9, 0.0 } // 二面角边:黄色 + } }; + for (size_t i = 0; i < category_count_; ++i) { + mappers_[i]->SetInputData(data_[i]); + mappers_[i]->SetScalarVisibility(false); + mappers_[i]->SetRelativeCoincidentTopologyLineOffsetParameters(0, highlight::LINE_UNITS); + mappers_[i]->SetRelativeCoincidentTopologyPointOffsetParameter(highlight::POINT_UNITS); + mappers_[i]->SetRelativeCoincidentTopologyPolygonOffsetParameters(0, highlight::POLYGON_UNITS - 1); + actors_[i]->SetMapper(mappers_[i]); + actors_[i]->GetProperty()->SetColor(colors[i][0], colors[i][1], colors[i][2]); + actors_[i]->GetProperty()->SetLineWidth(4.0); + actors_[i]->GetProperty()->SetPointSize(9.0); + actors_[i]->PickableOff(); + actors_[i]->SetVisibility(false); + renderer_->AddActor(actors_[i]); + } + actors_[categoryIndex(TopologyDiagnosticCategory::BoundaryFace)]->GetProperty()->SetOpacity(0.35); +} + +TopologyDiagnosticActor::~TopologyDiagnosticActor() +{ + if (!renderer_) + return; + for (auto& actor : actors_) + renderer_->RemoveActor(actor); +} + +void TopologyDiagnosticActor::loadModelData(const MeshDataVtk& model_data) +{ + diagnostics_ = model_data.topology_diagnostics_; + points_->Reset(); + for (const auto& position : model_data.vertex_positions_) + points_->InsertNextPoint(position.data()); + + if (!diagnostics_) { + for (auto& data : data_) + data->Initialize(); + applyVisibility(); + return; + } + + buildEdgeData(*data_[categoryIndex(TopologyDiagnosticCategory::BoundaryEdge)], *points_, diagnostics_->boundary_edges); + buildFaceData(*data_[categoryIndex(TopologyDiagnosticCategory::BoundaryFace)], *points_, model_data, diagnostics_->boundary_faces); + buildEdgeData(*data_[categoryIndex(TopologyDiagnosticCategory::NonManifoldEdge)], *points_, diagnostics_->non_manifold_edges); + buildPointData(*data_[categoryIndex(TopologyDiagnosticCategory::NonManifoldVertex)], *points_, diagnostics_->non_manifold_vertices); + buildEdgeData(*data_[categoryIndex(TopologyDiagnosticCategory::IsolatedEdge)], *points_, diagnostics_->isolated_edges); + buildPointData(*data_[categoryIndex(TopologyDiagnosticCategory::IsolatedVertex)], *points_, diagnostics_->isolated_vertices); + rebuildDihedralEdges(); + applyVisibility(); +} + +void TopologyDiagnosticActor::setCategoryVisible(TopologyDiagnosticCategory category, bool visible) +{ + category_visible_[categoryIndex(category)] = visible; + applyVisibility(); +} + +void TopologyDiagnosticActor::setMeshVisible(bool visible) +{ + mesh_visible_ = visible; + applyVisibility(); +} + +void TopologyDiagnosticActor::setClipPlane(vtkPlane* plane) +{ + for (size_t i = 0; i < category_count_; ++i) { + if (plane) { + // 与主网格面使用同一种提取裁剪管线,保证保留侧的深度和重合拓扑偏移一致。 + clippers_[i]->SetInputData(data_[i]); + clippers_[i]->SetImplicitFunction(plane); + mappers_[i]->SetInputConnection(clippers_[i]->GetOutputPort()); + } else { + mappers_[i]->SetInputData(data_[i]); + } + } +} + +void TopologyDiagnosticActor::setDihedralAngleRange(double minimum, double maximum) +{ + dihedral_minimum_ = std::clamp(minimum, 0.0, 180.0); + dihedral_maximum_ = std::clamp(maximum, dihedral_minimum_, 180.0); + rebuildDihedralEdges(); +} + +void TopologyDiagnosticActor::rebuildDihedralEdges() +{ + if (!diagnostics_) + return; + + std::vector filtered; + for (const auto& edge : diagnostics_->manifold_edges) { + if (edge.dihedral_angle_degrees >= dihedral_minimum_ + && edge.dihedral_angle_degrees <= dihedral_maximum_) { + filtered.push_back(edge); + } + } + buildEdgeData(*data_[categoryIndex(TopologyDiagnosticCategory::DihedralEdge)], *points_, filtered); +} + +void TopologyDiagnosticActor::applyVisibility() +{ + for (size_t i = 0; i < category_count_; ++i) + actors_[i]->SetVisibility(mesh_visible_ && category_visible_[i]); +} diff --git a/app/render/TopologyDiagnosticActor.h b/app/render/TopologyDiagnosticActor.h new file mode 100644 index 0000000000000000000000000000000000000000..2c24289e20feb0c9d3728a204d4496d8732bafbc --- /dev/null +++ b/app/render/TopologyDiagnosticActor.h @@ -0,0 +1,57 @@ +/** + * @file TopologyDiagnosticActor.h + * @brief 网格拓扑诊断结果的独立叠加渲染对象 + */ +#pragma once + +#include "Core.h" +#include "TopologyDiagnosticCategory.h" + +#include +#include +#include +#include +#include +#include +#include + +class vtkRenderer; +class vtkPlane; + +/** + * @brief 把拓扑诊断集合渲染为独立的边、点和面 Actor + */ +class TopologyDiagnosticActor { +public: + explicit TopologyDiagnosticActor(vtkRenderer* renderer); + ~TopologyDiagnosticActor(); + + /** @brief 根据最新网格和诊断结果重建叠加数据 */ + void loadModelData(const MeshDataVtk& model_data); + /** @brief 设置某一诊断类别是否显示 */ + void setCategoryVisible(TopologyDiagnosticCategory category, bool visible); + /** @brief 设置主网格是否可见,诊断层跟随组件可见性 */ + void setMeshVisible(bool visible); + /** @brief 设置与主网格一致的裁剪平面,传入 nullptr 时取消裁剪 */ + void setClipPlane(vtkPlane* plane); + /** @brief 设置二面角边筛选范围,单位为度 */ + void setDihedralAngleRange(double minimum, double maximum); + +private: + static constexpr std::size_t category_count_ = kTopologyDiagnosticCategoryCount; + + void rebuildDihedralEdges(); + void applyVisibility(); + + vtkRenderer* renderer_ {}; + bool mesh_visible_ { true }; + double dihedral_minimum_ { 30.0 }; + double dihedral_maximum_ { 180.0 }; + std::array category_visible_ {}; + std::array, category_count_> data_; + std::array, category_count_> clippers_; + std::array, category_count_> mappers_; + std::array, category_count_> actors_; + vtkNew points_; //> 当前组件诊断几何共用的点坐标 + std::shared_ptr diagnostics_; //> 二面角范围变化时重新筛选的只读结果 +}; diff --git a/app/render/TopologyDiagnosticCategory.h b/app/render/TopologyDiagnosticCategory.h new file mode 100644 index 0000000000000000000000000000000000000000..47d3bec5a1afe144616d3e21a613e838b76b6c07 --- /dev/null +++ b/app/render/TopologyDiagnosticCategory.h @@ -0,0 +1,25 @@ +/** + * @file TopologyDiagnosticCategory.h + * @brief 定义拓扑诊断可视化类别 + */ +#pragma once + +#include + +/** + * @brief 可独立显示的网格拓扑诊断类别 + */ +enum class TopologyDiagnosticCategory { + BoundaryEdge, + BoundaryFace, + NonManifoldEdge, + NonManifoldVertex, + IsolatedEdge, + IsolatedVertex, + DihedralEdge, + Count +}; + +//! @brief 拓扑诊断类别数量,由枚举末值统一推导 +inline constexpr std::size_t kTopologyDiagnosticCategoryCount + = static_cast(TopologyDiagnosticCategory::Count); diff --git a/core/Core.h b/core/Core.h index 0c8f17bb7c79e16e43bb889a223828fc63a5ca9b..120bdc655d34414f8a5023c8ad83d535dd7ae106 100644 --- a/core/Core.h +++ b/core/Core.h @@ -47,6 +47,27 @@ enum class SelectMode { */ using Index = int; +/** + * @brief 一条可直接用于诊断渲染的网格边 + */ +struct TopologyDiagnosticEdge { + std::array endpoints; //> 组件内局部点 id + double dihedral_angle_degrees { -1.0 }; //> 无有效二面角时为 -1 +}; + +/** + * @brief 保存一次网格拓扑诊断得到的特殊实体集合 + */ +struct MeshTopologyDiagnosticResult { + std::vector boundary_edges; + std::vector boundary_faces; + std::vector non_manifold_edges; + std::vector non_manifold_vertices; + std::vector isolated_edges; + std::vector isolated_vertices; + std::vector manifold_edges; //> 恰好邻接两个面的边,保存二面角供范围筛选 +}; + using GeomVertexId = int; using GeomEdgeId = int; using GeomFaceId = int; @@ -97,6 +118,8 @@ struct MeshDataVtk { Index component_id { -1 }; + std::shared_ptr topology_diagnostics_; //> 只读派生诊断结果,供渲染层叠加显示 + Index model_block_id(Index block_id) const { return this->model_blocks_->block_datas[block_id].id; diff --git a/model/data/CMakeLists.txt b/model/data/CMakeLists.txt index 626c4ba348b60e1b66eb45f11cd94ceb08de469e..5532ff7b0ba4161b6065a5048b88570cb02f6f80 100644 --- a/model/data/CMakeLists.txt +++ b/model/data/CMakeLists.txt @@ -10,6 +10,7 @@ add_library(Data STATIC MeshIDMap.cpp ComponentOperator.cpp MeshAdjacency.cpp + MeshTopologyDiagnostics.cpp ) target_include_directories(Data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} # 头文件路径 diff --git a/model/data/MeshAdjacency.cpp b/model/data/MeshAdjacency.cpp index 9cb818bef5027cd815d17f7c413a540a1ee97a60..3f7ed2df8a8cd3d5aa8b38d3454b7da8a6754190 100644 --- a/model/data/MeshAdjacency.cpp +++ b/model/data/MeshAdjacency.cpp @@ -117,6 +117,17 @@ Index MeshAdjacency::edgeCount(const MeshData& mesh) return static_cast(rows_.size()); } +std::vector MeshAdjacency::edgeTopologies(const MeshData& mesh) +{ + ensureBuilt(mesh); + std::vector result; + result.reserve(rows_.size()); + for (const EdgeRow& row : rows_) { + result.push_back({ row.endpoints, row.adjacent_faces }); + } + return result; +} + void MeshAdjacency::ensureEdgeGlobalIds(MeshIDMap& map, Index component_id, const MeshData& mesh) { ensureBuilt(mesh); @@ -240,6 +251,9 @@ void MeshAdjacency::ensureBuilt(const MeshData& mesh) continue; const Index row_id = resolve_row(p0, p1); face_edge_stable_ids_[begin + j] = rows_[row_id].stable_id; + auto& adjacent_faces = rows_[row_id].adjacent_faces; + if (adjacent_faces.empty() || adjacent_faces.back() != f) + adjacent_faces.push_back(f); } } } diff --git a/model/data/MeshAdjacency.h b/model/data/MeshAdjacency.h index 49bd1c4333348acdf2276d49966d035524b43db8..b82b6669c4b32f8363314274e3b60a8c60261a1e 100644 --- a/model/data/MeshAdjacency.h +++ b/model/data/MeshAdjacency.h @@ -51,6 +51,14 @@ public: EdgeHandle() = default; }; +/** + * @brief 统一边表中一条边的只读拓扑快照 + */ +struct MeshEdgeTopology { + std::array endpoints; //> 排序后的组件内局部点 id + std::vector adjacent_faces; //> 使用该边的面单元 id +}; + class MeshAdjacency { public: MeshAdjacency() = default; @@ -106,6 +114,12 @@ public: //! @brief 当轮边表行数(物化边 + 面边,重合归并后) Index edgeCount(const MeshData& mesh); + /** + * @brief 获取统一边表的只读拓扑快照 + * @return 每条当前有效边的端点、相邻面和物化状态,不暴露内部行号 + */ + std::vector edgeTopologies(const MeshData& mesh); + // —— 全局 id 同步与生命周期 —— /** @@ -130,6 +144,7 @@ private: std::array endpoints; //> 排序端点对(小在前),唯一键 Index cell_index { -1 }; //> 物化边在 edge_vertices_ 中的 cell 序号,-1 表示纯面边 Index stable_id { -1 }; //> 稳定局部边 id + std::vector adjacent_faces; //> 使用该边的面单元 id }; void ensureBuilt(const MeshData& mesh); diff --git a/model/data/MeshTopologyDiagnostics.cpp b/model/data/MeshTopologyDiagnostics.cpp new file mode 100644 index 0000000000000000000000000000000000000000..cbf9158b47325a6444603f0a9abed21b32640368 --- /dev/null +++ b/model/data/MeshTopologyDiagnostics.cpp @@ -0,0 +1,160 @@ +#include "MeshTopologyDiagnostics.h" + +#include "MeshData.h" +#include "MeshAdjacency.h" + +#include +#include +#include +#include + +namespace { +//! @brief 计算多边形前三个非共线顶点定义的单位法向 +std::array faceNormal(const MeshData& mesh, Index face) +{ + const Index begin = mesh.face_vertices_offset_[face]; + const Index end = mesh.face_vertices_offset_[face + 1]; + if (end - begin < 3) + return {}; + + const Index p0_id = mesh.face_vertices_[begin]; + if (p0_id < 0 || p0_id >= static_cast(mesh.vertex_positions_.size())) + return {}; + const auto& p0 = mesh.vertex_positions_[p0_id]; + for (Index i = begin + 1; i + 1 < end; ++i) { + const Index p1_id = mesh.face_vertices_[i]; + const Index p2_id = mesh.face_vertices_[i + 1]; + if (p1_id < 0 || p2_id < 0 + || p1_id >= static_cast(mesh.vertex_positions_.size()) + || p2_id >= static_cast(mesh.vertex_positions_.size())) { + continue; + } + const auto& p1 = mesh.vertex_positions_[p1_id]; + const auto& p2 = mesh.vertex_positions_[p2_id]; + const std::array a { p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2] }; + const std::array b { p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2] }; + std::array normal { + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + }; + const double length = std::sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]); + if (length > 0.0) { + for (double& value : normal) + value /= length; + return normal; + } + } + return {}; +} + +//! @brief 计算两个相邻面的内二面角:180°减去单位法向夹角,范围为 [0, 180] +double dihedralAngle(const std::array& first, const std::array& second) +{ + const double first_length = std::sqrt(first[0] * first[0] + first[1] * first[1] + first[2] * first[2]); + const double second_length = std::sqrt(second[0] * second[0] + second[1] * second[1] + second[2] * second[2]); + if (first_length == 0.0 || second_length == 0.0) + return -1.0; + + const double dot = std::clamp(first[0] * second[0] + first[1] * second[1] + first[2] * second[2], -1.0, 1.0); + constexpr double radians_to_degrees = 180.0 / 3.14159265358979323846; + const double normal_angle = std::acos(dot) * radians_to_degrees; + return 180.0 - normal_angle; +} + +//! @brief 判断一个点的一环面是否由一条连通扇或闭合环组成 +bool isNonManifoldVertex(const std::vector& incident_edges) +{ + std::unordered_map> face_graph; + Index boundary_edge_count = 0; + for (const MeshEdgeTopology& edge : incident_edges) { + // 将非流形边及其端点归入边类别,非流形点仅表示独立的一环面扇异常。 + if (edge.adjacent_faces.size() > 2) + return false; + if (edge.adjacent_faces.size() == 1) + ++boundary_edge_count; + if (edge.adjacent_faces.size() == 2) { + face_graph[edge.adjacent_faces[0]].push_back(edge.adjacent_faces[1]); + face_graph[edge.adjacent_faces[1]].push_back(edge.adjacent_faces[0]); + } else if (edge.adjacent_faces.size() == 1) { + face_graph.try_emplace(edge.adjacent_faces[0]); + } + } + if (face_graph.empty()) + return false; + + std::unordered_set visited; + std::vector pending { face_graph.begin()->first }; + while (!pending.empty()) { + const Index face = pending.back(); + pending.pop_back(); + if (!visited.insert(face).second) + continue; + for (Index neighbor : face_graph[face]) + pending.push_back(neighbor); + } + + // 流形内部点没有边界边;流形边界点恰有两条边界边。一环断裂或分叉均为非流形。 + return visited.size() != face_graph.size() || (boundary_edge_count != 0 && boundary_edge_count != 2); +} +} + +MeshTopologyDiagnosticResult MeshTopologyDiagnostics::analyze(MeshAdjacency& adjacency, const MeshData& mesh) +{ + MeshTopologyDiagnosticResult result; + const Index point_count = static_cast(mesh.vertex_positions_.size()); + std::vector point_used(static_cast(point_count), false); + + const Index face_count = mesh.face_vertices_offset_.empty() + ? 0 + : static_cast(mesh.face_vertices_offset_.size() - 1); + std::vector> normals(static_cast(face_count)); + std::vector boundary_faces(static_cast(face_count), false); + for (Index face = 0; face < face_count; ++face) { + const Index begin = mesh.face_vertices_offset_[face]; + const Index end = mesh.face_vertices_offset_[face + 1]; + if (begin < 0 || end > static_cast(mesh.face_vertices_.size()) || end - begin < 2) + continue; + normals[static_cast(face)] = faceNormal(mesh, face); + } + + const std::vector edges = adjacency.edgeTopologies(mesh); + std::vector> vertex_edges(static_cast(point_count)); + for (const MeshEdgeTopology& edge : edges) { + if (edge.endpoints[0] < 0 || edge.endpoints[1] < 0 + || edge.endpoints[0] >= point_count || edge.endpoints[1] >= point_count + || edge.endpoints[0] == edge.endpoints[1]) { + continue; + } + point_used[static_cast(edge.endpoints[0])] = true; + point_used[static_cast(edge.endpoints[1])] = true; + TopologyDiagnosticEdge diagnostic_edge { edge.endpoints, -1.0 }; + if (edge.adjacent_faces.empty()) { + result.isolated_edges.push_back(diagnostic_edge); + } else if (edge.adjacent_faces.size() == 1) { + result.boundary_edges.push_back(diagnostic_edge); + boundary_faces[static_cast(edge.adjacent_faces.front())] = true; + } else if (edge.adjacent_faces.size() == 2) { + diagnostic_edge.dihedral_angle_degrees = dihedralAngle( + normals[static_cast(edge.adjacent_faces[0])], normals[static_cast(edge.adjacent_faces[1])]); + result.manifold_edges.push_back(diagnostic_edge); + } else { + result.non_manifold_edges.push_back(diagnostic_edge); + } + vertex_edges[static_cast(edge.endpoints[0])].push_back(edge); + vertex_edges[static_cast(edge.endpoints[1])].push_back(edge); + } + + for (Index face = 0; face < face_count; ++face) { + if (boundary_faces[static_cast(face)]) + result.boundary_faces.push_back(face); + } + for (Index point = 0; point < point_count; ++point) { + if (!point_used[static_cast(point)]) { + result.isolated_vertices.push_back(point); + } else if (isNonManifoldVertex(vertex_edges[static_cast(point)])) { + result.non_manifold_vertices.push_back(point); + } + } + return result; +} diff --git a/model/data/MeshTopologyDiagnostics.h b/model/data/MeshTopologyDiagnostics.h new file mode 100644 index 0000000000000000000000000000000000000000..c8731455562fc78df76f2e73cf7f08fdee21036e --- /dev/null +++ b/model/data/MeshTopologyDiagnostics.h @@ -0,0 +1,23 @@ +/** + * @file MeshTopologyDiagnostics.h + * @brief 网格特殊拓扑实体与二面角边诊断 + */ +#pragma once + +#include "Core.h" + +struct MeshData; +class MeshAdjacency; + +/** + * @brief 计算网格特殊拓扑实体,结果仅为派生数据,不修改原网格 + */ +class MeshTopologyDiagnostics { +public: + /** + * @brief 对面网格和显式边执行拓扑诊断 + * @param mesh 待读取的网格数据 + * @return 可用于渲染的分类结果 + */ + static MeshTopologyDiagnosticResult analyze(MeshAdjacency& adjacency, const MeshData& mesh); +}; diff --git a/model/data/test/CMakeLists.txt b/model/data/test/CMakeLists.txt index 99d231abfa5e395313562b86fd91dd0beea4368a..276a82406218eb0dc33bce420e4ff633ddd1c6f9 100644 --- a/model/data/test/CMakeLists.txt +++ b/model/data/test/CMakeLists.txt @@ -26,8 +26,11 @@ precess_test_link_libraries(TestGeometryMeshMap Data) precess_add_test(TestMeshAdjacency TestMeshAdjacency.cpp) precess_test_link_libraries(TestMeshAdjacency Data) +precess_add_test(TestMeshTopologyDiagnostics TestMeshTopologyDiagnostics.cpp) +precess_test_link_libraries(TestMeshTopologyDiagnostics Data) + precess_add_test(TestComponentSnapshot TestComponentSnapshot.cpp) precess_test_link_libraries(TestComponentSnapshot DataTest Data) precess_add_test(TestDirtyNotify TestDirtyNotify.cpp) -precess_test_link_libraries(TestDirtyNotify Data) \ No newline at end of file +precess_test_link_libraries(TestDirtyNotify Data) diff --git a/model/data/test/TestMeshTopologyDiagnostics.cpp b/model/data/test/TestMeshTopologyDiagnostics.cpp new file mode 100644 index 0000000000000000000000000000000000000000..b268c821771eb490c226ffca74718a4d133aa228 --- /dev/null +++ b/model/data/test/TestMeshTopologyDiagnostics.cpp @@ -0,0 +1,98 @@ +#include "MeshTopologyDiagnostics.h" + +#include "MeshAdjacency.h" +#include "MeshData.h" + +#include +#include + +TEST_CASE("MeshTopologyDiagnostics classifies boundary and dihedral edges") +{ + MeshData mesh; + mesh.vertex_positions_ = { + { 0.0, 0.0, 0.0 }, { 1.0, 0.0, 0.0 }, + { 1.0, 1.0, 0.0 }, { 0.0, 1.0, 0.0 } + }; + mesh.face_vertices_ = { 0, 1, 2, 0, 2, 3 }; + mesh.face_vertices_offset_ = { 0, 3, 6 }; + + MeshAdjacency adjacency; + const MeshTopologyDiagnosticResult result = MeshTopologyDiagnostics::analyze(adjacency, mesh); + + REQUIRE(result.boundary_edges.size() == 4); + REQUIRE(result.boundary_faces.size() == 2); + REQUIRE(result.manifold_edges.size() == 1); + REQUIRE(result.manifold_edges.front().dihedral_angle_degrees == Catch::Approx(180.0)); + REQUIRE(result.non_manifold_edges.empty()); + REQUIRE(result.non_manifold_vertices.empty()); +} + +TEST_CASE("MeshTopologyDiagnostics computes a right interior dihedral angle") +{ + MeshData mesh; + mesh.vertex_positions_ = { + { 0.0, 0.0, 0.0 }, { 1.0, 0.0, 0.0 }, + { 0.0, 1.0, 0.0 }, { 0.0, 0.0, 1.0 } + }; + // 两个正交三角形共享边 (0,1),内二面角为 90°。 + mesh.face_vertices_ = { 0, 1, 2, 1, 0, 3 }; + mesh.face_vertices_offset_ = { 0, 3, 6 }; + + MeshAdjacency adjacency; + const MeshTopologyDiagnosticResult result = MeshTopologyDiagnostics::analyze(adjacency, mesh); + + REQUIRE(result.manifold_edges.size() == 1); + REQUIRE(result.manifold_edges.front().dihedral_angle_degrees == Catch::Approx(90.0)); +} + +TEST_CASE("MeshTopologyDiagnostics excludes non manifold edge endpoints from non manifold vertices") +{ + MeshData mesh; + mesh.vertex_positions_ = { + { 0.0, 0.0, 0.0 }, { 1.0, 0.0, 0.0 }, { 0.0, 1.0, 0.0 }, + { 0.0, 0.0, 1.0 }, { 0.0, -1.0, 0.0 } + }; + // 三个三角形共同使用边 (0,1)。 + mesh.face_vertices_ = { 0, 1, 2, 1, 0, 3, 0, 1, 4 }; + mesh.face_vertices_offset_ = { 0, 3, 6, 9 }; + + MeshAdjacency adjacency; + const MeshTopologyDiagnosticResult result = MeshTopologyDiagnostics::analyze(adjacency, mesh); + + REQUIRE(result.non_manifold_edges.size() == 1); + REQUIRE(result.non_manifold_vertices.empty()); +} + +TEST_CASE("MeshTopologyDiagnostics classifies isolated edge and vertex") +{ + MeshData mesh; + mesh.vertex_positions_ = { + { 0.0, 0.0, 0.0 }, { 1.0, 0.0, 0.0 }, { 2.0, 0.0, 0.0 } + }; + mesh.edge_vertices_ = { 0, 1 }; + mesh.face_vertices_offset_ = { 0 }; + + MeshAdjacency adjacency; + const MeshTopologyDiagnosticResult result = MeshTopologyDiagnostics::analyze(adjacency, mesh); + + REQUIRE(result.isolated_edges.size() == 1); + REQUIRE(result.isolated_vertices == std::vector { 2 }); +} + +TEST_CASE("MeshTopologyDiagnostics detects a disconnected vertex fan") +{ + MeshData mesh; + mesh.vertex_positions_ = { + { 0.0, 0.0, 0.0 }, { 1.0, 0.0, 0.0 }, { 0.0, 1.0, 0.0 }, + { -1.0, 0.0, 0.0 }, { 0.0, -1.0, 0.0 } + }; + // 两个三角形只共享点 0,其一环由两个互不连通的面扇组成。 + mesh.face_vertices_ = { 0, 1, 2, 0, 3, 4 }; + mesh.face_vertices_offset_ = { 0, 3, 6 }; + + MeshAdjacency adjacency; + const MeshTopologyDiagnosticResult result = MeshTopologyDiagnostics::analyze(adjacency, mesh); + + REQUIRE(result.non_manifold_edges.empty()); + REQUIRE(result.non_manifold_vertices == std::vector { 0 }); +}