diff --git a/src/Plugins/SimplnxCore/docs/Images/M3CSurfaceMeshing_BoundingBoxEdges_Chamfered.png b/src/Plugins/SimplnxCore/docs/Images/M3CSurfaceMeshing_BoundingBoxEdges_Chamfered.png new file mode 100644 index 0000000000..72d0e6c9bd Binary files /dev/null and b/src/Plugins/SimplnxCore/docs/Images/M3CSurfaceMeshing_BoundingBoxEdges_Chamfered.png differ diff --git a/src/Plugins/SimplnxCore/docs/Images/M3CSurfaceMeshing_BoundingBoxEdges_Sharp.png b/src/Plugins/SimplnxCore/docs/Images/M3CSurfaceMeshing_BoundingBoxEdges_Sharp.png new file mode 100644 index 0000000000..18e9d6a137 Binary files /dev/null and b/src/Plugins/SimplnxCore/docs/Images/M3CSurfaceMeshing_BoundingBoxEdges_Sharp.png differ diff --git a/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md b/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md index b2aeeb76ce..f72710881f 100644 --- a/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md +++ b/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md @@ -101,10 +101,49 @@ Two inputs leave this mode nothing useful to do. Both are reported as warnings, interior porosity; the warning concerns only the walls, not whether background exists elsewhere in the volume. - `-56340` — every **Voxel** is background, so every face is omitted and the **Triangle Geometry** is - created with zero faces. Unlike Create Surface Mesh (QuickMesh) and Create Surface Mesh (Surface - Nets), the vertex count is not zero for M3C (see the note below), so the warning reports the - remaining vertex count. The input is legal — it holds no internal interface and no **Feature** to - cap — so this is success. + created with zero faces and zero vertices. The input is legal — it holds no internal interface and + no **Feature** to cap — so this is success. + +### Sharp Bounding Box Edges + +M3C places every mesh vertex on a **Cell** edge, face center, or body center; no candidate vertex ever +sits on the corner of a **Cell**. Along an edge of the bounding box, the marching square that straddles +the edge has one real **Voxel** and three ghost **Voxels**, and the case table joins its two edge +midpoints with a diagonal. On its own, therefore, M3C bevels all twelve edges and eight corners of the +volume with a 45 degree chamfer that is half a **Cell** deep on each wall. + +That chamfer is a problem for downstream finite element workflows. Mesh preparation tools such as Gmsh +identify the outer walls of the model as planar surfaces that meet at sharp edges; a bevelled edge instead +introduces a narrow extra strip of surface along every box edge and a small extra facet at every corner, so +the walls have to be repaired by hand before a volume mesh can be built. Development builds of this port +produced sharp edges; the chamfer appeared in the released version, and this option restores the sharp +behavior as the default. + +| Option off: chamfered edges | Option on: sharp edges | +|---|---| +| ![](Images/M3CSurfaceMeshing_BoundingBoxEdges_Chamfered.png) | ![](Images/M3CSurfaceMeshing_BoundingBoxEdges_Sharp.png) | + +When *Sharp Bounding Box Edges* is on (the default), the **Filter** finishes with a pass that: + +1. Moves the outermost row of vertices on each wall onto the neighboring wall plane. Because vertices lie on a + half-**Cell** lattice, this row is exactly the set of wall vertices within half a **Cell** of another wall, + so no other vertex moves. +2. Merges the vertices from the two walls that now coincide on the shared edge line (and the three that + coincide at each corner) into one vertex. A merged vertex takes the higher of the **Node Types** involved. +3. Removes the chamfer triangles, which after the merge reference the same vertex twice. + +The result is six planar walls that meet along shared edge lines and at the eight corner points of the +volume, with fewer triangles and vertices than the chamfered mesh. Internal **Feature** boundaries that run +into a wall follow it to the edge because their triple-line vertices are in the same outermost row. Face +Labels and any transferred **Cell** or **Feature** arrays are unaffected. Turn the option off to obtain the +original chamfered output. + +The pass leaves an axis chamfered when the volume is only one **Cell** thick along it: the single row of +wall vertices is then half a **Cell** from both bounding planes on that axis and there is no unambiguous +edge to move it to. The other two axes are still sharpened. + +The pass runs after the **Bounding Box Skin** prune, so with *Background-Backed Walls Only* it only affects +the wall faces that survive the prune. ### Feature Id Validation @@ -116,23 +155,20 @@ value produces an error (code `-56343`) naming the offending value, its tuple in array's **Data Path**. This is a mitigation for the underlying sentinel-collision design (tracked as issue #1705), not a fix for it. -**Note:** M3C's candidate-node generation always leaves a handful of node entries near the volume -boundary that no triangle references. These orphan vertices are present in stock M3C output -regardless of the **Bounding Box Skin** setting (tracked as issue #1706), and -**Background-Backed Walls Only** does not touch them — it clears only the vertices its own pruning -orphans. This is why an all-background volume yields zero faces but a non-zero vertex count. - ### Notes and Limitations - The volume is automatically wrapped in a temporary ghost layer so that **Features** touching the edge of the volume are meshed correctly; no manual padding is required. The ghost layer is internal only: the generated mesh lies entirely within the bounds of the input **Image Geometry**, and its - exterior surface sits exactly on the volume boundary. + exterior surface sits exactly on the volume boundary. With *Sharp Bounding Box Edges* off, the edges + and corners of the bounding box are chamfered by half a **Cell** (see above); with it on they are sharp. - **Changed in this release:** earlier versions placed the mesh half a **Cell** off from the input volume and additionally emitted surface along the internal seams of the ghost layer, so roughly half of every mesh fell outside the volume bounds. Both are fixed. Meshes produced by this - **Filter** now align with the input volume and with the other surface meshing **Filters**. Surface - meshes regenerated after this change will differ from ones saved previously. + **Filter** now align with the input volume and with the other surface meshing **Filters**. The + edges and corners of the bounding box are also now sharp by default (see *Sharp Bounding Box + Edges* above), where the previous release chamfered them. Surface meshes regenerated after these + changes will differ from ones saved previously. - **Feature Id** values of 0 are handled internally and restored on output. - Only an **Image Geometry** is accepted as input: the M3C node coordinates assume uniform **Cell** spacing, so a **RectGrid Geometry** cannot be meshed correctly by this **Filter**. @@ -158,7 +194,7 @@ DREAM3D-NX provides three **Filters** that convert a segmented grid into a multi |---|---|---|---| | Algorithm | Voxel-face ("staircase") | Dual (SurfaceNets) | Primal multi-material marching cubes | | Vertex placement | Voxel corners | One relaxed vertex per boundary **Cell** | On **Cell** edges/faces | -| Surface quality | Blocky / stair-stepped | Smooth, sharp edges preserved | Faceted (marching-cubes) | +| Surface quality | Blocky / stair-stepped | Smooth, sharp edges preserved | Faceted (marching-cubes); bounding box edges sharp by default | | Built-in smoothing | No (apply Laplacian Smoothing afterward) | Yes, optional and accuracy-controlled | No (apply Laplacian Smoothing afterward) | | Relative triangle count | Highest | Lowest | Moderate to high (configuration dependent) | | Multi-material junctions | Yes | Native | Yes (via case table) | diff --git a/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline b/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline index 23ee26d731..d2ea086a64 100644 --- a/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline +++ b/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline @@ -288,11 +288,15 @@ "value": "TriangleDataContainer", "version": 1 }, - "parameters_version": 2, + "parameters_version": 3, "repair_triangle_winding": { "value": true, "version": 1 }, + "sharp_bounding_box_edges": { + "value": true, + "version": 1 + }, "vertex_data_group_name": { "value": "Vertex Data", "version": 1 diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp index 2977eee3d4..024eb97304 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include using namespace nx::core; @@ -2415,6 +2417,226 @@ void get_caseM_triangles(Triangle* t1, SiteId* mCubeID, const SiteId* afe, const // ----------------------------------------------------------------------------- // Fill the pre-sized triangle array cube-by-cube. Transcribed from // M3CEntireVolume::get_triangles. +// ----------------------------------------------------------------------------- +// Sharp Bounding Box Edges support. +// +// M3C's candidate nodes sit on a half-cell lattice: an edge-midpoint node has cell-centre coordinates on +// two axes and a cell-face coordinate on the third, a face-centre node has one cell-centre coordinate and +// a body centre none. Along a bounding-box edge the marching square straddling it has one real corner and +// three ghost corners, and the case table joins its two edge midpoints with a diagonal: a 45 degree +// chamfer half a cell deep on both walls. The chamfer vertices are exactly the OUTERMOST row of wall +// nodes, because on a wall the only nodes within half a cell of a neighbouring wall are those whose +// cell-centre coordinate lies in the first or last cell along that axis. Snapping that row onto the +// neighbouring wall plane extends both walls to the edge line, where the two rows coincide and are +// merged; the chamfer triangles then reference a repeated node and are dropped. +// +// Everything is decided on the integer half-cell lattice, never on float coordinates, so the pass is +// exact and independent of spacing and origin. +struct HalfCellLattice +{ + const NodeCoords& nodeCoords; + + // Position of candidate node `id` in half-cell units from the volume origin, i.e. the node's coordinate + // is origin + u * spacing / 2. The bounding planes are u == 0 and u == 2 * dims; the outermost rows of + // cell-centre nodes are u == 1 and u == 2 * dims - 1. + std::array operator()(SiteId id) const + { + const SiteCoords& sites = nodeCoords.sites; + const usize linear = static_cast(id / 7); + const int kind = static_cast(id % 7); + // Same padded-index decomposition as SiteCoords::operator[] (real cell (0,0,0) is padded (1,1,1)). + const int64 i = static_cast(linear % sites.fileDim0) - 1; + const int64 j = static_cast((linear / sites.fileDim0) % sites.fileDim1) - 1; + const int64 k = static_cast(linear / sites.fileNSP) - 1; + // Which axes carry the +half-spacing offset for this node kind (see NodeCoords::operator[]). + const bool offX = (kind == 0 || kind == 3 || kind == 4 || kind == 6); + const bool offY = (kind == 1 || kind == 3 || kind == 5 || kind == 6); + const bool offZ = (kind == 2 || kind == 4 || kind == 5 || kind == 6); + return {2 * i + 1 + (offX ? 1 : 0), 2 * j + 1 + (offY ? 1 : 0), 2 * k + 1 + (offZ ? 1 : 0)}; + } +}; + +// Result of the sharp-edge pass: coordinate overrides for the nodes it moved (every other node keeps +// nodeCoords[id]) and the number of chamfer triangles it removed. +struct SharpEdgeResult +{ + std::unordered_map SnappedCoords; + int64 NumFacesRemoved = 0; +}; + +// Applies the Sharp Bounding Box Edges pass described above to the surviving triangles. Must run after +// the exterior (+10) NodeType promotion, which is how boundary nodes are recognised, and before node +// compaction, since it clears the NodeType of merged-away nodes and drops triangles in place. +SharpEdgeResult sharpenBoundingBoxEdges(std::vector& triangles, std::vector& mCubeID, std::vector& nodeType, SiteId numCandidateNodes, const NodeCoords& nodeCoords, + const usize dims[3]) +{ + SharpEdgeResult result; + const HalfCellLattice lattice{nodeCoords}; + const SiteCoords& sites = nodeCoords.sites; + const std::array wallHi = {2 * static_cast(dims[0]), 2 * static_cast(dims[1]), 2 * static_cast(dims[2])}; + // Lattice positions packed into one integer for hashing. + const auto packLattice = [&wallHi](const std::array& u) -> uint64 { return static_cast((u[2] * (wallHi[1] + 1) + u[1]) * (wallHi[0] + 1) + u[0]); }; + + // Pass 1: for every boundary node decide its snapped lattice position; nodes landing on the same + // position are merged into the first (lowest id) one to get there, which keeps the pass deterministic. + std::unordered_map representativeByPosition; + std::unordered_map mergedInto; + for(SiteId id = 0; id < numCandidateNodes; id++) + { + if(nodeType[static_cast(id)] < 10) + { + continue; // interior node, or unused candidate + } + std::array u = lattice(id); + bool onWall = false; + for(usize ax = 0; ax < 3; ax++) + { + onWall = onWall || u[ax] == 0 || u[ax] == wallHi[ax]; + } + if(!onWall) + { + continue; // cannot happen for a promoted node; guards the lattice arithmetic + } + std::array snappedAxis = {false, false, false}; + for(usize ax = 0; ax < 3; ax++) + { + // A one-cell-thick axis has a single cell-centre row that is half a cell from BOTH of its bounding + // planes; there is no unambiguous edge to snap it to, so that axis is left chamfered. + if(dims[ax] < 2) + { + continue; + } + if(u[ax] == 1) + { + u[ax] = 0; + snappedAxis[ax] = true; + } + else if(u[ax] == wallHi[ax] - 1) + { + u[ax] = wallHi[ax]; + snappedAxis[ax] = true; + } + } + const auto [it, inserted] = representativeByPosition.try_emplace(packLattice(u), id); + if(inserted) + { + if(snappedAxis[0] || snappedAxis[1] || snappedAxis[2]) + { + // Keep the node's own float coordinates on the axes that did not move, and put it EXACTLY on the + // plane value the rest of simplnx derives for the volume bounds on the axes that did. + Node node = nodeCoords[id]; + for(usize ax = 0; ax < 3; ax++) + { + if(snappedAxis[ax]) + { + node.coord[ax] = (u[ax] == 0) ? sites.origin[ax] : sites.origin[ax] + static_cast(dims[ax]) * sites.res[ax]; + } + } + result.SnappedCoords.emplace(id, node); + } + } + else + { + const SiteId representative = it->second; + mergedInto.emplace(id, representative); + nodeType[static_cast(representative)] = std::max(nodeType[static_cast(representative)], nodeType[static_cast(id)]); + nodeType[static_cast(id)] = M3CNodeType::k_Unused; + } + } + + if(mergedInto.empty()) + { + return result; + } + + // Every node the pass touched (moved or merged into). Used to find the degenerate triangles left on the + // edge lines, and afterwards to clear any of these nodes no surviving triangle references. + std::unordered_set touched; + for(const auto& [id, node] : result.SnappedCoords) + { + touched.insert(id); + } + for(const auto& [id, representative] : mergedInto) + { + touched.insert(representative); + } + const auto finalCoord = [&result, &nodeCoords](SiteId id) -> Node { + const auto it = result.SnappedCoords.find(id); + return (it != result.SnappedCoords.end()) ? it->second : nodeCoords[id]; + }; + + // Pass 2: remap the triangles' node ids and drop the ones the merge collapsed. A chamfer triangle has + // two vertices on the same cell of the edge line, so after the merge it repeats a node id. The only + // other way a triangle can lose its area here is for all three vertices to end up on one edge line + // (exactly collinear, so the cross product is exactly zero); that is checked only for triangles made + // entirely of touched nodes, which is the sole place it can arise. + const int64 nTriangle = static_cast(triangles.size()); + int64 survivingCount = 0; + for(int64 i = 0; i < nTriangle; i++) + { + Triangle triangle = triangles[static_cast(i)]; + int numTouched = 0; + for(int corner = 0; corner < 3; corner++) + { + const auto it = mergedInto.find(triangle.node_id[corner]); + if(it != mergedInto.end()) + { + triangle.node_id[corner] = it->second; + } + if(touched.count(triangle.node_id[corner]) != 0) + { + numTouched++; + } + } + if(triangle.node_id[0] == triangle.node_id[1] || triangle.node_id[1] == triangle.node_id[2] || triangle.node_id[0] == triangle.node_id[2]) + { + continue; + } + if(numTouched == 3) + { + const Node a = finalCoord(triangle.node_id[0]); + const Node b = finalCoord(triangle.node_id[1]); + const Node c = finalCoord(triangle.node_id[2]); + const double abx = static_cast(b.coord[0]) - a.coord[0]; + const double aby = static_cast(b.coord[1]) - a.coord[1]; + const double abz = static_cast(b.coord[2]) - a.coord[2]; + const double acx = static_cast(c.coord[0]) - a.coord[0]; + const double acy = static_cast(c.coord[1]) - a.coord[1]; + const double acz = static_cast(c.coord[2]) - a.coord[2]; + const double crossX = aby * acz - abz * acy; + const double crossY = abz * acx - abx * acz; + const double crossZ = abx * acy - aby * acx; + if(crossX == 0.0 && crossY == 0.0 && crossZ == 0.0) + { + continue; + } + } + triangles[static_cast(survivingCount)] = triangle; + mCubeID[static_cast(survivingCount)] = mCubeID[static_cast(i)]; + survivingCount++; + } + triangles.resize(static_cast(survivingCount)); + mCubeID.resize(static_cast(survivingCount)); + result.NumFacesRemoved = nTriangle - survivingCount; + + // Pass 3: a touched node is normally still referenced by the wall triangles on either side of the + // edge, but if every triangle that used it was a chamfer (possible once the Bounding Box Skin prune + // has removed the walls around it) it is now an orphan and must not be emitted. + for(const Triangle& triangle : triangles) + { + for(const SiteId nodeId : triangle.node_id) + { + touched.erase(nodeId); + } + } + for(const SiteId orphan : touched) + { + nodeType[static_cast(orphan)] = M3CNodeType::k_Unused; + result.SnappedCoords.erase(orphan); + } + return result; +} + // ----------------------------------------------------------------------------- void get_triangles(const SiteCoords& p, Triangle* t, SiteId* mCubeID, Face* sq, const NodeCoords& v, Segment* e, SiteId ns, SiteId nsp, int xDim, const std::atomic_bool& shouldCancel) { @@ -2643,15 +2865,13 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput } } - const int64 nTriangleFinal = static_cast(triangles.size()); - // Promote surface nodes to their exterior variant (+10). A triangle that borders the outside of the // volume has exactly one negative feature label (nSpin[0]*nSpin[1] < 0), so each of its nodes lies on // the volume boundary. This is the only output-relevant effect of the legacy triangle-side/inner-edge // connectivity pass: the per-triangle edge ids, edgePlace flags, and unique inner-edge list it also // built never appear in the output (Triangle Geometry + Face Labels + Node Types), so that machinery // has been removed. - for(int64 j = 0; j < nTriangleFinal; j++) + for(usize j = 0; j < triangles.size(); j++) { if(triangles[j].nSpin[0] * triangles[j].nSpin[1] < 0) { @@ -2666,6 +2886,19 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput } } + // Sharp Bounding Box Edges: snap the outermost wall rows onto the box edges and drop the chamfer + // triangles (see sharpenBoundingBoxEdges). Runs on the scratch vectors, so the output TriangleGeom is + // sized from the surviving count exactly as for the skin prune above. + SharpEdgeResult sharpEdges; + if(inputValues->SharpBoundingBoxEdges) + { + messageHandler("Sharpening bounding box edges..."); + sharpEdges = sharpenBoundingBoxEdges(triangles, mCubeID, nodeType, 7 * numSites, nodeCoords, dims); + messageHandler(fmt::format("Sharpened bounding box edges: removed {} chamfer triangles", sharpEdges.NumFacesRemoved)); + } + + const int64 nTriangleFinal = static_cast(triangles.size()); + // The face-edge segments are no longer needed; release before the memory-heavy output + winding stages. std::vector().swap(fedges); @@ -2679,8 +2912,8 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput // Node-id compaction without a dense 7*numSites candidate->id map. A candidate's compacted id is simply // the number of real nodes (nodeType > 0) that precede it; we answer that from a coarse per-block // prefix over nodeType plus a small in-block scan (saves ~3.8 GB at 512^3 vs a uint32 map). This is - // valid because the surface-node promotion above only adds +10 to kinds and never clears a node, so - // the set of real nodes is exactly what the sweep produced. + // valid because the prefix is built here, after the skin prune and the sharp-edge pass have cleared + // the nodes they retire and the surface-node promotion has added +10 to the rest. const SiteId numCandidateNodes = 7 * numSites; constexpr SiteId k_NodeBlock = 128; const SiteId numNodeBlocks = (numCandidateNodes + k_NodeBlock - 1) / k_NodeBlock; @@ -2732,7 +2965,8 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput { if(nodeType[i] > 0) { - const Node nodeCoord = nodeCoords[i]; + const auto snappedIt = sharpEdges.SnappedCoords.find(i); + const Node nodeCoord = (snappedIt != sharpEdges.SnappedCoords.end()) ? snappedIt->second : nodeCoords[i]; vertexStore[static_cast(vtxRunning) * 3 + 0] = nodeCoord.coord[0]; vertexStore[static_cast(vtxRunning) * 3 + 1] = nodeCoord.coord[1]; vertexStore[static_cast(vtxRunning) * 3 + 2] = nodeCoord.coord[2]; @@ -2830,10 +3064,9 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput if(inputValues->BoundingBoxSkinMode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) { // An entirely-background volume has nothing but {-1, 0} faces, so omitting the skin - // legitimately produces an empty mesh. Report it rather than returning silently. Unlike - // QuickSurfaceMesh/SurfaceNets, M3C's narrowed orphan-node clearing (see above) can leave - // pre-existing candidate nodes in the output even when every face is dropped, so nNodes here - // is not necessarily zero. + // legitimately produces an empty mesh. Report it rather than returning silently. nNodes is + // expected to be zero here as well (every node was orphaned by the prune and cleared); it is + // passed through so the warning stays honest if that ever changes. if(nTriangleFinal == 0) { return MeshingUtilities::MakeEmptyMeshWarning(inputValues->TriangleGeometryPath, dataStructure.getDataRefAs(inputValues->FeatureIdsArrayPath).getNumberOfTuples(), diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp index f3ff9124f5..5ffb91abc7 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp @@ -16,6 +16,7 @@ struct SIMPLNXCORE_EXPORT M3CSurfaceMeshingInputValues { bool RepairTriangleWinding; ChoicesParameter::ValueType BoundingBoxSkinMode; + bool SharpBoundingBoxEdges; DataPath GridGeomDataPath; DataPath FeatureIdsArrayPath; MultiArraySelectionParameter::ValueType SelectedCellDataArrayPaths; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp index 3408934201..2d63a51453 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp @@ -67,6 +67,11 @@ Parameters M3CSurfaceMeshingFilter::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_SharpBoundingBoxEdges_Key, "Sharp Bounding Box Edges", + "M3C places mesh vertices on cell edges, so on its own it bevels the edges and corners of the bounding box by half a cell (a 45 " + "degree chamfer). If true, the wall vertices along each box edge are moved onto the edge and merged, and the chamfer " + "triangles are removed, so the six walls meet at sharp edges and corners as a finite element model expects.", + true)); params.insertSeparator(Parameters::Separator{"Input Data Objects"}); params.insert(std::make_unique(k_GridGeometryDataPath_Key, "Image Geometry", "The complete path to the Image Geometry from which to create a Triangle Geometry", @@ -104,12 +109,18 @@ Parameters M3CSurfaceMeshingFilter::parameters() const //------------------------------------------------------------------------------ IFilter::VersionType M3CSurfaceMeshingFilter::parametersVersion() const { - return 2; + return 3; // Version 1 -> 2 // Change 1: // Added - k_BoundingBoxSkinMode_Key = "bounding_box_skin_mode_index"; // Solution - set the value to 0 (BoundingBoxSkinMode::k_Off, preserves prior behavior); // + // Version 2 -> 3 + // Change 1: + // Added - k_SharpBoundingBoxEdges_Key = "sharp_bounding_box_edges"; + // Solution - missing values take the parameter default (true). Note that this deliberately CHANGES the + // output of older pipelines: the bounding box edges are now sharp instead of chamfered. + // } //------------------------------------------------------------------------------ @@ -200,6 +211,7 @@ Result<> M3CSurfaceMeshingFilter::executeImpl(DataStructure& dataStructure, cons inputValues.RepairTriangleWinding = filterArgs.value(k_RepairTriangleWinding_Key); inputValues.BoundingBoxSkinMode = filterArgs.value(k_BoundingBoxSkinMode_Key); + inputValues.SharpBoundingBoxEdges = filterArgs.value(k_SharpBoundingBoxEdges_Key); inputValues.GridGeomDataPath = filterArgs.value(k_GridGeometryDataPath_Key); inputValues.FeatureIdsArrayPath = filterArgs.value(k_FeatureIdsArrayPath_Key); inputValues.SelectedCellDataArrayPaths = filterArgs.value(k_SelectedDataArrayPaths_Key); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp index d44b7cc684..99ef91cea5 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp @@ -40,6 +40,7 @@ class SIMPLNXCORE_EXPORT M3CSurfaceMeshingFilter : public IFilter static constexpr StringLiteral k_SelectedFeatureDataArrayPaths_Key = "input_feature_data_array_paths"; static constexpr StringLiteral k_RepairTriangleWinding_Key = "repair_triangle_winding"; static constexpr StringLiteral k_BoundingBoxSkinMode_Key = "bounding_box_skin_mode_index"; + static constexpr StringLiteral k_SharpBoundingBoxEdges_Key = "sharp_bounding_box_edges"; static constexpr StringLiteral k_CreatedTriangleGeometryPath_Key = "output_triangle_geometry_path"; diff --git a/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp b/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp index ce61300e3e..01e875025d 100644 --- a/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp +++ b/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp @@ -25,7 +25,9 @@ #include #include #include +#include #include +#include using namespace nx::core; using namespace nx::core::UnitTest; @@ -37,7 +39,8 @@ namespace // Independent structural validation of an M3C output mesh (does NOT rely on the exemplar oracle): // - no degenerate triangles and all vertex indices in range, // - FaceLabels ordered (smaller feature id in component 0, per the output convention), -// - every emitted vertex is a real node (compaction keeps only NodeType > 0). +// - every emitted vertex is a real node (compaction keeps only NodeType > 0), +// - every emitted vertex is referenced by a triangle (no orphan vertices, issue #1706). void CheckMeshIntegrity(DataStructure& dataStructure, const DataPath& triGeomPath, const DataPath& faceLabelsPath, const DataPath& nodeTypesPath) { REQUIRE_NOTHROW(dataStructure.getDataRefAs(triGeomPath)); @@ -72,6 +75,21 @@ void CheckMeshIntegrity(DataStructure& dataStructure, const DataPath& triGeomPat REQUIRE(nodeTypeStore[i] > 0); } + // Every emitted vertex is referenced by at least one triangle. M3C used to leave orphan vertices + // near the volume boundary (issue #1706): the legacy ghost shell carried six distinct sentinels, so + // ghost cells looked like material interfaces to each other and marked candidate nodes whose + // triangles were never built. A single shared ghost sentinel removed the cause; this guards it. + std::vector referenced(numVertices, false); + for(usize i = 0; i < numTriangles * 3; i++) + { + referenced[triStore[i]] = true; + } + for(usize i = 0; i < numVertices; i++) + { + INFO("vertex " << i << " is not referenced by any triangle (orphan vertex)"); + REQUIRE(referenced[i]); + } + // A face separates exactly two DISTINCT regions. (-1,-1) would mean a face between two cells that // are both outside the volume, which is the signature of the ghost shell being triangulated // against itself; (a,a) would mean a face inside a single feature. @@ -926,3 +944,332 @@ TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Bounding Box Skin agrees across UnitTest::CheckArraysInheritTupleDims(serialWindowedResult.Structure); UnitTest::CheckArraysInheritTupleDims(parallelWindowedResult.Structure); } + +namespace +{ +const DataPath k_SharpEdgesTriGeomPath({"M3CSharpEdgesMesh"}); +const DataPath k_SharpEdgesImageGeomPath({"ImageGeometry"}); +const DataPath k_SharpEdgesFeatureIdsPath({"ImageGeometry", "Cell Data", "FeatureIds"}); +const DataPath k_SharpEdgesFaceLabelsPath = k_SharpEdgesTriGeomPath.createChildPath(k_FaceDataGroupName).createChildPath(k_Face_Labels); +const DataPath k_SharpEdgesNodeTypesPath = k_SharpEdgesTriGeomPath.createChildPath(k_VertexDataGroupName).createChildPath(k_NodeTypeArrayName); + +// Builds an in-memory ImageGeom + FeatureIds volume from a labeling functor. Unlike RunM3COnToy this takes +// an arbitrary spacing and origin, so that a half-cell snap along one axis cannot be confused with one along +// another, and so that the float arithmetic is exercised away from round numbers. +template +DataStructure BuildToyVolume(const SizeVec3& dims, const FloatVec3& spacing, const FloatVec3& origin, LabelFuncT&& labeler) +{ + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, k_SharpEdgesImageGeomPath.getTargetName()); + imageGeom->setDimensions(dims); + imageGeom->setSpacing(spacing); + imageGeom->setOrigin(origin); + const std::vector tupleShape = {dims[2], dims[1], dims[0]}; + auto* cellAM = AttributeMatrix::Create(dataStructure, "Cell Data", tupleShape, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* featureIds = Int32Array::CreateWithStore(dataStructure, "FeatureIds", tupleShape, std::vector{1}, cellAM->getId()); + auto& featureIdsRef = featureIds->getDataStoreRef(); + usize idx = 0; + for(usize z = 0; z < dims[2]; z++) + { + for(usize y = 0; y < dims[1]; y++) + { + for(usize x = 0; x < dims[0]; x++) + { + featureIdsRef[idx++] = labeler(x, y, z); + } + } + } + return dataStructure; +} + +// Runs M3C (winding repair on, Bounding Box Skin off) on the volume built by BuildToyVolume with the Sharp +// Bounding Box Edges option set as requested; the mesh is created at k_SharpEdgesTriGeomPath. +void RunM3CSharpEdges(DataStructure& dataStructure, bool sharpEdges) +{ + M3CSurfaceMeshingFilter filter; + Arguments args; + args.insertOrAssign(M3CSurfaceMeshingFilter::k_RepairTriangleWinding_Key, std::make_any(true)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_BoundingBoxSkinMode_Key, std::make_any(BoundingBoxSkinMode::k_Off)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_SharpBoundingBoxEdges_Key, std::make_any(sharpEdges)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_GridGeometryDataPath_Key, std::make_any(k_SharpEdgesImageGeomPath)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_FeatureIdsArrayPath_Key, std::make_any(k_SharpEdgesFeatureIdsPath)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_CreatedTriangleGeometryPath_Key, std::make_any(k_SharpEdgesTriGeomPath)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_VertexDataGroupName_Key, std::make_any(k_VertexDataGroupName)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_NodeTypesArrayName_Key, std::make_any(k_NodeTypeArrayName)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_FaceDataGroupName_Key, std::make_any(k_FaceDataGroupName)); + args.insertOrAssign(M3CSurfaceMeshingFilter::k_FaceLabelsArrayName_Key, std::make_any(k_Face_Labels)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); +} + +// The bounding planes of an ImageGeom and a tolerance suited to its coordinate magnitudes (same reasoning as +// in CheckMeshWithinVolume). +struct VolumeBounds +{ + std::array Lo; + std::array Hi; + float32 Tol; +}; + +VolumeBounds GetVolumeBounds(const DataStructure& dataStructure, const DataPath& imageGeomPath) +{ + REQUIRE_NOTHROW(dataStructure.getDataRefAs(imageGeomPath)); + const auto& imageGeom = dataStructure.getDataRefAs(imageGeomPath); + const SizeVec3 dims = imageGeom.getDimensions(); + const FloatVec3 origin = imageGeom.getOrigin(); + const FloatVec3 spacing = imageGeom.getSpacing(); + VolumeBounds bounds{}; + for(usize ax = 0; ax < 3; ax++) + { + bounds.Lo[ax] = origin[ax]; + bounds.Hi[ax] = origin[ax] + static_cast(dims[ax]) * spacing[ax]; + } + const float32 maxSpacing = std::max({spacing[0], spacing[1], spacing[2]}); + const float32 maxMagnitude = std::max({std::abs(bounds.Lo[0]), std::abs(bounds.Lo[1]), std::abs(bounds.Lo[2]), std::abs(bounds.Hi[0]), std::abs(bounds.Hi[1]), std::abs(bounds.Hi[2])}); + bounds.Tol = std::max(1.0e-4f * maxSpacing, 8.0f * std::numeric_limits::epsilon() * maxMagnitude); + return bounds; +} + +// Counts the exterior triangles (Face Label -1 on one side) whose three vertices do NOT all lie on one common +// bounding plane. Such a triangle bridges two walls: it is one of the 45 degree chamfer triangles M3C's +// marching-squares cases emit along the edges and at the corners of the bounding box. A box with sharp edges +// has none. +usize CountWallBridgingTriangles(const DataStructure& dataStructure, const DataPath& triGeomPath, const DataPath& faceLabelsPath, const DataPath& imageGeomPath) +{ + const VolumeBounds bounds = GetVolumeBounds(dataStructure, imageGeomPath); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(triGeomPath)); + const auto& tri = dataStructure.getDataRefAs(triGeomPath); + const auto& vertStore = tri.getVertices()->getDataStoreRef(); + const auto& triStore = tri.getFaces()->getDataStoreRef(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(faceLabelsPath)); + const auto& faceLabelStore = dataStructure.getDataRefAs(faceLabelsPath).getDataStoreRef(); + + usize numBridging = 0; + for(usize i = 0; i < tri.getNumberOfFaces(); i++) + { + if(faceLabelStore[i * 2 + 0] != -1 && faceLabelStore[i * 2 + 1] != -1) + { + continue; + } + // Which of the six planes does EVERY vertex of this triangle lie on? + bool sharesAPlane = false; + for(usize ax = 0; ax < 3 && !sharesAPlane; ax++) + { + for(const float32 plane : {bounds.Lo[ax], bounds.Hi[ax]}) + { + bool allOnPlane = true; + for(usize corner = 0; corner < 3 && allOnPlane; corner++) + { + const usize vertIndex = triStore[i * 3 + corner]; + allOnPlane = std::abs(vertStore[vertIndex * 3 + ax] - plane) <= bounds.Tol; + } + if(allOnPlane) + { + sharesAPlane = true; + break; + } + } + } + if(!sharesAPlane) + { + numBridging++; + } + } + return numBridging; +} + +// Asserts that each of the eight corner points of the volume is present as a mesh vertex. A chamfered box +// has none of them; a sharp box has all of them. +void RequireBoxCornersPresent(const DataStructure& dataStructure, const DataPath& triGeomPath, const DataPath& imageGeomPath) +{ + const VolumeBounds bounds = GetVolumeBounds(dataStructure, imageGeomPath); + const auto& tri = dataStructure.getDataRefAs(triGeomPath); + const auto& vertStore = tri.getVertices()->getDataStoreRef(); + for(int cornerBits = 0; cornerBits < 8; cornerBits++) + { + const std::array corner = {((cornerBits & 1) != 0) ? bounds.Hi[0] : bounds.Lo[0], ((cornerBits & 2) != 0) ? bounds.Hi[1] : bounds.Lo[1], + ((cornerBits & 4) != 0) ? bounds.Hi[2] : bounds.Lo[2]}; + bool found = false; + for(usize i = 0; i < tri.getNumberOfVertices() && !found; i++) + { + found = std::abs(vertStore[i * 3 + 0] - corner[0]) <= bounds.Tol && std::abs(vertStore[i * 3 + 1] - corner[1]) <= bounds.Tol && std::abs(vertStore[i * 3 + 2] - corner[2]) <= bounds.Tol; + } + INFO("box corner (" << corner[0] << ", " << corner[1] << ", " << corner[2] << ") is not a mesh vertex"); + REQUIRE(found); + } +} + +// Asserts that no two vertices share the same coordinates: the snap must MERGE the vertices it moves onto a +// common point, not leave coincident duplicates (which would make the walls disconnected along the edge). +void RequireNoCoincidentVertices(const DataStructure& dataStructure, const DataPath& triGeomPath) +{ + const auto& tri = dataStructure.getDataRefAs(triGeomPath); + const auto& vertStore = tri.getVertices()->getDataStoreRef(); + std::set> seen; + for(usize i = 0; i < tri.getNumberOfVertices(); i++) + { + const auto key = std::make_tuple(vertStore[i * 3 + 0], vertStore[i * 3 + 1], vertStore[i * 3 + 2]); + INFO("vertex " << i << " (" << std::get<0>(key) << ", " << std::get<1>(key) << ", " << std::get<2>(key) << ") coincides with an earlier vertex"); + REQUIRE(seen.insert(key).second); + } +} + +// Asserts the mesh reaches every bounding plane: min/max vertex coordinate per axis equals the volume extent. +void RequireMeshReachesBounds(const DataStructure& dataStructure, const DataPath& triGeomPath, const DataPath& imageGeomPath) +{ + const VolumeBounds bounds = GetVolumeBounds(dataStructure, imageGeomPath); + const auto& tri = dataStructure.getDataRefAs(triGeomPath); + const auto& vertStore = tri.getVertices()->getDataStoreRef(); + for(usize ax = 0; ax < 3; ax++) + { + float32 minC = std::numeric_limits::max(); + float32 maxC = std::numeric_limits::lowest(); + for(usize i = 0; i < tri.getNumberOfVertices(); i++) + { + minC = std::min(minC, vertStore[i * 3 + ax]); + maxC = std::max(maxC, vertStore[i * 3 + ax]); + } + REQUIRE(minC == Approx(bounds.Lo[ax])); + REQUIRE(maxC == Approx(bounds.Hi[ax])); + } +} + +// The toy labelings the sharp-edge test runs. Each puts a real Feature in every voxel that touches a box +// edge, so every one of the twelve edges and eight corners is chamfered in stock M3C output. +int32 SingleFeatureLabeler(usize /*x*/, usize /*y*/, usize /*z*/) +{ + return 1; +} + +// Three regions meeting along a line, so internal boundaries run into the walls and their triple-line +// vertices sit in the same outermost wall row as the chamfer vertices. +int32 TripleLineLabeler(usize x, usize y, usize /*z*/) +{ + return (x < 2) ? 1 : ((y < 2) ? 2 : 3); +} + +// Every cell its own Feature: interfaces along all three axes, and internal boundaries meet every edge cell. +int32 UniqueCellLabeler(usize x, usize y, usize z) +{ + return static_cast(1 + x + 4 * y + 16 * z); +} +} // namespace + +TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Sharp bounding box edges", "[SimplnxCore][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // Non-unit, anisotropic spacing and a non-zero origin: the half-cell snap distance differs per axis, so a + // snap applied along the wrong axis (or by the wrong amount) cannot pass by coincidence. + const SizeVec3 dims(4, 3, 5); + const FloatVec3 spacing(0.25f, 2.0f, 0.5f); + const FloatVec3 origin(10.0f, -5.0f, 2.5f); + + using LabelerFn = int32 (*)(usize, usize, usize); + const auto [labelerName, labeler] = GENERATE(std::make_tuple("single feature", static_cast(SingleFeatureLabeler)), + std::make_tuple("triple line", static_cast(TripleLineLabeler)), std::make_tuple("unique cells", static_cast(UniqueCellLabeler))); + + DYNAMIC_SECTION(labelerName << ": option off keeps the chamfer") + { + DataStructure dataStructure = BuildToyVolume(dims, spacing, origin, labeler); + RunM3CSharpEdges(dataStructure, false); + // Proves the assertion below discriminates: stock M3C output DOES bridge walls along every box edge. + REQUIRE(CountWallBridgingTriangles(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesFaceLabelsPath, k_SharpEdgesImageGeomPath) > 0); + CheckMeshIntegrity(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesFaceLabelsPath, k_SharpEdgesNodeTypesPath); + } + + DYNAMIC_SECTION(labelerName << ": option on makes every wall triangle coplanar with one wall") + { + DataStructure dataStructure = BuildToyVolume(dims, spacing, origin, labeler); + RunM3CSharpEdges(dataStructure, true); + + const usize numBridging = CountWallBridgingTriangles(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesFaceLabelsPath, k_SharpEdgesImageGeomPath); + INFO(numBridging << " exterior triangles still bridge two bounding planes"); + REQUIRE(numBridging == 0); + + RequireBoxCornersPresent(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesImageGeomPath); + RequireNoCoincidentVertices(dataStructure, k_SharpEdgesTriGeomPath); + RequireMeshReachesBounds(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesImageGeomPath); + CheckMeshIntegrity(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesFaceLabelsPath, k_SharpEdgesNodeTypesPath); + CheckMeshWithinVolume(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesFaceLabelsPath, k_SharpEdgesImageGeomPath); + + // With a single Feature the mesh is nothing but the six walls, so a sharp box must be a closed surface: + // every edge used by exactly two triangles. This is what proves the walls were JOINED along the box + // edges rather than merely extended to them. (The multi-Feature labelings are excluded because, with + // the Bounding Box Skin off, an internal boundary meeting a wall forms a documented T-junction.) + if(std::string(labelerName) == "single feature") + { + const auto& tri = dataStructure.getDataRefAs(k_SharpEdgesTriGeomPath); + const SurfaceMeshingTest::EdgeUseCounts counts = SurfaceMeshingTest::CountEdgeUses(tri); + INFO("edge use counts -- Total: " << counts.TotalEdges << " UsedOnce: " << counts.EdgesUsedOnce << " UsedTwice: " << counts.EdgesUsedTwice + << " UsedMoreThanTwice: " << counts.EdgesUsedMoreThanTwice); + REQUIRE(SurfaceMeshingTest::IsWatertight(tri)); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} + +TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Sharp bounding box edges on Small IN100", "[SimplnxCore][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "QuickSurfaceMeshTest_v2.tar.gz", "QuickSurfaceMeshTest_v2"); + + // Real segmented data: hundreds of Features, so triple lines and quad points run into every wall and + // box edge. The option is on by default, which is what RunM3COnSmallIn100 uses. + DataStructure dataStructure = LoadSmallIn100Input(); + RunM3COnSmallIn100(dataStructure); + + const DataPath faceLabelsPath = k_ExemplarMeshPath.createChildPath(k_FaceDataGroupName).createChildPath(k_Face_Labels); + const DataPath imageGeomPath({k_DataContainer}); + const usize numBridging = CountWallBridgingTriangles(dataStructure, k_ExemplarMeshPath, faceLabelsPath, imageGeomPath); + INFO(numBridging << " exterior triangles still bridge two bounding planes"); + REQUIRE(numBridging == 0); + RequireBoxCornersPresent(dataStructure, k_ExemplarMeshPath, imageGeomPath); + RequireNoCoincidentVertices(dataStructure, k_ExemplarMeshPath); + RequireMeshReachesBounds(dataStructure, k_ExemplarMeshPath, imageGeomPath); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Sharp bounding box edges on a one-cell-thick volume", "[SimplnxCore][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // Along an axis that is only one cell thick, the single row of wall vertices is within half a cell of BOTH + // bounding planes on that axis, so there is no unambiguous edge to snap it to; the option leaves that axis + // chamfered. The other two axes are still sharpened, and the mesh must stay well-formed and span the volume. + const SizeVec3 dims(4, 3, 1); + const FloatVec3 spacing(0.25f, 2.0f, 0.5f); + const FloatVec3 origin(10.0f, -5.0f, 2.5f); + + DataStructure dataStructure = BuildToyVolume(dims, spacing, origin, TripleLineLabeler); + RunM3CSharpEdges(dataStructure, true); + + RequireNoCoincidentVertices(dataStructure, k_SharpEdgesTriGeomPath); + RequireMeshReachesBounds(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesImageGeomPath); + CheckMeshIntegrity(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesFaceLabelsPath, k_SharpEdgesNodeTypesPath); + CheckMeshWithinVolume(dataStructure, k_SharpEdgesTriGeomPath, k_SharpEdgesFaceLabelsPath, k_SharpEdgesImageGeomPath); + + // The four edges parallel to the thin (z) axis ARE sharp: the vertical box edge lines exist as vertices at + // the z-midplane, where the x and y wall rows were merged. + const VolumeBounds bounds = GetVolumeBounds(dataStructure, k_SharpEdgesImageGeomPath); + const auto& tri = dataStructure.getDataRefAs(k_SharpEdgesTriGeomPath); + const auto& vertStore = tri.getVertices()->getDataStoreRef(); + const float32 zMid = origin[2] + 0.5f * spacing[2]; + for(int cornerBits = 0; cornerBits < 4; cornerBits++) + { + const float32 cx = ((cornerBits & 1) != 0) ? bounds.Hi[0] : bounds.Lo[0]; + const float32 cy = ((cornerBits & 2) != 0) ? bounds.Hi[1] : bounds.Lo[1]; + bool found = false; + for(usize i = 0; i < tri.getNumberOfVertices() && !found; i++) + { + found = std::abs(vertStore[i * 3 + 0] - cx) <= bounds.Tol && std::abs(vertStore[i * 3 + 1] - cy) <= bounds.Tol && std::abs(vertStore[i * 3 + 2] - zMid) <= bounds.Tol; + } + INFO("vertical box edge at (" << cx << ", " << cy << ") has no vertex at z = " << zMid); + REQUIRE(found); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} diff --git a/src/Plugins/SimplnxCore/test/OmitBoundingBoxSkinTest.cpp b/src/Plugins/SimplnxCore/test/OmitBoundingBoxSkinTest.cpp index 89cc226db2..d5f67ccb3a 100644 --- a/src/Plugins/SimplnxCore/test/OmitBoundingBoxSkinTest.cpp +++ b/src/Plugins/SimplnxCore/test/OmitBoundingBoxSkinTest.cpp @@ -214,12 +214,10 @@ TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Bounding Box Skin", "[SimplnxCo UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); } - // M3C's marching-cubes candidate generation can leave some "pre-existing" candidate nodes that no - // triangle -- dropped or surviving -- ever references, independent of the skin-omit option (see - // M3CSurfaceMeshing.cpp finalizeMesh). So the option cannot promise zero orphan vertices overall; - // what it promises is that it never CREATES a new orphan beyond nodes the prune itself touched. - // Verify that by coordinate: every vertex unreferenced in the pruned mesh must also have been - // unreferenced in the full (un-pruned) mesh at the same coordinate. + // The option must never leave a vertex behind that only dropped faces referenced. Verified by + // coordinate: a vertex unreferenced in the pruned mesh would have to be unreferenced in the full + // (un-pruned) mesh at the same coordinate as well -- and since M3C no longer emits orphan vertices + // at all (issue #1706 was resolved by the single ghost sentinel), no vertex is unreferenced in either. SECTION("Option on leaves no vertex newly orphaned by the prune") { SurfaceMeshingTest::MeshResult fullMesh = RunM3C(true, BoundingBoxSkinMode::k_Off); @@ -250,12 +248,14 @@ TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Bounding Box Skin", "[SimplnxCo const auto prunedReferenced = isReferencedByCoord(prunedMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath)); usize newlyOrphanedCount = 0; + usize unreferencedCount = 0; for(const auto& [coord, isReferencedInPruned] : prunedReferenced) { if(isReferencedInPruned) { continue; } + unreferencedCount++; // A vertex that survives the prune unreferenced must have also been unreferenced pre-prune. REQUIRE(fullReferenced.count(coord) == 1); if(fullReferenced.at(coord)) @@ -266,6 +266,8 @@ TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Bounding Box Skin", "[SimplnxCo INFO("Vertices newly orphaned by the prune (must be 0): " << newlyOrphanedCount); REQUIRE(newlyOrphanedCount == 0); + INFO("Unreferenced vertices in the pruned mesh (must be 0): " << unreferencedCount); + REQUIRE(unreferencedCount == 0); UnitTest::CheckArraysInheritTupleDims(fullMesh.Structure); UnitTest::CheckArraysInheritTupleDims(prunedMesh.Structure); @@ -376,9 +378,8 @@ TEST_CASE("SimplnxCore::Bounding Box Skin is a no-op without background", "[Simp REQUIRE_NOTHROW(onStructure.getDataRefAs(faceLabelsPath)); UnitTest::CompareDataArrays(offStructure.getDataRefAs(faceLabelsPath), onStructure.getDataRefAs(faceLabelsPath)); - // Node Types are untouched by M3C's orphan-cleanup discontinuity, but this is the only no-op - // test that checks all three meshers, so it is the natural place to cover the array for all of - // them rather than just Face Labels/Faces/Vertices. + // This is the only no-op test that checks all three meshers, so it is the natural place to cover + // Node Types for all of them rather than just Face Labels/Faces/Vertices. const DataPath nodeTypesPath = k_TriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); REQUIRE_NOTHROW(offStructure.getDataRefAs(nodeTypesPath)); REQUIRE_NOTHROW(onStructure.getDataRefAs(nodeTypesPath)); @@ -443,10 +444,9 @@ TEST_CASE("SimplnxCore::Bounding Box Skin is a no-op without background", "[Simp REQUIRE_NOTHROW(onStructure.getDataRefAs(faceLabelsPath)); UnitTest::CompareDataArrays(offStructure.getDataRefAs(faceLabelsPath), onStructure.getDataRefAs(faceLabelsPath)); - // M3C's orphan-cleanup is the one code path in the whole feature that has a discontinuity - // (see M3CSurfaceMeshing.cpp:2566): it is gated on whether the prune actually dropped anything, - // not on the option flag alone. This fully-indexed input drops nothing, so Node Types must - // still match exactly here. + // M3C's orphan-node clearing after the prune is the one code path in the whole feature that is + // gated on whether the prune actually dropped anything, not on the option flag alone. This + // fully-indexed input drops nothing, so Node Types must still match exactly here. const DataPath nodeTypesPath = k_M3CTriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); REQUIRE_NOTHROW(offStructure.getDataRefAs(nodeTypesPath)); REQUIRE_NOTHROW(onStructure.getDataRefAs(nodeTypesPath)); @@ -685,25 +685,9 @@ TEST_CASE("SimplnxCore::Bounding Box Skin warns on an all-background volume (M3C { UnitTest::LoadPlugins(); - // Unlike QuickSurfaceMesh/SurfaceNets, M3C does not necessarily reach zero vertices here: its - // marching-cubes candidate generation can leave "pre-existing" candidate nodes that no triangle - // ever references, independent of the skin-omit option (see M3CSurfaceMeshing.cpp finalizeMesh). - // When the option prunes every face (as it does on an all-background volume), the narrowed - // orphan-node clearing leaves exactly those pre-existing orphans behind. Measure that count from - // the un-pruned (option off) mesh of the same input rather than assuming any particular number. - DataStructure offStructure = SurfaceMeshingTest::CreateAllBackground(); - const Result<> offResult = RunM3CRaw(offStructure, BoundingBoxSkinMode::k_Off); - SIMPLNX_RESULT_REQUIRE_VALID(offResult); - REQUIRE_NOTHROW(offStructure.getDataRefAs(k_M3CTriangleGeomPath)); - const auto& offGeom = offStructure.getDataRefAs(k_M3CTriangleGeomPath); - const auto& offFacesRef = offGeom.getFaces()->getDataStoreRef(); - std::set offReferencedVertices; - for(usize i = 0; i < offGeom.getNumberOfFaces() * 3; i++) - { - offReferencedVertices.insert(static_cast(offFacesRef[i])); - } - const usize preExistingOrphanCount = offGeom.getNumberOfVertices() - offReferencedVertices.size(); - INFO("Pre-existing orphan vertex count measured from the un-pruned mesh: " << preExistingOrphanCount); + // Like QuickSurfaceMesh/SurfaceNets, M3C must reach zero vertices here: pruning every face orphans + // every node, and the option clears the nodes its prune orphans. (M3C once also carried orphan + // vertices of its own that survived this, issue #1706; the single ghost sentinel removed them.) SECTION("Repair Triangle Winding off") { @@ -717,7 +701,7 @@ TEST_CASE("SimplnxCore::Bounding Box Skin warns on an all-background volume (M3C REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_M3CTriangleGeomPath)); const auto& triangleGeom = dataStructure.getDataRefAs(k_M3CTriangleGeomPath); REQUIRE(triangleGeom.getNumberOfFaces() == 0); - REQUIRE(triangleGeom.getNumberOfVertices() == preExistingOrphanCount); + REQUIRE(triangleGeom.getNumberOfVertices() == 0); UnitTest::CheckArraysInheritTupleDims(dataStructure); } @@ -737,7 +721,7 @@ TEST_CASE("SimplnxCore::Bounding Box Skin warns on an all-background volume (M3C REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_M3CTriangleGeomPath)); const auto& triangleGeom = dataStructure.getDataRefAs(k_M3CTriangleGeomPath); REQUIRE(triangleGeom.getNumberOfFaces() == 0); - REQUIRE(triangleGeom.getNumberOfVertices() == preExistingOrphanCount); + REQUIRE(triangleGeom.getNumberOfVertices() == 0); UnitTest::CheckArraysInheritTupleDims(dataStructure); }