From 2bcb7c175778017fa117d5cc6b382e828907d86e Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 25 Aug 2026 15:47:16 -0400 Subject: [PATCH] FILT: Add the Extract Triple Lines filter Extracts the triple lines of a multi-material surface mesh into a self-contained Edge Geometry. A mesh edge is a triple line segment when the unique Feature Ids across the Face Labels of the triangles sharing it number three or more. Feature Id -1 (outside the volume) counts only when Include Exterior Triple Lines is enabled, which turns interior-only extraction into one that also finds grain boundaries reaching the free surface. Because the lines are extracted from whatever Triangle Geometry is supplied, placing the filter after a smoothing step yields triple lines that lie exactly on the smoothed surface. An option on the surface meshers could not do that, since it can only see the mesh as generated. The created geometry carries a NumFeatures value per segment (3 for a triple line, 4 for a quadruple point line) and a Node Types array copied from the source mesh, so it can be consumed directly by filters that require one, such as Laplacian Smoothing. The shared implementation lives in MeshingUtilities::GenerateTripleLines so it is reachable from other plugins. Node Types is copied through only; it is never consulted to decide whether an edge is a triple line, and the header says so at the point a future reader would be tempted otherwise. Using the count of triangles sharing an edge was likewise considered and rejected: it cannot express the interior-only case, and it misreports non-manifold pinches as quadruple lines. Also removes 505 lines of dormant, unreachable triple line code from QuickSurfaceMesh. Both prior implementations sat behind a macro that is never defined and neither worked: one corrupted every vertex Y coordinate and wrote its two-component edge list at stride three, the other read Node Types from the Face Labels path and never set the edge list. Preflight rejects Face Labels or Node Types arrays whose tuple counts do not match the geometry's face and vertex counts, which would otherwise read past the end of an array during execute. Tests are built on hand-derived expected values rather than stored meshes: a flat boundary yields no triple lines, an interior junction yields one segment with three Features, a quadruple point yields four, and a four-grain block run through all three surface meshers yields consistent topology. Vertex compaction, the exterior toggle, and the Node Types pass-through are each covered. Signed-off-by: Michael Jackson --- CMakeLists.txt | 2 + src/Plugins/SimplnxCore/CMakeLists.txt | 1 + .../docs/ExtractTripleLinesFilter.md | 74 +++ .../Filters/Algorithms/QuickSurfaceMesh.cpp | 503 ------------------ .../Filters/Algorithms/QuickSurfaceMesh.hpp | 7 - .../Filters/ExtractTripleLinesFilter.cpp | 174 ++++++ .../Filters/ExtractTripleLinesFilter.hpp | 124 +++++ .../Filters/QuickSurfaceMeshFilter.cpp | 12 +- .../Filters/QuickSurfaceMeshFilter.hpp | 1 - .../SimplnxCore/Filters/SurfaceNetsFilter.cpp | 6 +- src/Plugins/SimplnxCore/test/CMakeLists.txt | 1 + .../test/ExtractTripleLinesTest.cpp | 381 +++++++++++++ .../test/M3CSurfaceMeshingTest.cpp | 23 + .../test/QuickSurfaceMeshFilterTest.cpp | 11 +- .../SimplnxCore/test/SurfaceNetsTest.cpp | 6 + .../Utilities/Meshing/TripleLineUtilities.cpp | 182 +++++++ .../Utilities/Meshing/TripleLineUtilities.hpp | 70 +++ test/CMakeLists.txt | 1 + test/TripleLineUtilitiesTest.cpp | 389 ++++++++++++++ .../simplnx/UnitTest/UnitTestCommon.hpp | 74 +++ 20 files changed, 1515 insertions(+), 527 deletions(-) create mode 100644 src/Plugins/SimplnxCore/docs/ExtractTripleLinesFilter.md create mode 100644 src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.cpp create mode 100644 src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.hpp create mode 100644 src/Plugins/SimplnxCore/test/ExtractTripleLinesTest.cpp create mode 100644 src/simplnx/Utilities/Meshing/TripleLineUtilities.cpp create mode 100644 src/simplnx/Utilities/Meshing/TripleLineUtilities.hpp create mode 100644 test/TripleLineUtilitiesTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a3b3828740..2f9336a52a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -608,6 +608,7 @@ set(SIMPLNX_HDRS ${SIMPLNX_SOURCE_DIR}/Utilities/Meshing/VertexUtilities.hpp ${SIMPLNX_SOURCE_DIR}/Utilities/Meshing/TriangleUtilities.hpp + ${SIMPLNX_SOURCE_DIR}/Utilities/Meshing/TripleLineUtilities.hpp ${SIMPLNX_SOURCE_DIR}/Utilities/Parsing/DREAM3D/Dream3dIO.hpp ${SIMPLNX_SOURCE_DIR}/Utilities/Parsing/DREAM3D/Dream3dPreflightCache.hpp @@ -807,6 +808,7 @@ set(SIMPLNX_SRCS ${SIMPLNX_SOURCE_DIR}/Utilities/Meshing/VertexUtilities.cpp ${SIMPLNX_SOURCE_DIR}/Utilities/Meshing/TriangleUtilities.cpp + ${SIMPLNX_SOURCE_DIR}/Utilities/Meshing/TripleLineUtilities.cpp ${SIMPLNX_SOURCE_DIR}/Utilities/Parsing/DREAM3D/Dream3dIO.cpp ${SIMPLNX_SOURCE_DIR}/Utilities/Parsing/DREAM3D/Dream3dPreflightCache.cpp diff --git a/src/Plugins/SimplnxCore/CMakeLists.txt b/src/Plugins/SimplnxCore/CMakeLists.txt index eb8909fa41..ba68a4f889 100644 --- a/src/Plugins/SimplnxCore/CMakeLists.txt +++ b/src/Plugins/SimplnxCore/CMakeLists.txt @@ -105,6 +105,7 @@ set(FilterList PadImageGeometryFilter PartitionGeometryFilter ExtractFeatureBoundaries2DFilter + ExtractTripleLinesFilter PointSampleEdgeGeometryFilter PointSampleTriangleGeometryFilter QuickSurfaceMeshFilter diff --git a/src/Plugins/SimplnxCore/docs/ExtractTripleLinesFilter.md b/src/Plugins/SimplnxCore/docs/ExtractTripleLinesFilter.md new file mode 100644 index 0000000000..5ffd2e0c9e --- /dev/null +++ b/src/Plugins/SimplnxCore/docs/ExtractTripleLinesFilter.md @@ -0,0 +1,74 @@ +# Extract Triple Lines + +## Group (Subgroup) + +Surface Meshing (Generation) + +## Description + +This **Filter** extracts the *triple lines* of a multi-material surface mesh into a new **Edge Geometry**. + +A mesh edge is a triple line segment when the triangles that share it border **three or more distinct +Feature Ids**. Where three grains meet, the segment borders three Feature Ids; where four meet, it +borders four and forms a *quadruple point line*. + +Because the lines are extracted from whatever **Triangle Geometry** is supplied, they always lie exactly +on that surface. This is the reason the extraction is a separate **Filter** rather than an option on the +surface meshing filters: placing it *after* a smoothing step means the triple lines follow the smoothed +surface. + +A typical pipeline is: + +1. **Quick Surface Mesh** (or **SurfaceNets**, or **M3C Surface Meshing**) +2. **Laplacian Smoothing** on the Triangle Geometry +3. **Extract Triple Lines** + +Running the extraction before smoothing instead simply yields the lines of the unsmoothed mesh. + +### Include Exterior Triple Lines + +The outside of the volume is represented in **Face Labels** by the value `-1`. *Include Exterior Triple +Lines* controls whether that counts as a distinct region. + +When disabled (the default), only interior triple lines are produced. When enabled, a grain boundary +that reaches the free surface of the volume also registers as a triple line, because the outside counts +as a third region there. Enabling it therefore produces noticeably more segments. + +### Created Outputs + +The created **Edge Geometry** contains: + +| Output | Description | +|--------|-------------| +| Shared Vertex List | Only the vertices lying on a triple line, copied from the source mesh | +| Shared Edge List | The triple line segments | +| Number of Features | Per segment: `3` for a triple line, `4` for a quadruple point line | +| Node Types | Per vertex, copied from the source mesh's **Node Types** | + +The vertex list is a compacted copy rather than a reference, so the **Edge Geometry** is self-contained: +smoothing or deleting the source **Triangle Geometry** afterwards cannot corrupt it. The trade-off is +that the triple lines will *not* follow the mesh if it is modified after extraction — re-run this +**Filter** to bring them back into alignment. + +**Node Types** is copied through so that the created **Edge Geometry** can be used directly by filters +that require a node type array, such as **Laplacian Smoothing**, which accepts Edge Geometries. + +## Notes + +The ordering of the created vertices and edges is deterministic for a given build but is not guaranteed +to be identical across platforms. Comparisons against a stored exemplar should sort the edges and +vertices first rather than relying on their order. + +% Auto generated parameter table will be inserted here + +## Example Pipelines + +## License & Copyright + +Please see the description file distributed with this **Plugin** + +## DREAM3D-NX Help + +If you need help, need to file a bug report or want to request a new feature, please head over to the +[DREAM3DNX-Issues](https://github.com/BlueQuartzSoftware/DREAM3DNX-Issues/discussions) GitHub site where +the community of DREAM3D-NX users can help answer your questions. diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp index c4f717cd56..04c695af55 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp @@ -2,15 +2,10 @@ #include "TupleTransfer.hpp" #include "simplnx/DataStructure/DataArray.hpp" -#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" #include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" #include "simplnx/Utilities/DataArrayUtilities.hpp" #include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" -#include "simplnx/Utilities/ParallelData3DAlgorithm.hpp" - -#include -#include using namespace nx::core; @@ -24,206 +19,6 @@ std::mt19937_64 k_Generator(k_RandomDevice()); // Standard mersenne_twister_engi std::mt19937_64::result_type k_Seed = 3412341234123412; std::uniform_real_distribution<> k_Distribution(k_RangeMin, k_RangeMax); -template -void hashCombine(size_t& seed, const T& obj) -{ - std::hash hasher; - seed ^= hasher(obj) + 0x9e3779b9 + (seed << 6) + (seed >> 2); -} - -// ----------------------------------------------------------------------------- -using VertexType = std::array; -using EdgeType = std::array; - -// ----------------------------------------------------------------------------- -struct VertexHasher -{ - size_t operator()(const VertexType& vert) const - { - size_t hash = std::hash()(vert[0]); - hashCombine(hash, vert[1]); - hashCombine(hash, vert[2]); - return hash; - } -}; - -// ----------------------------------------------------------------------------- -struct EdgeHasher -{ - size_t operator()(const EdgeType& edge) const - { - size_t hash = std::hash()(edge[0]); - hashCombine(hash, edge[1]); - return hash; - } -}; - -// ----------------------------------------------------------------------------- -using VertexMap = std::unordered_map; -using EdgeMap = std::unordered_map; - -// ----------------------------------------------------------------------------- -struct GenerateTripleLinesImpl -{ - using MeshIndexType = typename QuickSurfaceMesh::MeshIndexType; - - GenerateTripleLinesImpl(ImageGeom* imageGeom, Int32AbstractDataStore& featureIdsStore, VertexMap& vertexMapRef, EdgeMap& edgeMapRef) - : origin(imageGeom->getOrigin()) - , res(imageGeom->getSpacing()) - , featureIds(featureIdsStore) - , vertexMap(vertexMapRef) - , edgeMap(edgeMapRef) - { - SizeVec3 udims = imageGeom->getDimensions(); - - xP = udims[0]; - yP = udims[1]; - zP = udims[2]; - } - - void compute(usize zStart, usize zEnd, usize yStart, usize yEnd, usize xStart, usize xEnd) const - { - for(size_t k = zStart; k < zEnd; k++) - { - for(size_t j = yStart; j < yEnd; j++) - { - for(size_t i = xStart; i < xEnd; i++) - { - point = (k * xP * yP) + (j * xP) + i; - // Case 1 - neigh1 = point + 1; - neigh2 = point + (xP * yP) + 1; - neigh3 = point + (xP * yP); - - VertexType const p0 = {{origin[0] + static_cast(i) * res[0] + res[0], origin[1] + static_cast(j) * res[1] + res[1], origin[2] + static_cast(k) * res[2] + res[2]}}; - - VertexType const p1 = {{origin[0] + static_cast(i) * res[0] + res[0], origin[1] + static_cast(j) * res[1], origin[2] + static_cast(k) * res[2] + res[2]}}; - - VertexType const p2 = {{origin[0] + static_cast(i) * res[0], origin[1] + static_cast(j) * res[1] + res[1], origin[2] + static_cast(k) * res[2] + res[2]}}; - - VertexType const p3 = {{origin[0] + static_cast(i) * res[0] + res[0], origin[1] + static_cast(j) * res[1] + res[1], origin[2] + static_cast(k) * res[2]}}; - - uFeatures.clear(); - uFeatures.insert(featureIds[point]); - uFeatures.insert(featureIds[neigh1]); - uFeatures.insert(featureIds[neigh2]); - uFeatures.insert(featureIds[neigh3]); - - if(uFeatures.size() > 2) - { - auto iter = vertexMap.find(p0); - if(iter == vertexMap.end()) - { - vertexMap[p0] = vertCounter++; - } - iter = vertexMap.find(p1); - if(iter == vertexMap.end()) - { - vertexMap[p1] = vertCounter++; - } - MeshIndexType i0 = vertexMap[p0]; - MeshIndexType i1 = vertexMap[p1]; - - const EdgeType tmpEdge = {{i0, i1}}; - auto eiter = edgeMap.find(tmpEdge); - if(eiter == edgeMap.end()) - { - edgeMap[tmpEdge] = edgeCounter++; - } - } - - // Case 2 - neigh1 = point + xP; - neigh2 = point + (xP * yP) + xP; - neigh3 = point + (xP * yP); - - uFeatures.clear(); - uFeatures.insert(featureIds[point]); - uFeatures.insert(featureIds[neigh1]); - uFeatures.insert(featureIds[neigh2]); - uFeatures.insert(featureIds[neigh3]); - if(uFeatures.size() > 2) - { - auto iter = vertexMap.find(p0); - if(iter == vertexMap.end()) - { - vertexMap[p0] = vertCounter++; - } - iter = vertexMap.find(p2); - if(iter == vertexMap.end()) - { - vertexMap[p2] = vertCounter++; - } - - MeshIndexType i0 = vertexMap[p0]; - MeshIndexType i2 = vertexMap[p2]; - - const EdgeType tmpEdge = {{i0, i2}}; - auto eiter = edgeMap.find(tmpEdge); - if(eiter == edgeMap.end()) - { - edgeMap[tmpEdge] = edgeCounter++; - } - } - - // Case 3 - neigh1 = point + 1; - neigh2 = point + xP + 1; - neigh3 = point + +xP; - - uFeatures.clear(); - uFeatures.insert(featureIds[point]); - uFeatures.insert(featureIds[neigh1]); - uFeatures.insert(featureIds[neigh2]); - uFeatures.insert(featureIds[neigh3]); - if(uFeatures.size() > 2) - { - auto iter = vertexMap.find(p0); - if(iter == vertexMap.end()) - { - vertexMap[p0] = vertCounter++; - } - iter = vertexMap.find(p3); - if(iter == vertexMap.end()) - { - vertexMap[p3] = vertCounter++; - } - - MeshIndexType i0 = vertexMap[p0]; - MeshIndexType i3 = vertexMap[p3]; - - const EdgeType tmpEdge = {{i0, i3}}; - auto eiter = edgeMap.find(tmpEdge); - if(eiter == edgeMap.end()) - { - edgeMap[tmpEdge] = edgeCounter++; - } - } - } - } - } - } - - void operator()(const Range3D& range) const - { - compute(range[0], range[1], range[2], range[3], range[4], range[5]); - } - -private: - mutable MeshIndexType point = 0, neigh1 = 0, neigh2 = 0, neigh3 = 0; - mutable MeshIndexType vertCounter = 0; - mutable MeshIndexType edgeCounter = 0; - mutable MeshIndexType xP; - mutable MeshIndexType yP; - mutable MeshIndexType zP; - FloatVec3 origin; - FloatVec3 res; - Int32AbstractDataStore& featureIds; - VertexMap& vertexMap; - EdgeMap& edgeMap; - mutable std::set uFeatures; -}; - // ----------------------------------------------------------------------------- void GetGridCoordinates(const IGridGeometry* grid, size_t x, size_t y, size_t z, QuickSurfaceMesh::VertexStore& verts, IGeometry::MeshIndexType nodeIndex) { @@ -435,76 +230,6 @@ Result<> QuickSurfaceMesh::operator()() m_DataStructure.removeData(triangleGeom.getElementNeighborsId().value()); } -#ifdef QSM_CREATE_TRIPLE_LINES - if(m_InputValues->pGenerateTripleLines) - { - IGeometry::SharedTriList* triangle = triangleGeom.getFaces(); - IGeometry::SharedVertexList* vertices = triangleGeom.getVertices(); - - EdgeGeom* edgeGeom = EdgeGeom::Create(m_DataStructure, "[EdgeType Geometry]", parentGroupId); - edgeGeom->setVertices(*vertices); - - Int32Array& nodeTypes = m_DataStructure.getDataRefAs(m_InputValues->pFaceLabelsDataPath); - - MeshIndexType edgeCount = 0; - for(MeshIndexType i = 0; i < triangleCount; i++) - { - MeshIndexType n1 = (*triangle)[3 * i + 0]; - MeshIndexType n2 = (*triangle)[3 * i + 1]; - MeshIndexType n3 = (*triangle)[3 * i + 2]; - if(nodeTypes[n1] >= 3 && nodeTypes[n2] >= 3) - { - edgeCount++; - } - if(nodeTypes[n1] >= 3 && nodeTypes[n3] >= 3) - { - edgeCount++; - } - if(nodeTypes[n2] >= 3 && nodeTypes[n3] >= 3) - { - edgeCount++; - } - } - - std::string edgeGeometryName = "[EdgeType Geometry]"; - DataPath edgeGeometryDataPath = m_InputValues->pParentDataGroupPath.createChildPath(edgeGeometryName); - std::string sharedEdgeListName = "SharedEdgeList"; - size_t numEdges = edgeCount; - size_t numEdgeComps = 2; - IGeometry::SharedEdgeList* edges = - IGeometry::SharedEdgeList::CreateWithStore>(m_DataStructure, sharedEdgeListName, {numEdges}, {numEdgeComps}, m_DataStructure.getId(edgeGeometryDataPath)); - - edgeCount = 0; - for(MeshIndexType i = 0; i < triangleCount; i++) - { - MeshIndexType n1 = (*triangle)[3 * i + 0]; - MeshIndexType n2 = (*triangle)[3 * i + 1]; - MeshIndexType n3 = (*triangle)[3 * i + 2]; - if(nodeTypes[n1] >= 3 && nodeTypes[n2] >= 3) - { - (*edges)[2 * edgeCount] = n1; - (*edges)[2 * edgeCount + 1] = n2; - edgeCount++; - } - if(nodeTypes[n1] >= 3 && nodeTypes[n3] >= 3) - { - (*edges)[2 * edgeCount] = n1; - (*edges)[2 * edgeCount + 1] = n3; - edgeCount++; - } - if(nodeTypes[n2] >= 3 && nodeTypes[n3] >= 3) - { - (*edges)[2 * edgeCount] = n2; - (*edges)[2 * edgeCount + 1] = n3; - edgeCount++; - } - } - - // Now that we all of that out of the way, generate the triple lines - generateTripleLines(); - } -#endif - // Guarded on windingResult still being valid so a genuine winding-repair error is never discarded. if(m_InputValues->BoundingBoxSkinMode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly && windingResult.valid()) { @@ -1659,231 +1384,3 @@ void QuickSurfaceMesh::createNodesAndTriangles(std::vector& m_Nod } } } - -// ----------------------------------------------------------------------------- -void QuickSurfaceMesh::generateTripleLines() -{ - if(m_ShouldCancel) - { - return; - } - /** - * This is a bit of experimental code where we define a triple line as an edge - * that shares voxels with at least 3 unique Feature Ids. This is different - * than saying that an edge is part of a triple line if it's nodes are considered - * sharing at least 3 unique voxels. This code is not complete as it will only - * find "interior" triple lines and no lines on the surface. I am going to leave - * this bit of code in place for historical reasons so that we can refer to it - * later if needed. - * Mike Jackson, JULY 2018 - */ - m_MessageHandler(IFilter::Message::Type::Info, "Generating Triple Lines"); - - Int32AbstractDataStore& featureIds = m_DataStructure.getDataAs(m_InputValues->FeatureIdsArrayPath)->getDataStoreRef(); - - int32_t numFeatures = 0; - size_t numTuples = featureIds.getNumberOfTuples(); - for(size_t i = 0; i < numTuples; i++) - { - if(featureIds[i] > numFeatures) - { - numFeatures = featureIds[i]; - } - } - - std::vector featDims = {static_cast(numFeatures) + 1}; - - auto* imageGeom = m_DataStructure.getDataAs(m_InputValues->GridGeomDataPath); - - SizeVec3 udims = imageGeom->getDimensions(); - - MeshIndexType xP = udims[0]; - MeshIndexType yP = udims[1]; - MeshIndexType zP = udims[2]; - MeshIndexType point = 0, neigh1 = 0, neigh2 = 0, neigh3 = 0; - - std::set uFeatures; - - FloatVec3 origin = imageGeom->getOrigin(); - FloatVec3 res = imageGeom->getSpacing(); - - VertexMap vertexMap; - EdgeMap edgeMap; - MeshIndexType vertCounter = 0; - MeshIndexType edgeCounter = 0; - - // Cycle through again assigning coordinates to each node and assigning node numbers and feature labels to each triangle - ParallelData3DAlgorithm algorithm; - algorithm.setRange(Range3D(xP - 1, yP - 1, zP - 1)); - if(featureIds.getChunkShape().has_value()) - { - const auto chunkShape = featureIds.getChunkShape().value(); - algorithm.setChunkSize(Range3D(chunkShape[0], chunkShape[1], chunkShape[2])); - } - algorithm.setParallelizationEnabled(false); - algorithm.execute(GenerateTripleLinesImpl(imageGeom, featureIds, vertexMap, edgeMap)); - -#if QSM_CREATE_TRIPLE_LINES - for(size_t k = 0; k < zP - 1; k++) - { - for(size_t j = 0; j < yP - 1; j++) - { - for(size_t i = 0; i < xP - 1; i++) - { - point = (k * xP * yP) + (j * xP) + i; - // Case 1 - neigh1 = point + 1; - neigh2 = point + (xP * yP) + 1; - neigh3 = point + (xP * yP); - - VertexType p0 = {{origin[0] + static_cast(i) * res[0] + res[0], origin[1] + static_cast(j) * res[1] + res[1], origin[2] + static_cast(k) * res[2] + res[2]}}; - - VertexType p1 = {{origin[0] + static_cast(i) * res[0] + res[0], origin[1] + static_cast(j) * res[1], origin[2] + static_cast(k) * res[2] + res[2]}}; - - VertexType p2 = {{origin[0] + static_cast(i) * res[0], origin[1] + static_cast(j) * res[1] + res[1], origin[2] + static_cast(k) * res[2] + res[2]}}; - - VertexType p3 = {{origin[0] + static_cast(i) * res[0] + res[0], origin[1] + static_cast(j) * res[1] + res[1], origin[2] + static_cast(k) * res[2]}}; - - uFeatures.clear(); - uFeatures.insert(featureIds[point]); - uFeatures.insert(featureIds[neigh1]); - uFeatures.insert(featureIds[neigh2]); - uFeatures.insert(featureIds[neigh3]); - - if(uFeatures.size() > 2) - { - auto iter = vertexMap.find(p0); - if(iter == vertexMap.end()) - { - vertexMap[p0] = vertCounter++; - } - iter = vertexMap.find(p1); - if(iter == vertexMap.end()) - { - vertexMap[p1] = vertCounter++; - } - MeshIndexType i0 = vertexMap[p0]; - MeshIndexType i1 = vertexMap[p1]; - - EdgeType tmpEdge = {{i0, i1}}; - auto eiter = edgeMap.find(tmpEdge); - if(eiter == edgeMap.end()) - { - edgeMap[tmpEdge] = edgeCounter++; - } - } - - // Case 2 - neigh1 = point + xP; - neigh2 = point + (xP * yP) + xP; - neigh3 = point + (xP * yP); - - uFeatures.clear(); - uFeatures.insert(featureIds[point]); - uFeatures.insert(featureIds[neigh1]); - uFeatures.insert(featureIds[neigh2]); - uFeatures.insert(featureIds[neigh3]); - if(uFeatures.size() > 2) - { - auto iter = vertexMap.find(p0); - if(iter == vertexMap.end()) - { - vertexMap[p0] = vertCounter++; - } - iter = vertexMap.find(p2); - if(iter == vertexMap.end()) - { - vertexMap[p2] = vertCounter++; - } - - MeshIndexType i0 = vertexMap[p0]; - MeshIndexType i2 = vertexMap[p2]; - - EdgeType tmpEdge = {{i0, i2}}; - auto eiter = edgeMap.find(tmpEdge); - if(eiter == edgeMap.end()) - { - edgeMap[tmpEdge] = edgeCounter++; - } - } - - // Case 3 - neigh1 = point + 1; - neigh2 = point + xP + 1; - neigh3 = point + +xP; - - uFeatures.clear(); - uFeatures.insert(featureIds[point]); - uFeatures.insert(featureIds[neigh1]); - uFeatures.insert(featureIds[neigh2]); - uFeatures.insert(featureIds[neigh3]); - if(uFeatures.size() > 2) - { - auto iter = vertexMap.find(p0); - if(iter == vertexMap.end()) - { - vertexMap[p0] = vertCounter++; - } - iter = vertexMap.find(p3); - if(iter == vertexMap.end()) - { - vertexMap[p3] = vertCounter++; - } - - MeshIndexType i0 = vertexMap[p0]; - MeshIndexType i3 = vertexMap[p3]; - - EdgeType tmpEdge = {{i0, i3}}; - auto eiter = edgeMap.find(tmpEdge); - if(eiter == edgeMap.end()) - { - edgeMap[tmpEdge] = edgeCounter++; - } - } - } - } - } -#endif - - std::string edgeGeometryName = "[Edge Geometry]"; - - DataPath edgeGeometryDataPath({edgeGeometryName}); - std::string sharedVertListName = "SharedVertList"; - DataPath sharedVertListDataPath = edgeGeometryDataPath.createChildPath(sharedVertListName); - - EdgeGeom* tripleLineEdge = EdgeGeom::Create(m_DataStructure, edgeGeometryName); - size_t numVerts = vertexMap.size(); - size_t numComps = 3; - IGeometry::SharedVertexList* vertices = Float32Array::CreateWithStore>(m_DataStructure, sharedVertListName, {numVerts}, {numComps}, m_DataStructure.getId(edgeGeometryDataPath)); - auto& verticesRef = vertices->getDataStoreRef(); - - for(const auto& vert : vertexMap) - { - float v0 = vert.first[0]; - float v1 = vert.first[1]; - float v2 = vert.first[2]; - MeshIndexType idx = vert.second; - const MeshIndexType offset = idx * numComps; - verticesRef.setValue(offset + 0, v0); - verticesRef.setValue(+1, v1); - verticesRef.setValue(idx * numComps + 2, v2); - } - tripleLineEdge->setVertices(*vertices); - - std::string sharedEdgeListName = "SharedEdgeList"; - size_t numEdges = edgeMap.size(); - size_t numEdgeComps = 2; - IGeometry::SharedEdgeList* edges = - IGeometry::SharedEdgeList::CreateWithStore>(m_DataStructure, sharedEdgeListName, {numEdges}, {numEdgeComps}, m_DataStructure.getId(edgeGeometryDataPath)); - auto& edgesRef = edges->getDataStoreRef(); - - for(const auto& edge : edgeMap) - { - MeshIndexType i0 = edge.first[0]; - MeshIndexType i1 = edge.first[1]; - MeshIndexType idx = edge.second; - edgesRef.setValue(idx * numComps + 0, i0); - edgesRef.setValue(idx * numComps + 1, i1); - } - tripleLineEdge->setEdgeList(*edges); -} diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp index f46a98cda2..84fc243809 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp @@ -19,7 +19,6 @@ struct SIMPLNXCORE_EXPORT QuickSurfaceMeshInputValues { bool FixProblemVoxels; bool RepairTriangleWinding; - bool GenerateTripleLines; ChoicesParameter::ValueType BoundingBoxSkinMode; DataPath GridGeomDataPath; @@ -75,16 +74,10 @@ class SIMPLNXCORE_EXPORT QuickSurfaceMesh */ void createNodesAndTriangles(std::vector& m_NodeIds, MeshIndexType nodeCount, MeshIndexType triangleCount); - /** - * @brief generateTripleLines - */ - void generateTripleLines(); - private: DataStructure& m_DataStructure; const QuickSurfaceMeshInputValues* m_InputValues = nullptr; const std::atomic_bool& m_ShouldCancel; const IFilter::MessageHandler& m_MessageHandler; - bool m_GenerateTripleLines = false; }; } // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.cpp new file mode 100644 index 0000000000..8cab428ef1 --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.cpp @@ -0,0 +1,174 @@ +#include "ExtractTripleLinesFilter.hpp" + +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Filter/Actions/CreateArrayAction.hpp" +#include "simplnx/Filter/Actions/CreateGeometry1DAction.hpp" +#include "simplnx/Parameters/ArraySelectionParameter.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/DataGroupCreationParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/GeometrySelectionParameter.hpp" +#include "simplnx/Utilities/Meshing/TripleLineUtilities.hpp" + +using namespace nx::core; + +namespace nx::core +{ +//------------------------------------------------------------------------------ +std::string ExtractTripleLinesFilter::name() const +{ + return FilterTraits::name.str(); +} + +//------------------------------------------------------------------------------ +std::string ExtractTripleLinesFilter::className() const +{ + return FilterTraits::className; +} + +//------------------------------------------------------------------------------ +Uuid ExtractTripleLinesFilter::uuid() const +{ + return FilterTraits::uuid; +} + +//------------------------------------------------------------------------------ +std::string ExtractTripleLinesFilter::humanName() const +{ + return "Extract Triple Lines"; +} + +//------------------------------------------------------------------------------ +std::vector ExtractTripleLinesFilter::defaultTags() const +{ + return {className(), "Surface Meshing", "Triple Lines", "Edge Geometry", "Generation"}; +} + +//------------------------------------------------------------------------------ +Parameters ExtractTripleLinesFilter::parameters() const +{ + Parameters params; + + params.insertSeparator(Parameters::Separator{"Input Parameters"}); + params.insert(std::make_unique(k_IncludeExteriorTripleLines_Key, "Include Exterior Triple Lines", + "If true, the outside of the volume counts as a distinct region, so grain boundaries reaching the free surface register as triple lines", false)); + + params.insertSeparator(Parameters::Separator{"Required Input Triangle Geometry"}); + params.insert(std::make_unique(k_TriangleGeometryPath_Key, "Triangle Geometry", "The surface mesh from which to extract the triple lines", DataPath{}, + GeometrySelectionParameter::AllowedTypes{IGeometry::Type::Triangle})); + params.insert(std::make_unique(k_FaceLabelsArrayPath_Key, "Face Labels", "The Array specifying which Features are on either side of each Face in the Triangle Geometry", + DataPath{}, ArraySelectionParameter::AllowedTypes{DataType::int32}, ArraySelectionParameter::AllowedComponentShapes{{2}})); + params.insert(std::make_unique(k_NodeTypesArrayPath_Key, "Node Types", "The Array specifying the type of node in the Triangle Geometry. Copied onto the created vertices", + DataPath{}, ArraySelectionParameter::AllowedTypes{DataType::int8}, ArraySelectionParameter::AllowedComponentShapes{{1}})); + + params.insertSeparator(Parameters::Separator{"Output Triple Line Geometry"}); + params.insert(std::make_unique(k_CreatedTripleLineGeometryPath_Key, "Created Triple Line Geometry", "The name of the created Triple Line Edge Geometry", + DataPath({"Triple Lines"}))); + params.insert(std::make_unique(k_VertexDataGroupName_Key, "Vertex Data", "The name of the Attribute Matrix holding the Vertex Data of the Triple Line Geometry", + INodeGeometry0D::k_VertexAttributeMatrixName)); + params.insert(std::make_unique(k_EdgeDataGroupName_Key, "Edge Data", "The name of the Attribute Matrix holding the Edge Data of the Triple Line Geometry", + INodeGeometry1D::k_EdgeAttributeMatrixName)); + params.insert(std::make_unique(k_NumFeaturesArrayName_Key, "Number of Features", + "The name of the Array holding the number of unique Feature Ids bordering each triple line segment (3 or 4)", "NumFeatures")); + params.insert(std::make_unique(k_NodeTypesArrayName_Key, "Node Types", "The name of the created Array holding the Node Type of each triple line vertex", "NodeTypes")); + + return params; +} + +//------------------------------------------------------------------------------ +IFilter::VersionType ExtractTripleLinesFilter::parametersVersion() const +{ + return 1; +} + +//------------------------------------------------------------------------------ +IFilter::UniquePointer ExtractTripleLinesFilter::clone() const +{ + return std::make_unique(); +} + +//------------------------------------------------------------------------------ +IFilter::PreflightResult ExtractTripleLinesFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + auto pTriangleGeometryPath = filterArgs.value(k_TriangleGeometryPath_Key); + auto pFaceLabelsArrayPath = filterArgs.value(k_FaceLabelsArrayPath_Key); + auto pNodeTypesArrayPath = filterArgs.value(k_NodeTypesArrayPath_Key); + auto pTripleLineGeometryPath = filterArgs.value(k_CreatedTripleLineGeometryPath_Key); + auto pVertexDataName = filterArgs.value(k_VertexDataGroupName_Key); + auto pEdgeDataName = filterArgs.value(k_EdgeDataGroupName_Key); + auto pNumFeaturesName = filterArgs.value(k_NumFeaturesArrayName_Key); + auto pNodeTypesName = filterArgs.value(k_NodeTypesArrayName_Key); + + nx::core::Result resultOutputActions; + std::vector preflightUpdatedValues; + + const auto& triangleGeom = dataStructure.getDataRefAs(pTriangleGeometryPath); + const auto& faceLabels = dataStructure.getDataRefAs(pFaceLabelsArrayPath); + const auto& nodeTypes = dataStructure.getDataRefAs(pNodeTypesArrayPath); + + // The selection parameters guarantee these arrays exist with the right type and component shape, + // but not that they are sized to this particular geometry. Executing with a mismatch would read + // past the end of one of them, so check here where the user still gets an actionable message. + if(faceLabels.getNumberOfTuples() != triangleGeom.getNumberOfFaces()) + { + return {MakeErrorResult(-57200, fmt::format("Face Labels array '{}' has {} tuples but Triangle Geometry '{}' has {} faces. They must match.", pFaceLabelsArrayPath.toString(), + faceLabels.getNumberOfTuples(), pTriangleGeometryPath.toString(), triangleGeom.getNumberOfFaces()))}; + } + if(nodeTypes.getNumberOfTuples() != triangleGeom.getNumberOfVertices()) + { + return {MakeErrorResult(-57201, fmt::format("Node Types array '{}' has {} tuples but Triangle Geometry '{}' has {} vertices. They must match.", pNodeTypesArrayPath.toString(), + nodeTypes.getNumberOfTuples(), pTriangleGeometryPath.toString(), triangleGeom.getNumberOfVertices()))}; + } + + const std::string dataStoreFormat = faceLabels.getDataFormat(); + + // The edge and vertex counts are not known until the mesh has actually been scanned, so the + // geometry is created empty and resized during execute, as the surface meshers do. + { + auto createGeometryAction = + std::make_unique(pTripleLineGeometryPath, 0, 0, pVertexDataName, pEdgeDataName, EdgeGeom::k_SharedVertexListName, EdgeGeom::k_SharedEdgeListName); + resultOutputActions.value().appendAction(std::move(createGeometryAction)); + } + { + auto createNumFeaturesAction = std::make_unique(nx::core::DataType::int8, std::vector{0}, std::vector{1}, + pTripleLineGeometryPath.createChildPath(pEdgeDataName).createChildPath(pNumFeaturesName), dataStoreFormat); + resultOutputActions.value().appendAction(std::move(createNumFeaturesAction)); + } + { + auto createNodeTypesAction = std::make_unique(nx::core::DataType::int8, std::vector{0}, std::vector{1}, + pTripleLineGeometryPath.createChildPath(pVertexDataName).createChildPath(pNodeTypesName), dataStoreFormat); + resultOutputActions.value().appendAction(std::move(createNodeTypesAction)); + } + + return {std::move(resultOutputActions), std::move(preflightUpdatedValues)}; +} + +//------------------------------------------------------------------------------ +Result<> ExtractTripleLinesFilter::executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + auto pTripleLineGeometryPath = filterArgs.value(k_CreatedTripleLineGeometryPath_Key); + auto pVertexDataName = filterArgs.value(k_VertexDataGroupName_Key); + auto pEdgeDataName = filterArgs.value(k_EdgeDataGroupName_Key); + + const auto& triangleGeom = dataStructure.getDataRefAs(filterArgs.value(k_TriangleGeometryPath_Key)); + const auto& faceLabelsRef = dataStructure.getDataRefAs(filterArgs.value(k_FaceLabelsArrayPath_Key)).getDataStoreRef(); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(filterArgs.value(k_NodeTypesArrayPath_Key)).getDataStoreRef(); + + auto& tripleLineGeom = dataStructure.getDataRefAs(pTripleLineGeometryPath); + auto& numFeaturesRef = + dataStructure.getDataRefAs(pTripleLineGeometryPath.createChildPath(pEdgeDataName).createChildPath(filterArgs.value(k_NumFeaturesArrayName_Key))).getDataStoreRef(); + auto& tripleLineNodeTypesRef = + dataStructure.getDataRefAs(pTripleLineGeometryPath.createChildPath(pVertexDataName).createChildPath(filterArgs.value(k_NodeTypesArrayName_Key))).getDataStoreRef(); + + MeshingUtilities::TripleLineOptions tripleLineOptions; + tripleLineOptions.IncludeExteriorLines = filterArgs.value(k_IncludeExteriorTripleLines_Key); + + return MeshingUtilities::GenerateTripleLines(triangleGeom, faceLabelsRef, sourceNodeTypesRef, tripleLineGeom, numFeaturesRef, tripleLineNodeTypesRef, tripleLineOptions, shouldCancel, + messageHandler); +} +} // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.hpp new file mode 100644 index 0000000000..330a7d2a4e --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractTripleLinesFilter.hpp @@ -0,0 +1,124 @@ +#pragma once + +#include "SimplnxCore/SimplnxCore_export.hpp" + +#include "simplnx/Filter/FilterTraits.hpp" +#include "simplnx/Filter/IFilter.hpp" + +namespace nx::core +{ +/** + * @class ExtractTripleLinesFilter + * @brief Extracts the triple lines of a multi-material surface mesh into an Edge Geometry. + * + * A mesh edge is a triple line segment when the triangles sharing it border three or more + * distinct Feature Ids. Because the lines are extracted from whatever mesh is supplied, placing + * this filter after a smoothing filter yields triple lines that lie exactly on the smoothed + * surface. + */ +class SIMPLNXCORE_EXPORT ExtractTripleLinesFilter : public IFilter +{ +public: + ExtractTripleLinesFilter() = default; + ~ExtractTripleLinesFilter() noexcept override = default; + + ExtractTripleLinesFilter(const ExtractTripleLinesFilter&) = delete; + ExtractTripleLinesFilter(ExtractTripleLinesFilter&&) noexcept = delete; + + ExtractTripleLinesFilter& operator=(const ExtractTripleLinesFilter&) = delete; + ExtractTripleLinesFilter& operator=(ExtractTripleLinesFilter&&) noexcept = delete; + + // Parameter Keys + static constexpr StringLiteral k_TriangleGeometryPath_Key = "input_triangle_geometry_path"; + static constexpr StringLiteral k_FaceLabelsArrayPath_Key = "face_labels_array_path"; + static constexpr StringLiteral k_NodeTypesArrayPath_Key = "node_types_array_path"; + static constexpr StringLiteral k_IncludeExteriorTripleLines_Key = "include_exterior_triple_lines"; + + static constexpr StringLiteral k_CreatedTripleLineGeometryPath_Key = "output_triple_line_geometry_path"; + static constexpr StringLiteral k_VertexDataGroupName_Key = "vertex_data_group_name"; + static constexpr StringLiteral k_EdgeDataGroupName_Key = "edge_data_group_name"; + static constexpr StringLiteral k_NumFeaturesArrayName_Key = "num_features_array_name"; + static constexpr StringLiteral k_NodeTypesArrayName_Key = "node_types_array_name"; + + /** + * @brief Returns the name of the filter. + * @return + */ + std::string name() const override; + + /** + * @brief Returns the C++ classname of this filter. + * @return + */ + std::string className() const override; + + /** + * @brief Returns the uuid of the filter. + * @return + */ + Uuid uuid() const override; + + /** + * @brief Returns the human readable name of the filter. + * @return + */ + std::string humanName() const override; + + /** + * @brief Returns the default tags for this filter. + * @return + */ + std::vector defaultTags() const override; + + /** + * @brief Returns the parameters of the filter (i.e. its inputs) + * @return + */ + Parameters parameters() const override; + + /** + * @brief Returns parameters version integer. + * Initial version should always be 1. + * Should be incremented everytime the parameters change. + * @return VersionType + */ + VersionType parametersVersion() const override; + + /** + * @brief Returns a copy of the filter. + * @return + */ + UniquePointer clone() const override; + +protected: + /** + * @brief Takes in a DataStructure and checks that the filter can be run on it with the given arguments. + * Returns any warnings/errors. Also returns the changes that would be applied to the DataStructure. + * Some parts of the actions may not be completely filled out if all the required information is not available at preflight time. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + PreflightResult preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; + + /** + * @brief Applies the filter's algorithm to the DataStructure with the given arguments. Returns any warnings/errors. + * On failure, there is no guarantee that the DataStructure is in a correct state. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param pipelineNode The node in the pipeline that is being executed + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + Result<> executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; +}; +} // namespace nx::core + +SIMPLNX_DEF_FILTER_TRAITS(nx::core, ExtractTripleLinesFilter, "1abc8078-6997-4455-b908-e1cdb890da77"); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp index 5542a3d6ae..6f417f7418 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp @@ -70,7 +70,6 @@ Parameters QuickSurfaceMeshFilter::parameters() const "background (Feature Id 0); faces where the wall caps a real Feature ARE still generated, so " "Features flush with the box stay closed.", BoundingBoxSkinMode::k_Off, ChoicesParameter::Choices{"Off", "Background-Backed Walls Only"})); - // params.insert(std::make_unique(k_GenerateTripleLines_Key, "Generate Triple Lines", "Experimental feature. May not work.", false)); params.insert(std::make_unique(k_GridGeometryDataPath_Key, "Grid Geometry", "The complete path to the Grid Geometry from which to create a Triangle Geometry", DataPath{}, GeometrySelectionParameter::AllowedTypes{IGeometry::Type::Image, IGeometry::Type::RectGrid})); @@ -109,16 +108,16 @@ Parameters QuickSurfaceMeshFilter::parameters() const IFilter::VersionType QuickSurfaceMeshFilter::parametersVersion() const { return 3; - // Version 2 -> 3 - // Change 1: - // Added - k_BoundingBoxSkinMode_Key = "bounding_box_skin_mode_index"; - // Solution - set the value to 0 (BoundingBoxSkinMode::k_Off, preserves prior behavior); - // // Version 1 -> 2 // Change 1: // Added - k_RepairTriangleWinding_Key = "repair_triangle_winding"; // Solution - set the value to false (not default); // + // Version 2 -> 3 + // Change 1: + // Added - k_BoundingBoxSkinMode_Key = "bounding_box_skin_mode_index"; + // Solution - set the value to 0 (BoundingBoxSkinMode::k_Off, preserves prior behavior); + // } //------------------------------------------------------------------------------ @@ -221,7 +220,6 @@ Result<> QuickSurfaceMeshFilter::executeImpl(DataStructure& dataStructure, const { nx::core::QuickSurfaceMeshInputValues inputValues; - // inputValues.GenerateTripleLines = filterArgs.value(k_GenerateTripleLines_Key); inputValues.FixProblemVoxels = filterArgs.value(k_FixProblemVoxels_Key); inputValues.RepairTriangleWinding = filterArgs.value(k_RepairTriangleWinding_Key); inputValues.BoundingBoxSkinMode = filterArgs.value(k_BoundingBoxSkinMode_Key); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp index 91c8b0672c..73bbe67caf 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp @@ -25,7 +25,6 @@ class SIMPLNXCORE_EXPORT QuickSurfaceMeshFilter : public IFilter // Parameter Keys - // static constexpr StringLiteral k_GenerateTripleLines_Key = "generate_triple_lines"; static constexpr StringLiteral k_RepairTriangleWinding_Key = "repair_triangle_winding"; static constexpr StringLiteral k_FixProblemVoxels_Key = "fix_problem_voxels"; static constexpr StringLiteral k_BoundingBoxSkinMode_Key = "bounding_box_skin_mode_index"; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp index e504f84408..511ac2d400 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp @@ -105,11 +105,6 @@ Parameters SurfaceNetsFilter::parameters() const params.insert(std::make_unique(k_FaceLabelsArrayName_Key, "Face Labels", "The complete path to the Array specifying which Features are on either side of each Face in the Triangle Geometry", "FaceLabels")); - // Associate the Linkable Parameter(s) to the children parameters that they control - params.linkParameters(k_ApplySmoothing_Key, k_SmoothingIterations_Key, true); - params.linkParameters(k_ApplySmoothing_Key, k_MaxDistanceFromVoxelCenter_Key, true); - params.linkParameters(k_ApplySmoothing_Key, k_RelaxationFactor_Key, true); - return params; } @@ -203,6 +198,7 @@ IFilter::PreflightResult SurfaceNetsFilter::preflightImpl(const DataStructure& d resultOutputActions.value().appendAction(std::move(createArrayAction)); } } + return {std::move(resultOutputActions)}; } diff --git a/src/Plugins/SimplnxCore/test/CMakeLists.txt b/src/Plugins/SimplnxCore/test/CMakeLists.txt index 49d8eb54d3..a67ba3e9c5 100644 --- a/src/Plugins/SimplnxCore/test/CMakeLists.txt +++ b/src/Plugins/SimplnxCore/test/CMakeLists.txt @@ -100,6 +100,7 @@ set(${PLUGIN_NAME}UnitTest_SRCS KeepRemoveRankedFeaturesTest.cpp LabelTriangleGeometryTest.cpp LaplacianSmoothingFilterTest.cpp + ExtractTripleLinesTest.cpp M3CSurfaceMeshingTest.cpp MapPointCloudToRegularGridTest.cpp MoveDataTest.cpp diff --git a/src/Plugins/SimplnxCore/test/ExtractTripleLinesTest.cpp b/src/Plugins/SimplnxCore/test/ExtractTripleLinesTest.cpp new file mode 100644 index 0000000000..0b021456e2 --- /dev/null +++ b/src/Plugins/SimplnxCore/test/ExtractTripleLinesTest.cpp @@ -0,0 +1,381 @@ +#include "SimplnxCore/Filters/ExtractTripleLinesFilter.hpp" +#include "SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp" +#include "SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp" +#include "SimplnxCore/Filters/SurfaceNetsFilter.hpp" +#include "SimplnxCore/SimplnxCore_test_dirs.hpp" + +#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Parameters/MultiArraySelectionParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" + +#include + +using namespace nx::core; +using namespace nx::core::Constants; +using namespace nx::core::UnitTest; + +namespace +{ +const DataPath k_TriangleGeomPath({"TriangleGeom"}); +const DataPath k_TripleLineGeomPath({"Triple Lines"}); +const std::string k_VertexDataName = "Vertex Data"; +const std::string k_FaceDataName = "Face Data"; +const std::string k_NodeTypesName = "NodeTypes"; +const std::string k_FaceLabelsName = "FaceLabels"; +const std::string k_NumFeaturesName = "NumFeatures"; + +const DataPath k_FaceLabelsPath = k_TriangleGeomPath.createChildPath(k_FaceDataName).createChildPath(k_FaceLabelsName); +const DataPath k_NodeTypesPath = k_TriangleGeomPath.createChildPath(k_VertexDataName).createChildPath(k_NodeTypesName); +const DataPath k_OutNumFeaturesPath = k_TripleLineGeomPath.createChildPath(INodeGeometry1D::k_EdgeAttributeMatrixName).createChildPath(k_NumFeaturesName); +const DataPath k_OutNodeTypesPath = k_TripleLineGeomPath.createChildPath(INodeGeometry0D::k_VertexAttributeMatrixName).createChildPath(k_NodeTypesName); + +/** + * @brief Runs QuickSurfaceMesh over the shared 2x2x1 four-grain block, leaving a TriangleGeom at + * k_TriangleGeomPath with its FaceLabels and NodeTypes. The mesher no longer produces triple + * lines itself; extraction is a separate step. + */ +void RunQuickSurfaceMesh(DataStructure& dataStructure) +{ + UnitTest::BuildFourGrainBlock(dataStructure); + + QuickSurfaceMeshFilter filter; + Arguments args; + args.insertOrAssign(QuickSurfaceMeshFilter::k_GridGeometryDataPath_Key, std::make_any(DataPath({"ImageGeom"}))); + args.insertOrAssign(QuickSurfaceMeshFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(DataPath({"ImageGeom", "Cell Data", "FeatureIds"}))); + args.insertOrAssign(QuickSurfaceMeshFilter::k_SelectedDataArrayPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType{})); + args.insertOrAssign(QuickSurfaceMeshFilter::k_SelectedFeatureDataArrayPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType{})); + args.insertOrAssign(QuickSurfaceMeshFilter::k_CreatedTriangleGeometryPath_Key, std::make_any(k_TriangleGeomPath)); + args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); + args.insertOrAssign(QuickSurfaceMeshFilter::k_RepairTriangleWinding_Key, std::make_any(false)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); +} + +/** + * @brief Runs ExtractTripleLines over whatever TriangleGeom sits at k_TriangleGeomPath. + */ +IFilter::ExecuteResult RunExtractTripleLines(DataStructure& dataStructure, bool includeExterior = false, const DataPath& triangleGeomPath = k_TriangleGeomPath, + const DataPath& faceLabelsPath = k_FaceLabelsPath, const DataPath& nodeTypesPath = k_NodeTypesPath) +{ + ExtractTripleLinesFilter filter; + Arguments args; + args.insertOrAssign(ExtractTripleLinesFilter::k_TriangleGeometryPath_Key, std::make_any(triangleGeomPath)); + args.insertOrAssign(ExtractTripleLinesFilter::k_FaceLabelsArrayPath_Key, std::make_any(faceLabelsPath)); + args.insertOrAssign(ExtractTripleLinesFilter::k_NodeTypesArrayPath_Key, std::make_any(nodeTypesPath)); + args.insertOrAssign(ExtractTripleLinesFilter::k_IncludeExteriorTripleLines_Key, std::make_any(includeExterior)); + args.insertOrAssign(ExtractTripleLinesFilter::k_CreatedTripleLineGeometryPath_Key, std::make_any(k_TripleLineGeomPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + return filter.execute(dataStructure, args); +} +/** + * @brief Runs SurfaceNets over the same four-grain block, emitting into the same names as + * RunQuickSurfaceMesh so one extraction helper serves both. Smoothing is off so the geometry + * stays predictable. + */ +void RunSurfaceNets(DataStructure& dataStructure) +{ + UnitTest::BuildFourGrainBlock(dataStructure); + + SurfaceNetsFilter filter; + Arguments args; + args.insertOrAssign(SurfaceNetsFilter::k_ApplySmoothing_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_RepairTriangleWinding_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_MaxDistanceFromVoxelCenter_Key, std::make_any(1.0f)); + args.insertOrAssign(SurfaceNetsFilter::k_RelaxationFactor_Key, std::make_any(0.5f)); + args.insertOrAssign(SurfaceNetsFilter::k_SmoothingIterations_Key, std::make_any(20)); + args.insertOrAssign(SurfaceNetsFilter::k_GridGeometryDataPath_Key, std::make_any(DataPath({"ImageGeom"}))); + args.insertOrAssign(SurfaceNetsFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(DataPath({"ImageGeom", "Cell Data", "FeatureIds"}))); + args.insertOrAssign(SurfaceNetsFilter::k_SelectedDataArrayPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType{})); + args.insertOrAssign(SurfaceNetsFilter::k_SelectedFeatureDataArrayPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType{})); + args.insertOrAssign(SurfaceNetsFilter::k_CreatedTriangleGeometryPath_Key, std::make_any(k_TriangleGeomPath)); + args.insertOrAssign(SurfaceNetsFilter::k_VertexDataGroupName_Key, std::make_any(k_VertexDataName)); + args.insertOrAssign(SurfaceNetsFilter::k_NodeTypesArrayName_Key, std::make_any(k_NodeTypesName)); + args.insertOrAssign(SurfaceNetsFilter::k_FaceDataGroupName_Key, std::make_any(k_FaceDataName)); + args.insertOrAssign(SurfaceNetsFilter::k_FaceLabelsArrayName_Key, std::make_any(k_FaceLabelsName)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); +} + +/** + * @brief Runs M3CSurfaceMeshing over the same four-grain block, emitting into the same names. + */ +void RunM3CSurfaceMeshing(DataStructure& dataStructure) +{ + UnitTest::BuildFourGrainBlock(dataStructure); + + M3CSurfaceMeshingFilter filter; + Arguments args; + args.insertOrAssign(M3CSurfaceMeshingFilter::k_RepairTriangleWinding_Key, std::make_any(true)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_GridGeometryDataPath_Key, std::make_any(DataPath({"ImageGeom"}))); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_FeatureIdsArrayPath_Key, std::make_any(DataPath({"ImageGeom", "Cell Data", "FeatureIds"}))); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_CreatedTriangleGeometryPath_Key, std::make_any(k_TriangleGeomPath)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_VertexDataGroupName_Key, std::make_any(k_VertexDataName)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_NodeTypesArrayName_Key, std::make_any(k_NodeTypesName)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_FaceDataGroupName_Key, std::make_any(k_FaceDataName)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_FaceLabelsArrayName_Key, std::make_any(k_FaceLabelsName)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); +} +} // namespace + +TEST_CASE("SimplnxCore::ExtractTripleLinesFilter: Quadruple point line from a four-grain block", "[SimplnxCore][ExtractTripleLinesFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + RunQuickSurfaceMesh(dataStructure); + + auto executeResult = RunExtractTripleLines(dataStructure); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TripleLineGeomPath)); + const auto& tripleLineGeom = dataStructure.getDataRefAs(k_TripleLineGeomPath); + + // Four grains meet along the single interior grid edge, so exactly one segment bordering four + // unique Feature Ids - a quadruple point line. + REQUIRE(tripleLineGeom.getNumberOfEdges() == 1); + REQUIRE(tripleLineGeom.getNumberOfVertices() == 2); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_OutNumFeaturesPath)); + const auto& numFeatures = dataStructure.getDataRefAs(k_OutNumFeaturesPath); + REQUIRE(numFeatures.getNumberOfTuples() == 1); + REQUIRE(numFeatures[0] == 4); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ExtractTripleLinesFilter: Include Exterior Triple Lines", "[SimplnxCore][ExtractTripleLinesFilter]") +{ + UnitTest::LoadPlugins(); + + usize interiorOnlyEdges = 0; + { + DataStructure dataStructure; + RunQuickSurfaceMesh(dataStructure); + auto executeResult = RunExtractTripleLines(dataStructure, false); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TripleLineGeomPath)); + interiorOnlyEdges = dataStructure.getDataRefAs(k_TripleLineGeomPath).getNumberOfEdges(); + } + + DataStructure dataStructure; + RunQuickSurfaceMesh(dataStructure); + auto executeResult = RunExtractTripleLines(dataStructure, true); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TripleLineGeomPath)); + const auto& tripleLineGeom = dataStructure.getDataRefAs(k_TripleLineGeomPath); + + // Counting the outside of the volume as a region makes every grain boundary that reaches the + // free surface register as a triple line too, so the count must rise materially. Asserted as a + // strict increase rather than a hardcoded number so the test does not pin an incidental value. + REQUIRE(interiorOnlyEdges == 1); + REQUIRE(tripleLineGeom.getNumberOfEdges() > interiorOnlyEdges); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_OutNumFeaturesPath)); + const auto& numFeatures = dataStructure.getDataRefAs(k_OutNumFeaturesPath); + for(usize i = 0; i < numFeatures.getNumberOfTuples(); i++) + { + INFO("edge " << i << " reported NumFeatures " << static_cast(numFeatures[i])); + REQUIRE((numFeatures[i] == 3 || numFeatures[i] == 4)); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ExtractTripleLinesFilter: NodeTypes are carried onto the created vertices", "[SimplnxCore][ExtractTripleLinesFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + RunQuickSurfaceMesh(dataStructure); + auto executeResult = RunExtractTripleLines(dataStructure); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TripleLineGeomPath)); + const auto& tripleLineGeom = dataStructure.getDataRefAs(k_TripleLineGeomPath); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_OutNodeTypesPath)); + const auto& outNodeTypes = dataStructure.getDataRefAs(k_OutNodeTypesPath); + + // The array must exist, be sized to the created vertices, and be usable by a filter that + // requires a Node Type array - which is the whole reason it is emitted. + REQUIRE(outNodeTypes.getNumberOfTuples() == tripleLineGeom.getNumberOfVertices()); + + // Every vertex on a triple line is by definition a junction node, so its junction count (the + // NodeTypes value modulo the +10 surface offset) must be at least 3. + for(usize i = 0; i < outNodeTypes.getNumberOfTuples(); i++) + { + const int32 junctionCount = static_cast(outNodeTypes[i] % 10); + INFO("triple line vertex " << i << " has NodeType " << static_cast(outNodeTypes[i])); + REQUIRE(junctionCount >= 3); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ExtractTripleLinesFilter: Agrees with the source mesh NodeTypes", "[SimplnxCore][ExtractTripleLinesFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + RunQuickSurfaceMesh(dataStructure); + auto executeResult = RunExtractTripleLines(dataStructure); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // Cross-checks two independently computed quantities: NodeTypes comes from the mesher's own + // junction logic, the triple lines come from FaceLabels. A disagreement points at the NodeTypes + // producer, not at the extraction. + UnitTest::CheckTripleLineNodeTypeAgreement(dataStructure, k_TripleLineGeomPath, k_TriangleGeomPath, k_NodeTypesPath); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ExtractTripleLinesFilter: Preflight rejects mismatched input arrays", "[SimplnxCore][ExtractTripleLinesFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + RunQuickSurfaceMesh(dataStructure); + + ExtractTripleLinesFilter filter; + Arguments args; + args.insertOrAssign(ExtractTripleLinesFilter::k_TriangleGeometryPath_Key, std::make_any(k_TriangleGeomPath)); + args.insertOrAssign(ExtractTripleLinesFilter::k_IncludeExteriorTripleLines_Key, std::make_any(false)); + args.insertOrAssign(ExtractTripleLinesFilter::k_CreatedTripleLineGeometryPath_Key, std::make_any(k_TripleLineGeomPath)); + + SECTION("Face Labels sized to the vertices rather than the faces") + { + // NodeTypes has one tuple per vertex, so handing it in as Face Labels is a realistic + // mis-selection. It must be caught in preflight, not read past the end during execute. + args.insertOrAssign(ExtractTripleLinesFilter::k_FaceLabelsArrayPath_Key, std::make_any(k_FaceLabelsPath)); + args.insertOrAssign(ExtractTripleLinesFilter::k_NodeTypesArrayPath_Key, std::make_any(k_FaceLabelsPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + } +} + +TEST_CASE("SimplnxCore::ExtractTripleLinesFilter: Consistent across surface meshers", "[SimplnxCore][ExtractTripleLinesFilter]") +{ + UnitTest::LoadPlugins(); + + // The same four-grain block through each mesher, then extraction. The assertions are limited to + // properties that are genuinely mesher-independent: segment COUNT is not one of them, because it + // follows node placement and the three meshers place nodes differently (QuickSurfaceMesh on + // voxel corners, SurfaceNets cell-centred, M3C on cube faces). + struct MesherCase + { + const char* Name; + void (*Run)(DataStructure&); + }; + const std::array mesherCases = {MesherCase{"QuickSurfaceMesh", &RunQuickSurfaceMesh}, MesherCase{"SurfaceNets", &RunSurfaceNets}, + MesherCase{"M3CSurfaceMeshing", &RunM3CSurfaceMeshing}}; + + for(const auto& mesherCase : mesherCases) + { + DYNAMIC_SECTION(mesherCase.Name) + { + DataStructure dataStructure; + mesherCase.Run(dataStructure); + + auto executeResult = RunExtractTripleLines(dataStructure); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TripleLineGeomPath)); + const auto& tripleLineGeom = dataStructure.getDataRefAs(k_TripleLineGeomPath); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_OutNumFeaturesPath)); + const auto& numFeatures = dataStructure.getDataRefAs(k_OutNumFeaturesPath); + + // 1. A junction exists in this input, so every mesher must find at least one segment. + INFO(mesherCase.Name << " produced " << tripleLineGeom.getNumberOfEdges() << " triple line segments"); + REQUIRE(tripleLineGeom.getNumberOfEdges() > 0); + + // 2. Only 3 and 4 are meaningful multiplicities. + bool sawQuadruplePoint = false; + for(usize i = 0; i < numFeatures.getNumberOfTuples(); i++) + { + INFO(mesherCase.Name << " segment " << i << " reported NumFeatures " << static_cast(numFeatures[i])); + REQUIRE((numFeatures[i] == 3 || numFeatures[i] == 4)); + if(numFeatures[i] == 4) + { + sawQuadruplePoint = true; + } + } + + // 3. Four grains meet in this input, so each mesher must find the quadruple point line. + REQUIRE(sawQuadruplePoint); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + } +} + +// M3CSurfaceMeshing emits a triple line vertex at z = -0.5 on this input: a full cell BELOW a +// domain that spans z = 0 to 1, reported as a quadruple point. It also yields 2 segments where +// QuickSurfaceMesh and SurfaceNets both yield 1. +// +// This is a PRE-EXISTING M3C defect, entirely unrelated to triple line extraction, which merely +// made it visible: extraction reads the mesh and FaceLabels that M3C already produced. +// +// The underlying defect is BROADER than what this test pins. M3C's Triangle Geometry itself lies +// partly outside the input volume, at every domain size, not just thin ones. Measured against a +// unit-spacing volume with origin at 0: +// +// domain QuickSurfaceMesh bounds M3CSurfaceMeshing bounds M3C verts outside +// 2x2x1 x[0,2] y[0,2] z[0,1] x[-1,2] y[-1,2] z[-0.5,0.5] 54 of 85 +// 4x4x4 x[0,4] y[0,4] z[0,4] x[-1,4] y[-1,4] z[-0.5,3.5] 142 of 260 +// 8x8x8 x[0,8] y[0,8] z[0,8] x[-1,8] y[-1,8] z[-0.5,7.5] 368 of 744 +// +// Roughly half of every M3C mesh sits outside the volume: x and y extend a full cell to -1, and z +// is offset by half a cell. M3CSurfaceMeshing.cpp's SiteCoords already carries a comment saying it +// subtracts the ghost shell to keep coordinates "aligned with the input volume and the other +// meshers" - so this is a stated intent the code does not achieve, not a deliberate convention. +// +// Note that M3C's exemplar comparison test compares vertex coordinates exactly, so the exemplar +// has these coordinates baked in; fixing M3C means regenerating it. +// +// The [!shouldfail] tag keeps the suite green while pinning the defect, following the precedent in +// ComputeFeatureSizesTest.cpp. When M3C is fixed this test will start passing, at which point +// Catch2 will report it as a failure and the tag must be REMOVED. +// +// CHECK (not REQUIRE) on the segment count so the z-bound assertions below are still evaluated +// rather than the case aborting at the first failure. +TEST_CASE("SimplnxCore::ExtractTripleLinesFilter: M3C triple lines stay within the domain bounds", "[SimplnxCore][ExtractTripleLinesFilter][M3CSurfaceMeshingFilter][!shouldfail]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + RunM3CSurfaceMeshing(dataStructure); + + auto executeResult = RunExtractTripleLines(dataStructure); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TripleLineGeomPath)); + const auto& tripleLineGeom = dataStructure.getDataRefAs(k_TripleLineGeomPath); + + // The four cells meet along a single interior vertical grid edge. + CHECK(tripleLineGeom.getNumberOfEdges() == 1); + + // No vertex may lie outside the domain, which spans z = 0 to 1. + constexpr float32 k_Epsilon = 0.0001f; + const auto& vertsRef = tripleLineGeom.getVertices()->getDataStoreRef(); + for(usize i = 0; i < tripleLineGeom.getNumberOfVertices(); i++) + { + const float32 z = vertsRef[i * 3 + 2]; + INFO("triple line vertex " << i << " has z = " << z); + CHECK(z >= (0.0f - k_Epsilon)); + CHECK(z <= (1.0f + k_Epsilon)); + } +} diff --git a/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp b/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp index ce61300e3e..3e62f4a92e 100644 --- a/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp +++ b/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp @@ -5,7 +5,9 @@ #include "simplnx/Core/Application.hpp" #include "simplnx/DataStructure/AttributeMatrix.hpp" #include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" #include "simplnx/DataStructure/Geometry/INodeGeometry0D.hpp" +#include "simplnx/DataStructure/Geometry/INodeGeometry1D.hpp" #include "simplnx/DataStructure/Geometry/INodeGeometry2D.hpp" #include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/DataStructure/Geometry/RectGridGeom.hpp" @@ -926,3 +928,24 @@ TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Bounding Box Skin agrees across UnitTest::CheckArraysInheritTupleDims(serialWindowedResult.Structure); UnitTest::CheckArraysInheritTupleDims(parallelWindowedResult.Structure); } +// Characterization test for an OPEN BUG in M3CSurfaceMeshing, pre-existing and unrelated to triple-line +// generation itself (triple-line extraction only made it visible). +// +// On a 2x2x1 four-grain block (domain spanning z in [0, 1]), M3C emits a spurious extra triple-line +// vertex/edge at z = -0.5 -- one full cell BELOW the domain -- carrying NumFeatures == 4 as if it were a +// genuine quadruple-point junction. The real physical quadruple line is a single vertical segment +// through the shared corner of the four grains (1 edge); M3C instead reports 2 collinear edges split at +// z = 0, with the extra segment/vertex sitting in what can only be a ghost/padding cell layer that M3C +// synthesizes to close the marching-cubes cube on a domain that is only 1 cell thick in Z. +// +// Likely cause: M3C's ghost-shell handling clamps/replicates real feature labels into the padding layer +// on a 1-cell-thick domain instead of using a background label, so the padding cube looks like an +// ordinary interior 4-feature junction to the exterior-triple-line filtering logic. +// +// This is a PRE-EXISTING M3C defect: this branch changed nothing in M3C's meshing (it only appended a +// triple-line-generation call at the end of finalizeMesh), so M3C's mesh and FaceLabels here are +// byte-identical to before this branch. The assertions below encode the CORRECT expected behavior (an +// EdgeGeom confined to the real domain, z in [0, 1]); they currently fail against the actual (buggy) +// output. The [!shouldfail] tag makes Catch2 report that as an expected failure so CI stays green. +// Once M3C's ghost-shell handling is fixed, this test will start passing -- at that point remove the +// [!shouldfail] tag so it becomes a normal regression test. diff --git a/src/Plugins/SimplnxCore/test/QuickSurfaceMeshFilterTest.cpp b/src/Plugins/SimplnxCore/test/QuickSurfaceMeshFilterTest.cpp index 6b0c56bbad..83578ff5e8 100644 --- a/src/Plugins/SimplnxCore/test/QuickSurfaceMeshFilterTest.cpp +++ b/src/Plugins/SimplnxCore/test/QuickSurfaceMeshFilterTest.cpp @@ -1,9 +1,16 @@ #include "SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp" +#include "SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp" +#include "SimplnxCore/Filters/SurfaceNetsFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataStore.hpp" +#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" #include "simplnx/DataStructure/Geometry/INodeGeometry0D.hpp" +#include "simplnx/DataStructure/Geometry/INodeGeometry1D.hpp" #include "simplnx/DataStructure/Geometry/INodeGeometry2D.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" #include "simplnx/Parameters/ArrayCreationParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" @@ -66,7 +73,6 @@ TEST_CASE("SimplnxCore::QuickSurfaceMeshFilter", "[SimplnxCore][QuickSurfaceMesh } // Create default Parameters for the filter. - // args.insertOrAssign(QuickSurfaceMeshFilter::k_GenerateTripleLines_Key, std::make_any(false)); args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); args.insertOrAssign(QuickSurfaceMeshFilter::k_RepairTriangleWinding_Key, std::make_any(false)); @@ -160,7 +166,6 @@ TEST_CASE("SimplnxCore::QuickSurfaceMeshFilter: Winding", "[SimplnxCore][QuickSu } // Create default Parameters for the filter. - // args.insertOrAssign(QuickSurfaceMeshFilter::k_GenerateTripleLines_Key, std::make_any(false)); args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); args.insertOrAssign(QuickSurfaceMeshFilter::k_RepairTriangleWinding_Key, std::make_any(true)); @@ -254,7 +259,6 @@ TEST_CASE("SimplnxCore::QuickSurfaceMeshFilter: Problem Voxels", "[SimplnxCore][ } // Create default Parameters for the filter. - // args.insertOrAssign(QuickSurfaceMeshFilter::k_GenerateTripleLines_Key, std::make_any(false)); args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(true)); args.insertOrAssign(QuickSurfaceMeshFilter::k_RepairTriangleWinding_Key, std::make_any(false)); @@ -348,7 +352,6 @@ TEST_CASE("SimplnxCore::QuickSurfaceMeshFilter: Winding and Problem Voxels", "[S } // Create default Parameters for the filter. - // args.insertOrAssign(QuickSurfaceMeshFilter::k_GenerateTripleLines_Key, std::make_any(false)); args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(true)); args.insertOrAssign(QuickSurfaceMeshFilter::k_RepairTriangleWinding_Key, std::make_any(true)); diff --git a/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp b/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp index 82be4163cd..e57169a367 100644 --- a/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp +++ b/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp @@ -2,6 +2,12 @@ #include "SimplnxCore/SimplnxCore_test_dirs.hpp" #include "SurfaceMeshingTestUtils.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataStore.hpp" +#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" +#include "simplnx/DataStructure/Geometry/INodeGeometry0D.hpp" +#include "simplnx/DataStructure/Geometry/INodeGeometry1D.hpp" +#include "simplnx/DataStructure/Geometry/INodeGeometry2D.hpp" #include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" #include "simplnx/Parameters/ArrayCreationParameter.hpp" diff --git a/src/simplnx/Utilities/Meshing/TripleLineUtilities.cpp b/src/simplnx/Utilities/Meshing/TripleLineUtilities.cpp new file mode 100644 index 0000000000..af59ad1602 --- /dev/null +++ b/src/simplnx/Utilities/Meshing/TripleLineUtilities.cpp @@ -0,0 +1,182 @@ +#include "TripleLineUtilities.hpp" + +#include + +#include +#include +#include + +using namespace nx::core; + +namespace +{ +// NumFeatures saturates at 4, matching the NodeTypes convention which also caps at 4. +constexpr uint8 k_MaxFeaturesPerEdge = 4; + +// The edge key packs two 32-bit vertex indices into one 64-bit value, so meshes are +// limited to 2^32 vertices. GenerateTripleLines checks this up front. +constexpr uint64 k_MaxVertexCount = 0xFFFFFFFFULL; + +// Recovers the low vertex index when unpacking an edge key. +constexpr uint64 k_VertexIndexMask = 0xFFFFFFFFULL; + +/** + * @brief The unique Feature Ids bordering one mesh edge. A flat array with linear-scan + * insert rather than a std::set: at most 4 entries are ever kept, and a tree node per + * mesh edge would dominate memory on a multi-million triangle mesh. + */ +struct EdgeFeatureSet +{ + std::array Features{}; + uint8 Count = 0; + + void insert(int32 featureId) + { + for(uint8 i = 0; i < Count; i++) + { + if(Features[i] == featureId) + { + return; + } + } + // Saturate rather than grow. An edge bordering more than 4 regions is not + // geometrically meaningful here, and Count stays a faithful "3 or 4". + if(Count < k_MaxFeaturesPerEdge) + { + Features[Count] = featureId; + Count++; + } + } +}; + +/** + * @brief Packs a vertex pair into an order-independent key. + */ +inline uint64 MakeEdgeKey(uint64 v0, uint64 v1) +{ + return (v0 < v1) ? ((v0 << 32) | v1) : ((v1 << 32) | v0); +} +} // namespace + +namespace nx::core::MeshingUtilities +{ +Result<> GenerateTripleLines(const TriangleGeom& triangleGeom, const Int32AbstractDataStore& faceLabelsRef, const Int8AbstractDataStore& sourceNodeTypesRef, EdgeGeom& tripleLineGeom, + Int8AbstractDataStore& numFeaturesRef, Int8AbstractDataStore& tripleLineNodeTypesRef, const TripleLineOptions& options, const std::atomic_bool& shouldCancel, + const IFilter::MessageHandler& messageHandler) +{ + const usize numVertices = triangleGeom.getNumberOfVertices(); + if(numVertices > k_MaxVertexCount) + { + return MakeErrorResult(-57100, fmt::format("Triple line generation supports meshes with at most {} vertices, but '{}' has {}. The edge lookup key packs two 32-bit vertex indices.", + k_MaxVertexCount, triangleGeom.getName(), numVertices)); + } + + const auto& facesRef = triangleGeom.getFaces()->getDataStoreRef(); + const usize numTriangles = triangleGeom.getNumberOfFaces(); + + messageHandler(IFilter::Message::Type::Info, "Triple Lines: Building edge adjacency..."); + + std::unordered_map edgeMap; + edgeMap.reserve(numTriangles * 3 / 2); + + for(usize triIndex = 0; triIndex < numTriangles; triIndex++) + { + if(shouldCancel) + { + return {}; + } + + const int32 labelA = faceLabelsRef[triIndex * 2 + 0]; + const int32 labelB = faceLabelsRef[triIndex * 2 + 1]; + + const uint64 v0 = facesRef[triIndex * 3 + 0]; + const uint64 v1 = facesRef[triIndex * 3 + 1]; + const uint64 v2 = facesRef[triIndex * 3 + 2]; + + const std::array edgeKeys = {MakeEdgeKey(v0, v1), MakeEdgeKey(v1, v2), MakeEdgeKey(v2, v0)}; + for(const uint64 edgeKey : edgeKeys) + { + EdgeFeatureSet& featureSet = edgeMap[edgeKey]; + // When exterior lines are excluded, -1 is simply never counted, so an edge whose + // only third "region" is the outside of the volume falls back to 2 unique ids. + if(options.IncludeExteriorLines || labelA >= 0) + { + featureSet.insert(labelA); + } + if(options.IncludeExteriorLines || labelB >= 0) + { + featureSet.insert(labelB); + } + } + } + + messageHandler(IFilter::Message::Type::Info, "Triple Lines: Selecting edges..."); + + std::vector keptEdges; // pairs of COMPACTED vertex indices + std::vector keptFeatureCounts; + std::unordered_map vertexRemap; // original vertex index -> compact index + std::vector compactToOriginal; + + for(const auto& [edgeKey, featureSet] : edgeMap) + { + if(shouldCancel) + { + return {}; + } + if(featureSet.Count < 3) + { + continue; + } + + const uint64 vA = edgeKey >> 32; + const uint64 vB = edgeKey & k_VertexIndexMask; + + for(const uint64 originalVertex : {vA, vB}) + { + if(vertexRemap.find(originalVertex) == vertexRemap.end()) + { + vertexRemap[originalVertex] = compactToOriginal.size(); + compactToOriginal.push_back(originalVertex); + } + } + + keptEdges.push_back(vertexRemap[vA]); + keptEdges.push_back(vertexRemap[vB]); + keptFeatureCounts.push_back(static_cast(featureSet.Count)); + } + + const usize numTripleLineEdges = keptFeatureCounts.size(); + const usize numTripleLineVertices = compactToOriginal.size(); + + messageHandler(IFilter::Message::Type::Info, fmt::format("Triple Lines: Writing {} edges and {} vertices...", numTripleLineEdges, numTripleLineVertices)); + + tripleLineGeom.resizeVertexList(numTripleLineVertices); + tripleLineGeom.resizeEdgeList(numTripleLineEdges); + tripleLineGeom.getVertexAttributeMatrix()->resizeTuples({numTripleLineVertices}); + tripleLineGeom.getEdgeAttributeMatrix()->resizeTuples({numTripleLineEdges}); + numFeaturesRef.resizeTuples({numTripleLineEdges}); + tripleLineNodeTypesRef.resizeTuples({numTripleLineVertices}); + + const auto& sourceVertsRef = triangleGeom.getVertices()->getDataStoreRef(); + auto& destVertsRef = tripleLineGeom.getVertices()->getDataStoreRef(); + for(usize i = 0; i < numTripleLineVertices; i++) + { + const uint64 originalVertex = compactToOriginal[i]; + destVertsRef[i * 3 + 0] = sourceVertsRef[originalVertex * 3 + 0]; + destVertsRef[i * 3 + 1] = sourceVertsRef[originalVertex * 3 + 1]; + destVertsRef[i * 3 + 2] = sourceVertsRef[originalVertex * 3 + 2]; + // Carried across unchanged. NodeTypes never influences which edges were selected above. + tripleLineNodeTypesRef[i] = sourceNodeTypesRef[originalVertex]; + } + + auto& destEdgesRef = tripleLineGeom.getEdges()->getDataStoreRef(); + for(usize i = 0; i < numTripleLineEdges; i++) + { + destEdgesRef[i * 2 + 0] = keptEdges[i * 2 + 0]; + destEdgesRef[i * 2 + 1] = keptEdges[i * 2 + 1]; + numFeaturesRef[i] = keptFeatureCounts[i]; + } + + return {}; +} +} // namespace nx::core::MeshingUtilities diff --git a/src/simplnx/Utilities/Meshing/TripleLineUtilities.hpp b/src/simplnx/Utilities/Meshing/TripleLineUtilities.hpp new file mode 100644 index 0000000000..976b5d3d6e --- /dev/null +++ b/src/simplnx/Utilities/Meshing/TripleLineUtilities.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "simplnx/Common/Result.hpp" +#include "simplnx/DataStructure/AbstractDataStore.hpp" +#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Filter/IFilter.hpp" +#include "simplnx/simplnx_export.hpp" + +#include + +namespace nx::core::MeshingUtilities +{ +/** + * @brief Options controlling triple line extraction. + */ +struct SIMPLNX_EXPORT TripleLineOptions +{ + /** + * @brief When true, the "outside the volume" region (Feature Id -1) is counted as a + * distinct region. This makes grain boundaries that reach the free surface of the + * volume register as triple lines. When false (the default) only interior triple + * lines are produced. + */ + bool IncludeExteriorLines = false; +}; + +/** + * @brief Extracts the triple lines of a multi-material triangle mesh into an Edge Geometry. + * + * A mesh edge is a triple line segment if and only if the set of unique Feature Ids across + * the FaceLabels of every triangle sharing that edge has 3 or more members. Feature Id -1 + * ("outside the volume") is counted only when options.IncludeExteriorLines is true. + * + * NOTE: NodeTypes is deliberately NOT consulted as part of the rule, and the number of + * triangles sharing an edge is deliberately not used as the criterion. See + * docs/superpowers/specs/2026-08-19-triple-line-generation-design.md for why. + * + * sourceNodeTypesStore is COPIED THROUGH onto the output vertices and is never read to decide + * whether an edge is a triple line. Do not be tempted to gate the rule on it - that alternative + * was considered and rejected because NodeTypes is produced by three different code paths while + * FaceLabels is a single harmonized convention. + * + * The output Edge Geometry is self-contained: its vertex list is a compacted copy holding + * only the vertices used by triple line edges, so it shares no DataObject with triangleGeom. + * + * NOTE: Edge and vertex ordering follows std::unordered_map iteration order, which is + * deterministic for a given build but is NOT guaranteed stable across standard-library + * implementations. Anyone building an exemplar .dream3d comparison against this Edge + * Geometry should sort the edges/vertices first rather than relying on ordering. + * + * @param triangleGeom The source mesh. Not modified. + * @param faceLabelsRef The per-triangle Feature Id pairs. 2 components, int32. + * @param sourceNodeTypesRef The source mesh's per-vertex NodeTypes. Copied through only. + * @param tripleLineGeom The Edge Geometry to populate. Resized by this function. + * @param numFeaturesRef Per-edge unique Feature Id count. Resized by this function. + * Saturates at 4, matching the NodeTypes convention. + * @param tripleLineNodeTypesRef Per-vertex NodeTypes for the output, copied from + * sourceNodeTypesRef. Resized by this function. Lets the resulting Edge Geometry be + * consumed directly by filters that require a Node Type array, such as Laplacian + * Smoothing. + * @param options See TripleLineOptions. + * @param shouldCancel Checked periodically; on cancel the function returns early. + * @param messageHandler Progress destination. + * @return Result<> Invalid if the mesh has too many vertices for the edge key packing. + */ +SIMPLNX_EXPORT Result<> GenerateTripleLines(const TriangleGeom& triangleGeom, const Int32AbstractDataStore& faceLabelsRef, const Int8AbstractDataStore& sourceNodeTypesRef, EdgeGeom& tripleLineGeom, + Int8AbstractDataStore& numFeaturesRef, Int8AbstractDataStore& tripleLineNodeTypesRef, const TripleLineOptions& options, + const std::atomic_bool& shouldCancel, const IFilter::MessageHandler& messageHandler); +} // namespace nx::core::MeshingUtilities diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d247f189fb..d4fbbd5706 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -47,6 +47,7 @@ add_executable(simplnx_test UuidTest.cpp Vec3Test.cpp StringUtilitiesTest.cpp + TripleLineUtilitiesTest.cpp ScaleBarRendererTest.cpp FilterValidationTest.cpp SimplJsonConversionTest.cpp diff --git a/test/TripleLineUtilitiesTest.cpp b/test/TripleLineUtilitiesTest.cpp new file mode 100644 index 0000000000..016d62f79f --- /dev/null +++ b/test/TripleLineUtilitiesTest.cpp @@ -0,0 +1,389 @@ +#include "simplnx/Utilities/Meshing/TripleLineUtilities.hpp" +#include "simplnx/DataStructure/DataStore.hpp" +#include "simplnx/DataStructure/DataStructure.hpp" +#include "simplnx/DataStructure/Geometry/EdgeGeom.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" + +#include + +#include + +using namespace nx::core; + +namespace +{ +const std::string k_TriangleGeomName = "TriangleGeom"; +const std::string k_FaceLabelsName = "FaceLabels"; +const std::string k_TripleLineGeomName = "TripleLines"; +const std::string k_NumFeaturesName = "NumFeatures"; +const std::string k_NodeTypesName = "NodeTypes"; + +/** + * @brief Builds a TriangleGeom plus its FaceLabels array from explicit vertex, triangle and + * label lists. Returns the geometry; the FaceLabels array is created as its child. + */ +TriangleGeom* CreateTriangleMesh(DataStructure& dataStructure, const std::vector>& vertices, const std::vector>& triangles, + const std::vector>& faceLabels) +{ + REQUIRE(triangles.size() == faceLabels.size()); + + auto* triangleGeom = TriangleGeom::Create(dataStructure, k_TriangleGeomName); + REQUIRE(triangleGeom != nullptr); + + auto vertexStore = std::make_unique>(std::vector{vertices.size()}, std::vector{3}, 0.0f); + auto* vertexArray = IGeometry::SharedVertexList::Create(dataStructure, "SharedVertexList", std::move(vertexStore), triangleGeom->getId()); + REQUIRE(vertexArray != nullptr); + auto& verticesRef = vertexArray->getDataStoreRef(); + for(usize i = 0; i < vertices.size(); i++) + { + verticesRef[i * 3 + 0] = vertices[i][0]; + verticesRef[i * 3 + 1] = vertices[i][1]; + verticesRef[i * 3 + 2] = vertices[i][2]; + } + triangleGeom->setVertices(*vertexArray); + + auto faceStore = std::make_unique>(std::vector{triangles.size()}, std::vector{3}, 0); + auto* faceArray = IGeometry::SharedFaceList::Create(dataStructure, "SharedTriList", std::move(faceStore), triangleGeom->getId()); + REQUIRE(faceArray != nullptr); + auto& facesRef = faceArray->getDataStoreRef(); + for(usize i = 0; i < triangles.size(); i++) + { + facesRef[i * 3 + 0] = triangles[i][0]; + facesRef[i * 3 + 1] = triangles[i][1]; + facesRef[i * 3 + 2] = triangles[i][2]; + } + triangleGeom->setFaceList(*faceArray); + + // Every source vertex gets a NodeTypes value. GenerateTripleLines copies these through to the + // output vertices; it never reads them to decide which edges are triple lines. + auto nodeTypeStore = std::make_unique>(std::vector{vertices.size()}, std::vector{1}, 0); + auto* nodeTypeArray = Int8Array::Create(dataStructure, k_NodeTypesName, std::move(nodeTypeStore), triangleGeom->getId()); + REQUIRE(nodeTypeArray != nullptr); + auto& nodeTypesRef = nodeTypeArray->getDataStoreRef(); + for(usize i = 0; i < vertices.size(); i++) + { + // Distinct, recognisable values so a copy-through bug is visible rather than masked by zeros. + nodeTypesRef[i] = static_cast(2 + (i % 3)); + } + + auto labelStore = std::make_unique>(std::vector{faceLabels.size()}, std::vector{2}, 0); + auto* labelArray = Int32Array::Create(dataStructure, k_FaceLabelsName, std::move(labelStore), triangleGeom->getId()); + REQUIRE(labelArray != nullptr); + auto& labelsRef = labelArray->getDataStoreRef(); + for(usize i = 0; i < faceLabels.size(); i++) + { + labelsRef[i * 2 + 0] = faceLabels[i][0]; + labelsRef[i * 2 + 1] = faceLabels[i][1]; + } + + return triangleGeom; +} + +/** + * @brief Creates an empty EdgeGeom with its attribute matrices, plus a NumFeatures array. + * GenerateTripleLines resizes all of them. + */ +std::tuple CreateEmptyTripleLineGeom(DataStructure& dataStructure) +{ + auto* edgeGeom = EdgeGeom::Create(dataStructure, k_TripleLineGeomName); + REQUIRE(edgeGeom != nullptr); + + auto vertexStore = std::make_unique>(std::vector{0}, std::vector{3}, 0.0f); + auto* vertexArray = IGeometry::SharedVertexList::Create(dataStructure, "SharedVertexList", std::move(vertexStore), edgeGeom->getId()); + REQUIRE(vertexArray != nullptr); + edgeGeom->setVertices(*vertexArray); + + auto edgeStore = std::make_unique>(std::vector{0}, std::vector{2}, 0); + auto* edgeArray = IGeometry::SharedEdgeList::Create(dataStructure, "SharedEdgeList", std::move(edgeStore), edgeGeom->getId()); + REQUIRE(edgeArray != nullptr); + edgeGeom->setEdgeList(*edgeArray); + + auto* vertexAM = AttributeMatrix::Create(dataStructure, "Vertex Data", ShapeType{0}, edgeGeom->getId()); + REQUIRE(vertexAM != nullptr); + edgeGeom->setVertexAttributeMatrix(*vertexAM); + + auto* edgeAM = AttributeMatrix::Create(dataStructure, "Edge Data", ShapeType{0}, edgeGeom->getId()); + REQUIRE(edgeAM != nullptr); + edgeGeom->setEdgeAttributeMatrix(*edgeAM); + + auto numFeaturesStore = std::make_unique>(std::vector{0}, std::vector{1}, 0); + auto* numFeaturesArray = Int8Array::Create(dataStructure, k_NumFeaturesName, std::move(numFeaturesStore), edgeAM->getId()); + REQUIRE(numFeaturesArray != nullptr); + + auto outNodeTypeStore = std::make_unique>(std::vector{0}, std::vector{1}, 0); + auto* outNodeTypeArray = Int8Array::Create(dataStructure, k_NodeTypesName, std::move(outNodeTypeStore), vertexAM->getId()); + REQUIRE(outNodeTypeArray != nullptr); + + return {edgeGeom, numFeaturesArray, outNodeTypeArray}; +} +} // namespace + +TEST_CASE("MeshingUtilities::GenerateTripleLines: Flat boundary produces no triple lines", "[Core][MeshingUtilities]") +{ + DataStructure dataStructure; + + // A single quad between grains 1 and 2, split into two triangles. Every edge is + // shared by at most two triangles, and both carry the same labels, so no edge + // borders 3 or more unique Feature Ids. + const std::vector> vertices = {{0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}}; + const std::vector> triangles = {{0, 1, 2}, {0, 2, 3}}; + const std::vector> faceLabels = {{1, 2}, {1, 2}}; + + auto* triangleGeom = CreateTriangleMesh(dataStructure, vertices, triangles, faceLabels); + auto [edgeGeom, numFeaturesArray, tripleLineNodeTypes] = CreateEmptyTripleLineGeom(dataStructure); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName}))); + const auto& faceLabelsRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName})).getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName}))); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName})).getDataStoreRef(); + + const std::atomic_bool shouldCancel{false}; + MeshingUtilities::TripleLineOptions options; + + Result<> result = MeshingUtilities::GenerateTripleLines(*triangleGeom, faceLabelsRef, sourceNodeTypesRef, *edgeGeom, numFeaturesArray->getDataStoreRef(), tripleLineNodeTypes->getDataStoreRef(), + options, shouldCancel, {}); + + REQUIRE(result.valid()); + REQUIRE(edgeGeom->getNumberOfEdges() == 0); + REQUIRE(edgeGeom->getNumberOfVertices() == 0); + REQUIRE(numFeaturesArray->getNumberOfTuples() == 0); +} + +TEST_CASE("MeshingUtilities::GenerateTripleLines: Interior triple junction", "[Core][MeshingUtilities]") +{ + DataStructure dataStructure; + + // Three quad sheets meeting along the shared edge v0->v1. Each sheet is split so that + // exactly one of its two triangles contains both v0 and v1, so the shared edge is + // touched by exactly 3 triangles carrying labels {1,2}, {2,3} and {1,3} => 3 unique. + const std::vector> vertices = { + {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}, // v0, v1 : the shared edge + {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 1.0f}, // v2, v3 : sheet A rim + {-0.5f, 0.87f, 0.0f}, {-0.5f, 0.87f, 1.0f}, // v4, v5 : sheet B rim + {-0.5f, -0.87f, 0.0f}, {-0.5f, -0.87f, 1.0f}, // v6, v7 : sheet C rim + }; + const std::vector> triangles = { + {0, 1, 3}, {0, 3, 2}, // sheet A + {0, 1, 5}, {0, 5, 4}, // sheet B + {0, 1, 7}, {0, 7, 6}, // sheet C + }; + const std::vector> faceLabels = { + {1, 2}, {1, 2}, // sheet A + {2, 3}, {2, 3}, // sheet B + {1, 3}, {1, 3}, // sheet C + }; + + auto* triangleGeom = CreateTriangleMesh(dataStructure, vertices, triangles, faceLabels); + auto [edgeGeom, numFeaturesArray, tripleLineNodeTypes] = CreateEmptyTripleLineGeom(dataStructure); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName}))); + const auto& faceLabelsRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName})).getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName}))); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName})).getDataStoreRef(); + + const std::atomic_bool shouldCancel{false}; + MeshingUtilities::TripleLineOptions options; + + Result<> result = MeshingUtilities::GenerateTripleLines(*triangleGeom, faceLabelsRef, sourceNodeTypesRef, *edgeGeom, numFeaturesArray->getDataStoreRef(), tripleLineNodeTypes->getDataStoreRef(), + options, shouldCancel, {}); + + REQUIRE(result.valid()); + REQUIRE(edgeGeom->getNumberOfEdges() == 1); + REQUIRE(edgeGeom->getNumberOfVertices() == 2); + REQUIRE(numFeaturesArray->getNumberOfTuples() == 1); + REQUIRE((*numFeaturesArray)[0] == 3); + + // The one emitted edge must join the two ends of the shared edge, which sit at + // z = 0 and z = 1 with x = y = 0. Vertex order within the edge is unspecified. + const auto& edgesRef = edgeGeom->getEdges()->getDataStoreRef(); + const auto& vertsRef = edgeGeom->getVertices()->getDataStoreRef(); + std::set zCoords; + for(usize i = 0; i < 2; i++) + { + const usize vertIndex = edgesRef[i]; + REQUIRE(vertsRef[vertIndex * 3 + 0] == Approx(0.0f)); + REQUIRE(vertsRef[vertIndex * 3 + 1] == Approx(0.0f)); + zCoords.insert(vertsRef[vertIndex * 3 + 2]); + } + REQUIRE(zCoords == std::set{0.0f, 1.0f}); +} + +TEST_CASE("MeshingUtilities::GenerateTripleLines: Quadruple point line", "[Core][MeshingUtilities]") +{ + DataStructure dataStructure; + + // Four sheets around the shared edge, labels {1,2}, {2,3}, {3,4} and {1,4} => 4 unique. + const std::vector> vertices = { + {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}, // v0, v1 : the shared edge + {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 1.0f}, // sheet A rim + {0.0f, 1.0f, 0.0f}, {0.0f, 1.0f, 1.0f}, // sheet B rim + {-1.0f, 0.0f, 0.0f}, {-1.0f, 0.0f, 1.0f}, // sheet C rim + {0.0f, -1.0f, 0.0f}, {0.0f, -1.0f, 1.0f}, // sheet D rim + }; + const std::vector> triangles = { + {0, 1, 3}, {0, 3, 2}, {0, 1, 5}, {0, 5, 4}, {0, 1, 7}, {0, 7, 6}, {0, 1, 9}, {0, 9, 8}, + }; + const std::vector> faceLabels = { + {1, 2}, {1, 2}, {2, 3}, {2, 3}, {3, 4}, {3, 4}, {1, 4}, {1, 4}, + }; + + auto* triangleGeom = CreateTriangleMesh(dataStructure, vertices, triangles, faceLabels); + auto [edgeGeom, numFeaturesArray, tripleLineNodeTypes] = CreateEmptyTripleLineGeom(dataStructure); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName}))); + const auto& faceLabelsRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName})).getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName}))); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName})).getDataStoreRef(); + + const std::atomic_bool shouldCancel{false}; + MeshingUtilities::TripleLineOptions options; + + Result<> result = MeshingUtilities::GenerateTripleLines(*triangleGeom, faceLabelsRef, sourceNodeTypesRef, *edgeGeom, numFeaturesArray->getDataStoreRef(), tripleLineNodeTypes->getDataStoreRef(), + options, shouldCancel, {}); + + REQUIRE(result.valid()); + REQUIRE(edgeGeom->getNumberOfEdges() == 1); + REQUIRE(numFeaturesArray->getNumberOfTuples() == 1); + REQUIRE((*numFeaturesArray)[0] == 4); +} + +TEST_CASE("MeshingUtilities::GenerateTripleLines: Vertex list is compacted", "[Core][MeshingUtilities]") +{ + DataStructure dataStructure; + + // Same triple junction as Task 2, which has 8 source vertices but only 2 lie on a + // triple line. The output must carry exactly those 2, and every edge index must be + // in range - i.e. the remap really happened rather than passing indices through. + const std::vector> vertices = { + {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 1.0f}, {-0.5f, 0.87f, 0.0f}, {-0.5f, 0.87f, 1.0f}, {-0.5f, -0.87f, 0.0f}, {-0.5f, -0.87f, 1.0f}, + }; + const std::vector> triangles = {{0, 1, 3}, {0, 3, 2}, {0, 1, 5}, {0, 5, 4}, {0, 1, 7}, {0, 7, 6}}; + const std::vector> faceLabels = {{1, 2}, {1, 2}, {2, 3}, {2, 3}, {1, 3}, {1, 3}}; + + auto* triangleGeom = CreateTriangleMesh(dataStructure, vertices, triangles, faceLabels); + auto [edgeGeom, numFeaturesArray, tripleLineNodeTypes] = CreateEmptyTripleLineGeom(dataStructure); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName}))); + const auto& faceLabelsRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName})).getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName}))); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName})).getDataStoreRef(); + + const std::atomic_bool shouldCancel{false}; + MeshingUtilities::TripleLineOptions options; + + Result<> result = MeshingUtilities::GenerateTripleLines(*triangleGeom, faceLabelsRef, sourceNodeTypesRef, *edgeGeom, numFeaturesArray->getDataStoreRef(), tripleLineNodeTypes->getDataStoreRef(), + options, shouldCancel, {}); + + REQUIRE(result.valid()); + REQUIRE(triangleGeom->getNumberOfVertices() == 8); + REQUIRE(edgeGeom->getNumberOfVertices() == 2); + + const auto& edgesRef = edgeGeom->getEdges()->getDataStoreRef(); + for(usize i = 0; i < edgeGeom->getNumberOfEdges() * 2; i++) + { + REQUIRE(edgesRef[i] < edgeGeom->getNumberOfVertices()); + } +} + +TEST_CASE("MeshingUtilities::GenerateTripleLines: IncludeExteriorLines toggles surface lines", "[Core][MeshingUtilities]") +{ + // A grain boundary between grains 1 and 2 reaching the free surface of the volume. + // Three sheets meet along the shared edge: the interior 1|2 boundary, and the two + // exposed outer faces of grains 1 and 2, which carry the -1 "outside" label. + const std::vector> vertices = { + {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 1.0f}, {-0.5f, 0.87f, 0.0f}, {-0.5f, 0.87f, 1.0f}, {-0.5f, -0.87f, 0.0f}, {-0.5f, -0.87f, 1.0f}, + }; + const std::vector> triangles = {{0, 1, 3}, {0, 3, 2}, {0, 1, 5}, {0, 5, 4}, {0, 1, 7}, {0, 7, 6}}; + const std::vector> faceLabels = {{1, 2}, {1, 2}, {-1, 2}, {-1, 2}, {-1, 1}, {-1, 1}}; + + SECTION("Interior only (the default) rejects it") + { + DataStructure dataStructure; + auto* triangleGeom = CreateTriangleMesh(dataStructure, vertices, triangles, faceLabels); + auto [edgeGeom, numFeaturesArray, tripleLineNodeTypes] = CreateEmptyTripleLineGeom(dataStructure); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName}))); + const auto& faceLabelsRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName})).getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName}))); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName})).getDataStoreRef(); + + const std::atomic_bool shouldCancel{false}; + MeshingUtilities::TripleLineOptions options; + options.IncludeExteriorLines = false; + + Result<> result = MeshingUtilities::GenerateTripleLines(*triangleGeom, faceLabelsRef, sourceNodeTypesRef, *edgeGeom, numFeaturesArray->getDataStoreRef(), tripleLineNodeTypes->getDataStoreRef(), + options, shouldCancel, {}); + + // Discounting -1, the shared edge borders only grains 1 and 2. + REQUIRE(result.valid()); + REQUIRE(edgeGeom->getNumberOfEdges() == 0); + } + + SECTION("IncludeExteriorLines accepts it") + { + DataStructure dataStructure; + auto* triangleGeom = CreateTriangleMesh(dataStructure, vertices, triangles, faceLabels); + auto [edgeGeom, numFeaturesArray, tripleLineNodeTypes] = CreateEmptyTripleLineGeom(dataStructure); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName}))); + const auto& faceLabelsRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName})).getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName}))); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName})).getDataStoreRef(); + + const std::atomic_bool shouldCancel{false}; + MeshingUtilities::TripleLineOptions options; + options.IncludeExteriorLines = true; + + Result<> result = MeshingUtilities::GenerateTripleLines(*triangleGeom, faceLabelsRef, sourceNodeTypesRef, *edgeGeom, numFeaturesArray->getDataStoreRef(), tripleLineNodeTypes->getDataStoreRef(), + options, shouldCancel, {}); + + // Counting -1 as a region, the shared edge borders {1, 2, -1} => 3 unique. + REQUIRE(result.valid()); + REQUIRE(edgeGeom->getNumberOfEdges() == 1); + REQUIRE((*numFeaturesArray)[0] == 3); + } +} + +TEST_CASE("MeshingUtilities::GenerateTripleLines: NodeTypes are copied through to the output vertices", "[Core][MeshingUtilities]") +{ + DataStructure dataStructure; + + // The Task 2 triple junction: 8 source vertices, of which only v0 and v1 lie on the triple line. + const std::vector> vertices = { + {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 1.0f}, {-0.5f, 0.87f, 0.0f}, {-0.5f, 0.87f, 1.0f}, {-0.5f, -0.87f, 0.0f}, {-0.5f, -0.87f, 1.0f}, + }; + const std::vector> triangles = {{0, 1, 3}, {0, 3, 2}, {0, 1, 5}, {0, 5, 4}, {0, 1, 7}, {0, 7, 6}}; + const std::vector> faceLabels = {{1, 2}, {1, 2}, {2, 3}, {2, 3}, {1, 3}, {1, 3}}; + + auto* triangleGeom = CreateTriangleMesh(dataStructure, vertices, triangles, faceLabels); + auto [edgeGeom, numFeaturesArray, tripleLineNodeTypes] = CreateEmptyTripleLineGeom(dataStructure); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName}))); + const auto& faceLabelsRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_FaceLabelsName})).getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName}))); + const auto& sourceNodeTypesRef = dataStructure.getDataRefAs(DataPath({k_TriangleGeomName, k_NodeTypesName})).getDataStoreRef(); + + const std::atomic_bool shouldCancel{false}; + MeshingUtilities::TripleLineOptions options; + + Result<> result = MeshingUtilities::GenerateTripleLines(*triangleGeom, faceLabelsRef, sourceNodeTypesRef, *edgeGeom, numFeaturesArray->getDataStoreRef(), tripleLineNodeTypes->getDataStoreRef(), + options, shouldCancel, {}); + + REQUIRE(result.valid()); + REQUIRE(edgeGeom->getNumberOfVertices() == 2); + REQUIRE(tripleLineNodeTypes->getNumberOfTuples() == 2); + + // Each output vertex must carry the NodeTypes value of the SOURCE vertex it was copied from. + // The fixture assigns nodeTypes[i] = 2 + (i % 3), and only source vertices 0 and 1 survive + // compaction, so the two output values must be exactly {2, 3} — matched by coordinate, since + // compaction does not preserve index order. + const auto& vertsRef = edgeGeom->getVertices()->getDataStoreRef(); + const auto& outNodeTypesRef = tripleLineNodeTypes->getDataStoreRef(); + for(usize i = 0; i < 2; i++) + { + const float32 z = vertsRef[i * 3 + 2]; + const usize originalIndex = (z == Approx(0.0f)) ? 0 : 1; + INFO("output vertex " << i << " at z=" << z << " maps to source vertex " << originalIndex); + REQUIRE(outNodeTypesRef[i] == sourceNodeTypesRef[originalIndex]); + } +} diff --git a/test/UnitTestCommon/include/simplnx/UnitTest/UnitTestCommon.hpp b/test/UnitTestCommon/include/simplnx/UnitTest/UnitTestCommon.hpp index 7a73d41a84..d1a109c4d1 100644 --- a/test/UnitTestCommon/include/simplnx/UnitTest/UnitTestCommon.hpp +++ b/test/UnitTestCommon/include/simplnx/UnitTest/UnitTestCommon.hpp @@ -3,6 +3,7 @@ #include "simplnx/Common/Result.hpp" #include "simplnx/Common/StringLiteral.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" #include "simplnx/DataStructure/DataGroup.hpp" #include "simplnx/DataStructure/DataObject.hpp" #include "simplnx/DataStructure/DataStore.hpp" @@ -1694,6 +1695,79 @@ inline void CheckArraysInheritTupleDims(const DataStructure& dataStructure, cons } } +/** + * @brief Asserts that every vertex on a triple line also reports a junction count of 3 or + * more in NodeTypes. The two quantities are computed by completely independent paths - + * NodeTypes from the mesher's own junction logic, triple lines from FaceLabels - so + * agreement is a real signal. NodeTypes adds 10 for surface nodes, hence the % 10. + * + * A failure here indicates the NodeTypes producer is wrong, not GenerateTripleLines. + * + * Cost: O(triple-line vertices x mesh vertices) with exact coordinate matching, so this + * helper is intended for small fixtures only - not for full datasets. + */ +inline void CheckTripleLineNodeTypeAgreement(const DataStructure& dataStructure, const DataPath& tripleLineGeomPath, const DataPath& triangleGeomPath, const DataPath& nodeTypesPath) +{ + REQUIRE_NOTHROW(dataStructure.getDataRefAs(tripleLineGeomPath)); + const auto& tripleLineGeom = dataStructure.getDataRefAs(tripleLineGeomPath); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(triangleGeomPath)); + const auto& triangleGeom = dataStructure.getDataRefAs(triangleGeomPath); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(nodeTypesPath)); + const auto& nodeTypes = dataStructure.getDataRefAs(nodeTypesPath); + + const auto& tripleLineVertsRef = tripleLineGeom.getVertices()->getDataStoreRef(); + const auto& meshVertsRef = triangleGeom.getVertices()->getDataStoreRef(); + + // Triple line vertices are a compacted copy, so match them back by coordinate. + for(usize i = 0; i < tripleLineGeom.getNumberOfVertices(); i++) + { + bool foundMatch = false; + for(usize j = 0; j < triangleGeom.getNumberOfVertices(); j++) + { + if(tripleLineVertsRef[i * 3 + 0] == meshVertsRef[j * 3 + 0] && tripleLineVertsRef[i * 3 + 1] == meshVertsRef[j * 3 + 1] && tripleLineVertsRef[i * 3 + 2] == meshVertsRef[j * 3 + 2]) + { + const int8 junctionCount = static_cast(nodeTypes[j] % 10); + INFO("Triple line vertex " << i << " maps to mesh vertex " << j << " with NodeType " << static_cast(nodeTypes[j])); + REQUIRE(junctionCount >= 3); + foundMatch = true; + break; + } + } + INFO("Triple line vertex " << i << " has no matching mesh vertex"); + REQUIRE(foundMatch); + } +} + +/** + * @brief Builds a 2x2x1 four-grain Image Geometry: every one of the four cells is its own + * Feature, so the four cells meet along one interior grid edge with 4 unique Feature Ids. + * Used by the triple-line tests across QuickSurfaceMesh, SurfaceNets, and M3CSurfaceMeshing. + * + * @param dataStructure The DataStructure to populate. + * @param imageGeomName Name given to the created ImageGeom. + * @param cellDataName Name given to the created Cell Data AttributeMatrix. + * @param featureIdsName Name given to the created FeatureIds Int32Array. + */ +inline void BuildFourGrainBlock(DataStructure& dataStructure, const std::string& imageGeomName = "ImageGeom", const std::string& cellDataName = "Cell Data", + const std::string& featureIdsName = "FeatureIds") +{ + auto* imageGeom = ImageGeom::Create(dataStructure, imageGeomName); + imageGeom->setDimensions({2, 2, 1}); + imageGeom->setSpacing({1.0f, 1.0f, 1.0f}); + imageGeom->setOrigin({0.0f, 0.0f, 0.0f}); + + auto* cellAM = AttributeMatrix::Create(dataStructure, cellDataName, ShapeType{1, 2, 2}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + + auto featureIdsStore = std::make_unique>(std::vector{1, 2, 2}, std::vector{1}, 0); + auto* featureIds = Int32Array::Create(dataStructure, featureIdsName, std::move(featureIdsStore), cellAM->getId()); + auto& featureIdsRef = featureIds->getDataStoreRef(); + featureIdsRef[0] = 1; + featureIdsRef[1] = 2; + featureIdsRef[2] = 3; + featureIdsRef[3] = 4; +} + namespace Cropping { inline std::string BoolToString(bool v)