diff --git a/src/Plugins/ITKImageProcessing/pipelines/(03) Porosity Mesh Export.d3dpipeline b/src/Plugins/ITKImageProcessing/pipelines/(03) Porosity Mesh Export.d3dpipeline index ad4c92030e..bc9a980bc7 100644 --- a/src/Plugins/ITKImageProcessing/pipelines/(03) Porosity Mesh Export.d3dpipeline +++ b/src/Plugins/ITKImageProcessing/pipelines/(03) Porosity Mesh Export.d3dpipeline @@ -252,6 +252,10 @@ }, { "args": { + "bounding_box_skin_mode_index": { + "value": 0, + "version": 1 + }, "face_data_group_name": { "value": "Face Data", "version": 1 @@ -288,7 +292,7 @@ "value": "TriangleDataContainer", "version": 1 }, - "parameters_version": 2, + "parameters_version": 3, "repair_triangle_winding": { "value": true, "version": 1 diff --git a/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(05) Small IN100 Quick Mesh.d3dpipeline b/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(05) Small IN100 Quick Mesh.d3dpipeline index 483fa71e4c..11a0f48f19 100644 --- a/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(05) Small IN100 Quick Mesh.d3dpipeline +++ b/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(05) Small IN100 Quick Mesh.d3dpipeline @@ -128,6 +128,10 @@ }, { "args": { + "bounding_box_skin_mode_index": { + "value": 0, + "version": 1 + }, "face_data_group_name": { "value": "Face Data", "version": 1 @@ -164,7 +168,7 @@ "value": "TriangleDataContainer", "version": 1 }, - "parameters_version": 2, + "parameters_version": 3, "repair_triangle_winding": { "value": true, "version": 1 diff --git a/src/Plugins/SimplnxCore/docs/ExtractInternalSurfacesFromTriangleGeometryFilter.md b/src/Plugins/SimplnxCore/docs/ExtractInternalSurfacesFromTriangleGeometryFilter.md index 90fd3d3b71..3718239da5 100644 --- a/src/Plugins/SimplnxCore/docs/ExtractInternalSurfacesFromTriangleGeometryFilter.md +++ b/src/Plugins/SimplnxCore/docs/ExtractInternalSurfacesFromTriangleGeometryFilter.md @@ -6,34 +6,44 @@ Geometry ## Description -This **Filter** extracts any **Triangles** from the supplied **Triangle Geometry** that contain any *internal nodes*, then uses these extracted **Triangles** to create a new **Data Container** with the reduced **Triangle Geometry**. This operation is the same as removing all **Triangles** that only lie of the outer surface of the supplied **Triangle Geometry**. The user must supply a "Node Type" **Vertex Attribute Array** that defines the type for each node of the **Triangle Geometry**. Node types may take the following values: +This **Filter** extracts any **Triangles** from the supplied **Triangle Geometry** that contain any *internal nodes*, then uses these extracted **Triangles** to create a new **Data Container** with the reduced **Triangle Geometry**. This operation is the same as removing all **Triangles** that only lie of the outer surface of the supplied **Triangle Geometry**. Internal surfaces can be identified two ways, chosen with the **Internal Surface Criterion** parameter below. -### Quick Surface Mesh +### Node Type Range Criterion -| Id Value | Node Type | -|----------|-----------| -| 2 | Normal **Vertex | -| 3 | Triple Line | -| 4 | Quadruple Point | -| 12 | Normal **Vertex** on the outer surface | -| 13 | Triple Line on the outer surface | -| 14 | Quadruple Point on the outer surface | - -### SurfaceNets Mesh +With the **Node Type Range** criterion, the user must supply a "Node Type" **Vertex Attribute Array** that defines the type for each node of the **Triangle Geometry**. All three surface meshing **Filters** (Create Surface Mesh (QuickMesh), Create Surface Mesh (Surface Nets), and Create Surface Mesh (M3C)) emit Node Types in the same convention: | Id Value | Node Type | |----------|-----------| -| 0 | Normal **Vertex | +| 2 | Normal **Vertex** | | 3 | Triple Line | -| 4-8 | Quadruple Point | -| 13 | Normal **Vertex** on the outer surface | -| 14 | Triple Line on the outer surface | -| 15-18 | Quadruple Point on the outer surface | +| 4 | Quadruple Point (four or more Features meet) | +| 12 | Normal **Vertex** on the outer surface of the bounding box | +| 13 | Triple Line on the outer surface of the bounding box | +| 14 | Quadruple Point on the outer surface of the bounding box | + +The value is the number of distinct Features meeting at the node, capped at 4, plus 10 when +the node lies on the bounding box wall. The region outside the volume counts as one of those +owners, which is why an ordinary vertex on the wall between the exterior and a single Feature +is `12` — two owners, one of them the exterior — rather than `11`. This **Filter** has the effect of removing any **Triangles** that only contain **Vertices** whose node Id values fall outside of the min and max that the user sets. In general, this *node type* array is created when the original surface mesh is created. It is unknown until runtime how the **Geometry** will be changed by removing certain **Vertices** and **Triangles**. +### Choosing a Criterion + +**Node Type Range** keeps a triangle only when all three of its nodes have a Node Type +inside the given range. Because every node on the bounding box wall is promoted by 10, this +also discards internal boundary triangles that merely *touch* the wall — leaving a +one-triangle-wide gap wherever an internal surface meets the box. A Feature flush with the +box wall will come out open. + +**Face Labels** instead discards only the faces whose Face Labels are `{-1, 0}`: the +bounding box wall where it borders the background. Faces where the wall caps a real Feature +are kept, so Features flush with the box stay closed, and no rim is eroded. This is the +same rule as the **Bounding Box Skin** option's **Background-Backed Walls Only** mode on the +surface meshing filters, and is the better choice for meshes that already exist. + % Auto generated parameter table will be inserted here ## Example Pipelines diff --git a/src/Plugins/SimplnxCore/docs/HierarchicalSmoothFilter.md b/src/Plugins/SimplnxCore/docs/HierarchicalSmoothFilter.md index a0538686fc..7563ca7b22 100644 --- a/src/Plugins/SimplnxCore/docs/HierarchicalSmoothFilter.md +++ b/src/Plugins/SimplnxCore/docs/HierarchicalSmoothFilter.md @@ -14,6 +14,12 @@ This **Filter** applies hierarchical smoothing to a triangle surface mesh repres This hierarchical approach preserves the topology of the grain boundary network while producing smooth surfaces. The smoothing parameter is optimized via interval bisection to balance smoothness against displacement from the original mesh. +Because every stage above is driven by the Node Type value, smoothing a mesh produced by Create +Surface Mesh (Surface Nets) now behaves differently than it did previously: that **Filter**'s Node +Types were corrected to follow the `2`/`3`/`4`/`12`/`13`/`14` convention this **Filter** expects, +so vertices that were previously misclassified (and therefore held fixed or smoothed in the wrong +stage) are now classified correctly. This is a correction, not a regression. + Nodes that are displaced beyond the error threshold (a multiple of a reference edge length) are rejected and reset to their original positions. ## Algorithm Overview @@ -48,12 +54,12 @@ where `I` is the identity matrix, `L` is the reduced graph Laplacian, `y_origina The algorithm uses **interval bisection** to find the optimal `epsilon`: -1. Start at `epsilon = 0.5`. -2. Compute a numerical derivative of the objective function (the total Laplacian residual energy). -3. If the derivative is near zero (flat region), the current `epsilon` is not in the active tradeoff zone. Halve `epsilon` and try again. -4. Repeat until either a significant slope is found or the iteration limit is reached. +1. Start at `epsilon = 0.5`, with a step size of `epsilon / 2`. +2. Compute a numerical derivative (slope) of the objective function (the total Laplacian residual energy) at the current `epsilon`. +3. If the slope's magnitude is still significant, step `epsilon` up or down (in the direction that reduces the slope) by the current step size, then halve the step size. +4. Repeat until the slope's magnitude falls below the convergence threshold (a flat region — the optimal `epsilon` has been found) or the iteration limit is reached. -The **Max Bisection Iterations** parameter controls how many halvings are attempted. The default value of **53** comes from `log2(10^16) ~ 53`, which is the number of bisection steps needed to resolve a double-precision floating point value to machine epsilon. In practice, the search often converges in far fewer iterations because it terminates early once a significant slope is detected. +The **Max Bisection Iterations** parameter controls how many halvings are attempted. The default value of **53** comes from `log2(10^16) ~ 53`, which is the number of bisection steps needed to resolve a double-precision floating point value to machine epsilon. In practice, the search often converges in far fewer iterations because it stops as soon as the slope flattens out below the convergence threshold, well before the iteration limit is reached. **Practical guidance:** diff --git a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_0.png b/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_0.png deleted file mode 100644 index 26b7d270f0..0000000000 Binary files a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_0.png and /dev/null differ diff --git a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_2.png b/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_2.png deleted file mode 100644 index 238bcd7586..0000000000 Binary files a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_2.png and /dev/null differ diff --git a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_3.png b/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_3.png deleted file mode 100644 index 8fa3ebfec6..0000000000 Binary files a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_3.png and /dev/null differ diff --git a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_4.png b/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_4.png deleted file mode 100644 index 2f06fab44c..0000000000 Binary files a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_4.png and /dev/null differ diff --git a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_6.png b/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_6.png deleted file mode 100644 index 0bd786f647..0000000000 Binary files a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_6.png and /dev/null differ diff --git a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_Exterior.png b/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_Exterior.png deleted file mode 100644 index 9bfcf94f68..0000000000 Binary files a/src/Plugins/SimplnxCore/docs/Images/SurfaceNets_NodeType_Exterior.png and /dev/null differ diff --git a/src/Plugins/SimplnxCore/docs/LaplacianSmoothingFilter.md b/src/Plugins/SimplnxCore/docs/LaplacianSmoothingFilter.md index fc52a99704..b412eac23a 100644 --- a/src/Plugins/SimplnxCore/docs/LaplacianSmoothingFilter.md +++ b/src/Plugins/SimplnxCore/docs/LaplacianSmoothingFilter.md @@ -60,20 +60,27 @@ At the conclusion of the filter these extra internal arrays will be reclaimed by The values for the *Node Type* array can take one of the following values. - namespace SurfaceMesh { + namespace nx::core { namespace NodeType { - const int8_t Unused = -1; - const int8_t Default = 2; - const int8_t TriplePoint = 3; - const int8_t QuadPoint = 4; - const int8_t SurfaceDefault = 12; - const int8_t SurfaceTriplePoint = 13; - const int8_t SurfaceQuadPoint = 14; + inline constexpr int8_t Unused = 0; + inline constexpr int8_t Default = 2; + inline constexpr int8_t TriplePoint = 3; + inline constexpr int8_t QuadPoint = 4; + inline constexpr int8_t SurfaceDefault = 12; + inline constexpr int8_t SurfaceTriplePoint = 13; + inline constexpr int8_t SurfaceQuadPoint = 14; } } If your surface mesh is lacking a `Node Type` array, you can simply create a DataArray inside the Vertex Data Attribute Matrix. The type should be "int8" and have an initialization value of 3. This will allow **all** nodes to move. +Any Node Type value not listed above (including `0`/`Unused`) leaves that vertex's lambda at `0.0`, +so the vertex does not move. Smoothing a mesh produced by Create Surface Mesh (Surface Nets) now +behaves differently than it did previously, because that **Filter**'s Node Types were corrected to +follow this convention: most vertices in a Surface Nets mesh were previously pinned in place +because their Node Type value did not match any of the cases above. This is a correction, not a +regression — those vertices are now free to move as their Node Type intends. + For more information on surface meshing, visit the tutorial. % Auto generated parameter table will be inserted here diff --git a/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md b/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md index 09ab88f613..90fa238535 100644 --- a/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md +++ b/src/Plugins/SimplnxCore/docs/M3CSurfaceMeshingFilter.md @@ -40,7 +40,10 @@ modeling and simulation workflows. ### Node Types The **Node Types** array uses the same convention as the Surface Nets **Filter**. Interior values -denote how many **Features** meet at the vertex; the exterior (volume-boundary) variants add 10. +denote how many **Features** meet at the vertex, capped at 4; the exterior (volume-boundary) +variants add 10. The region outside the volume counts as one of those owners, which is why an +ordinary vertex on the wall between the exterior and a single Feature is `12` — two owners, one +of them the exterior — rather than `11`. | Node Type | Meaning | |-----------|---------| @@ -64,6 +67,67 @@ heuristic, which does not guarantee globally consistent normals across the whole consistent across connected faces. This is recommended for meshes that will be used for normal- or curvature-dependent analysis. +### Omitting the Bounding Box Skin + +By default this filter generates triangles covering all six outer walls of the Image +Geometry's bounding box. These faces are artifacts of where the volume was cropped rather +than real interfaces, and they receive a Face Label of `-1` on the exterior side. + +Enabling the **Bounding Box Skin** option's **Background-Backed Walls Only** mode suppresses a +wall face when the voxel behind it is background (Feature Id 0) — that is, when its Face Labels would be `{-1, 0}`. Wall faces +that cap a *real* Feature are still generated, because that cut plane is the only possible +closure for a Feature flush with the box. A cylinder sitting flush with the box floor +therefore comes out as a closed surface with no surrounding box. + +On a fully-indexed volume with no Feature Id 0 voxels, nothing is dropped and the option +has no effect — every boundary Feature already needs its wall cap to stay closed. The same warning +also fires on a volume whose background is fully enclosed as interior porosity: no bounding-box +wall voxel is background-backed even though the volume is full of Feature Id 0 internally, so the +option again drops nothing. Rather than silently doing nothing, the **Filter** reports this with a +warning (code `-56342`): no bounding-box **wall** face is backed by background, so the option has +nothing to prune on this input. The warning says nothing about whether the volume contains +background elsewhere — only that none of it borders a wall. + +Because the test is per-face rather than per-vertex, no triangles are lost along the rim +where an internal boundary meets the box wall. With the option **off**, wherever an internal +Feature-Feature boundary meets the bounding box wall, three faces share that edge: the internal +boundary quad and the two wall quads on either side of it — a non-manifold T-junction that is +inherent to including the full box skin. With the option **on**, the background-backed wall quad +on that edge is dropped, leaving exactly two faces sharing it, which is manifold. The option +therefore does not merely remove unwanted geometry: in the configurations exercised by this +**Filter**'s cross-mesher conformance test (a cylinder **Feature** flush with the box wall) and by +a separate corner-Feature test (a **Feature** occupying a box corner where three suppressed wall +planes meet), enabling it produces a watertight mesh where leaving it off does not. This is not a +universal guarantee of watertightness for arbitrary input. + +If every voxel in the volume is background (Feature Id 0), every face is a background-backed +wall face and the option removes all of them. The **Filter** reports a warning (code `-56340`) +and creates the **Triangle Geometry** with zero faces. Unlike Create Surface Mesh (QuickMesh) and +Surface Nets, this is **not** zero vertices for M3C: as described in the note below, M3C's +candidate-node generation leaves a handful of pre-existing orphan vertices that the option's +pruning does not clear, so they remain in the output. This is treated as success, not an error, +because the input is legal — it simply contains no internal interface and no Feature to cap. + +### Feature Id Validation + +Independently of the **Bounding Box Skin** setting, this **Filter** always rejects a **Feature +Ids** array that contains a negative value or a value equal to `INT32_MAX`, because both collide +with sentinel values M3C's algorithm uses internally to represent ghost cells and the outside of +the volume. A **Feature Id** must therefore be in the range `0` to `INT32_MAX - 1`. A rejected +value produces an error (code `-56343`) naming the offending value, its tuple index, and the +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 produces a handful of node entries near the +volume boundary that no triangle references, even with the option disabled — these pre-existing +orphan vertices are present in stock M3C output regardless of the **Bounding Box Skin** setting +(tracked as issue #1706). The **Bounding Box Skin** option's **Background-Backed Walls Only** mode +does not touch them: it clears only the vertices that its own pruning orphans (vertices that were +referenced exclusively by a dropped wall face), and leaves every pre-existing orphan exactly as it +was. Consequently, on an all-background volume the pruned output is zero faces with those +pre-existing orphan vertices still present — which is why the `-56340` warning above reports the +remaining vertex count rather than assuming it is zero. + ### Notes and Limitations - The volume is automatically wrapped in a temporary ghost layer so that **Features** touching the diff --git a/src/Plugins/SimplnxCore/docs/QuickSurfaceMeshFilter.md b/src/Plugins/SimplnxCore/docs/QuickSurfaceMeshFilter.md index 3938111fa9..f97ad9b4b3 100644 --- a/src/Plugins/SimplnxCore/docs/QuickSurfaceMeshFilter.md +++ b/src/Plugins/SimplnxCore/docs/QuickSurfaceMeshFilter.md @@ -56,6 +56,11 @@ NodeType = 4 One of the arrays to come out of the algorithm is the "Node Type" vertex array. This array uses a value to label each vertex as to what kind of node it was determined to be during the meshing process. +The value is the number of distinct **Features** meeting at the node, capped at 4, plus 10 when the +node lies on the outer surface of the bounding box. The region outside the volume counts as one of +those owners, which is why an ordinary vertex on the wall between the exterior and a single Feature +is `12` — two owners, one of them the exterior — rather than `11`. + | Value | Description | |-------|-------------| | 2 | Node within the interior of the grain face. | @@ -65,6 +70,10 @@ One of the arrays to come out of the algorithm is the "Node Type" vertex array. | 13 | Node that is on the exterior of the mesh and is a triple line | | 14 | Node that is on the exterior of the mesh and is a quadruple point | +When the **Bounding Box Skin** option below prunes wall faces, the Node Type of every surviving +vertex is unchanged from the value it had in the full (unpruned) mesh; this is asserted by a unit +test. Create Surface Mesh (M3C) offers no such guarantee. + ### Exterior or Boundary Triangles Each triangle that is created will have an 2 component attribute called `Face Labels` that represent the Feature ID on either @@ -97,6 +106,55 @@ DREAM3D-NX provides three **Filters** that convert a segmented grid into a multi **Guidance:** Surface Nets is the recommended default for most workflows — it yields the smoothest mesh with the fewest triangles and preserves sharp boundaries. Use M3C when a primal, marching-cubes case-table topology is required for a specific downstream modeling or simulation workflow. QuickMesh is retained for backward compatibility. +### Omitting the Bounding Box Skin + +By default this filter generates triangles covering all six outer walls of the Image +Geometry's bounding box. These faces are artifacts of where the volume was cropped rather +than real interfaces, and they receive a Face Label of `-1` on the exterior side. + +Enabling the **Bounding Box Skin** option's **Background-Backed Walls Only** mode suppresses a +wall face when the voxel behind it is background (Feature Id 0) — that is, when its Face Labels would be `{-1, 0}`. Wall faces +that cap a *real* Feature are still generated, because that cut plane is the only possible +closure for a Feature flush with the box. A cylinder sitting flush with the box floor +therefore comes out as a closed surface with no surrounding box. + +On a fully-indexed volume with no Feature Id 0 voxels, nothing is dropped and the option +has no effect — every boundary Feature already needs its wall cap to stay closed. The same warning +also fires on a volume whose background is fully enclosed as interior porosity: no bounding-box +wall voxel is background-backed even though the volume is full of Feature Id 0 internally, so the +option again drops nothing. Rather than silently doing nothing, the **Filter** reports this with a +warning (code `-56342`): no bounding-box **wall** face is backed by background, so the option has +nothing to prune on this input. The warning says nothing about whether the volume contains +background elsewhere — only that none of it borders a wall. + +Because the test is per-face rather than per-vertex, no triangles are lost along the rim +where an internal boundary meets the box wall. With the option **off**, wherever an internal +Feature-Feature boundary meets the bounding box wall, three faces share that edge: the internal +boundary quad and the two wall quads on either side of it — a non-manifold T-junction that is +inherent to including the full box skin. With the option **on**, the background-backed wall quad +on that edge is dropped, leaving exactly two faces sharing it, which is manifold. The option +therefore does not merely remove unwanted geometry: in the configurations exercised by this +**Filter**'s cross-mesher conformance test (a cylinder **Feature** flush with the box wall) and by +a separate corner-Feature test (a **Feature** occupying a box corner where three suppressed wall +planes meet), enabling it produces a watertight mesh where leaving it off does not. This is not a +universal guarantee of watertightness for arbitrary input. + +If every voxel in the volume is background (Feature Id 0), every face is a background-backed +wall face and the option removes all of them. The **Filter** reports a warning (code `-56340`) +and creates the **Triangle Geometry** with zero vertices and zero faces; this is treated as +success, not an error, because the input is legal — it simply contains no internal interface +and no Feature to cap. + +### Feature Id Validation + +Independently of the **Bounding Box Skin** setting, this **Filter** always rejects a **Feature +Ids** array that contains a negative value, because negative values collide with an internal +ghost/exterior sentinel convention. A **Feature Id** must therefore be `>= 0` (this **Filter**, +unlike Create Surface Mesh (Surface Nets) and Create Surface Mesh (M3C), does not additionally +reject `INT32_MAX`). A rejected value produces an error (code `-56343`) naming the offending value, +its tuple index, and the array's **Data Path**. This is a mitigation for the underlying +sentinel-collision design (tracked as issue #1705), not a fix for it. + % Auto generated parameter table will be inserted here ## Example Pipelines diff --git a/src/Plugins/SimplnxCore/docs/SurfaceNetsFilter.md b/src/Plugins/SimplnxCore/docs/SurfaceNetsFilter.md index 81a3b1a1b1..043674e49b 100644 --- a/src/Plugins/SimplnxCore/docs/SurfaceNetsFilter.md +++ b/src/Plugins/SimplnxCore/docs/SurfaceNetsFilter.md @@ -39,26 +39,20 @@ SurfaceNets output **with** the built-in smoothing operation applied. ## Node Types -During the meshing process, each vertex, or node, will get a "Node Type" value assigned to it. These will range from 0 to 6. The value is an internal representation from the SurfaceNets algorithm. They are roughly equivelent to the Node Types from the Quick Surface Mesh algorithm but not strictly the same. +During the meshing process, each vertex, or node, will get a "Node Type" value assigned to it. This filter uses the same convention as the Create Surface Mesh (QuickMesh) and Create Surface Mesh (M3C) **Filters**: the value is the number of distinct **Features** meeting at the node, capped at 4, plus 10 when the node lies on the outer surface of the bounding box. The region outside the volume counts as one of those owners, which is why an ordinary vertex on the wall between the exterior and a single Feature is `12` — two owners, one of them the exterior — rather than `11`. -- Node Type = 0: This is a node that ONLY has 2 features connected to the node. -- Node Type = 2: This is a node that has 3 features connected to the node, such as a triple line -- Node Type = 3-6: These nodes have 4 or more features connected to the node. - - -| Node Type | Example Image | -|-----------|----------------------------------------------| -| 0 | ![Node Type 0](Images/SurfaceNets_NodeType_0.png)| -| 2 | ![Node Type 2](Images/SurfaceNets_NodeType_2.png)| -| 3 | ![Node Type 3](Images/SurfaceNets_NodeType_3.png)| -| 4 | ![Node Type 4](Images/SurfaceNets_NodeType_4.png)| -| 6 | ![Node Type 6](Images/SurfaceNets_NodeType_6.png)| +| Id Value | Node Type | +|----------|-----------| +| 2 | Normal **Vertex** | +| 3 | Triple Line | +| 4 | Quadruple Point (four or more Features meet) | +| 12 | Normal **Vertex** on the outer surface of the bounding box | +| 13 | Triple Line on the outer surface of the bounding box | +| 14 | Quadruple Point on the outer surface of the bounding box | ### Exterior or Boundary Nodes -Nodes that appear on the exterior of a volume have Node Type values starting at 10 and going up from there. For instance, a triple line that is also on the exterior of the volume should have a value of 12. - -![Exterior Node Types](Images/SurfaceNets_NodeType_Exterior.png) +Nodes that appear on the outer surface of the bounding box have Node Type values starting at 12 and going up from there. For instance, a triple line that is also on the exterior of the volume has a value of 13. ### Exterior or Boundary Triangles @@ -70,6 +64,30 @@ have a value of -1. This filter should be used in place of the "QuickMesh Surface Filter". +## Changed Behavior for Existing Pipelines + +Two defects in this filter's output were fixed as part of adding the **Bounding Box Skin** +option below. Both fixes apply unconditionally -- they are not gated by that new option -- so any +pipeline that already used this **Filter** will see different (corrected) output the next time it +runs, with no parameter change on the user's part: + +- **Face Labels**: a face between the exterior and Feature Id 0 is now reported as `{-1, 0}`. + Previously it was reported as `{-1, -1}`. Likewise, a face between Feature Id 0 and another + Feature `k` is now `{0, k}`; previously it was reported as `{-1, k}`. +- **Node Types**: values were previously a junction-face-crossing count in the range `0`-`6`, with + exterior-node variants offset inconsistently. They now follow the shared convention described + above (`2`/`3`/`4` for interior nodes, `12`/`13`/`14` on the bounding box wall), matching Create + Surface Mesh (QuickMesh) and Create Surface Mesh (M3C). +- **Vertex Z coordinate on anisotropic spacing**: the half-voxel offset this **Filter** applies when + relocating each vertex used the **Y** spacing for the Z component instead of the **Z** spacing. + Every vertex's Z coordinate was wrong on any **Image Geometry** with anisotropic spacing (Z + spacing different from Y spacing); it is correct now. Volumes with isotropic spacing (all three + spacing values equal) were unaffected and see no change from this fix. + +If you hand-tuned a Node Type range parameter against this **Filter**'s old values, if you rely on +smoothing **Filters** (Laplacian Smoothing, Hierarchical Smoothing) downstream that read Node +Types, or if you compare newly generated output against previously stored SurfaceNets results +generated on anisotropic-spacing data, re-check it against the corrected behavior above. ## Comparison of Surface Meshing Filters @@ -88,6 +106,55 @@ DREAM3D-NX provides three **Filters** that convert a segmented grid into a multi **Guidance:** Surface Nets is the recommended default for most workflows — it yields the smoothest mesh with the fewest triangles and preserves sharp boundaries. Use M3C when a primal, marching-cubes case-table topology is required for a specific downstream modeling or simulation workflow. QuickMesh is retained for backward compatibility. +### Omitting the Bounding Box Skin + +By default this filter generates triangles covering all six outer walls of the Image +Geometry's bounding box. These faces are artifacts of where the volume was cropped rather +than real interfaces, and they receive a Face Label of `-1` on the exterior side. + +Enabling the **Bounding Box Skin** option's **Background-Backed Walls Only** mode suppresses a +wall face when the voxel behind it is background (Feature Id 0) — that is, when its Face Labels would be `{-1, 0}`. Wall faces +that cap a *real* Feature are still generated, because that cut plane is the only possible +closure for a Feature flush with the box. A cylinder sitting flush with the box floor +therefore comes out as a closed surface with no surrounding box. + +On a fully-indexed volume with no Feature Id 0 voxels, nothing is dropped and the option +has no effect — every boundary Feature already needs its wall cap to stay closed. The same warning +also fires on a volume whose background is fully enclosed as interior porosity: no bounding-box +wall voxel is background-backed even though the volume is full of Feature Id 0 internally, so the +option again drops nothing. Rather than silently doing nothing, the **Filter** reports this with a +warning (code `-56342`): no bounding-box **wall** face is backed by background, so the option has +nothing to prune on this input. The warning says nothing about whether the volume contains +background elsewhere — only that none of it borders a wall. + +Because the test is per-face rather than per-vertex, no triangles are lost along the rim +where an internal boundary meets the box wall. With the option **off**, wherever an internal +Feature-Feature boundary meets the bounding box wall, three faces share that edge: the internal +boundary quad and the two wall quads on either side of it — a non-manifold T-junction that is +inherent to including the full box skin. With the option **on**, the background-backed wall quad +on that edge is dropped, leaving exactly two faces sharing it, which is manifold. The option +therefore does not merely remove unwanted geometry: in the configurations exercised by this +**Filter**'s cross-mesher conformance test (a cylinder **Feature** flush with the box wall) and by +a separate corner-Feature test (a **Feature** occupying a box corner where three suppressed wall +planes meet), enabling it produces a watertight mesh where leaving it off does not. This is not a +universal guarantee of watertightness for arbitrary input. + +If every voxel in the volume is background (Feature Id 0), every face is a background-backed +wall face and the option removes all of them. The **Filter** reports a warning (code `-56340`) +and creates the **Triangle Geometry** with zero vertices and zero faces; this is treated as +success, not an error, because the input is legal — it simply contains no internal interface +and no Feature to cap. + +### Feature Id Validation + +Independently of the **Bounding Box Skin** setting, this **Filter** always rejects a **Feature +Ids** array that contains a negative value or a value equal to `INT32_MAX`, because both collide +with sentinel values this **Filter**'s underlying `MMSurfaceNet` implementation uses internally to +mark padding. A **Feature Id** must therefore be in the range `0` to `INT32_MAX - 1`. A rejected +value produces an error (code `-56343`) naming the offending value, its tuple index, and the +array's **Data Path**. This is a mitigation for the underlying sentinel-collision design (tracked +as issue #1705), not a fix for it. + % Auto generated parameter table will be inserted here ## Example Pipelines diff --git a/src/Plugins/SimplnxCore/pipelines/Compute_Feature_Face_Curvature_Small_IN100.d3dpipeline b/src/Plugins/SimplnxCore/pipelines/Compute_Feature_Face_Curvature_Small_IN100.d3dpipeline index 9c4cead2d8..2c0a1a0803 100644 --- a/src/Plugins/SimplnxCore/pipelines/Compute_Feature_Face_Curvature_Small_IN100.d3dpipeline +++ b/src/Plugins/SimplnxCore/pipelines/Compute_Feature_Face_Curvature_Small_IN100.d3dpipeline @@ -158,6 +158,10 @@ "value": true, "version": 1 }, + "bounding_box_skin_mode_index": { + "value": 0, + "version": 1 + }, "face_data_group_name": { "value": "Face Data", "version": 1 @@ -194,7 +198,7 @@ "value": "TriangleDataContainer", "version": 1 }, - "parameters_version": 1, + "parameters_version": 2, "relaxation_factor": { "value": 0.5, "version": 1 diff --git a/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline b/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline index 8ec624b6cb..23ee26d731 100644 --- a/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline +++ b/src/Plugins/SimplnxCore/pipelines/M3C_Demo.d3dpipeline @@ -13,6 +13,14 @@ "value": false, "version": 1 }, + "change_origin": { + "value": false, + "version": 1 + }, + "change_spacing": { + "value": false, + "version": 1 + }, "color_weights": { "value": [ 0.21250000596046448, @@ -25,6 +33,40 @@ "value": false, "version": 1 }, + "cropping_options": { + "value": { + "bounds_x_physical": [ + 0.0, + 0.0 + ], + "bounds_x_voxels": [ + 0, + 0 + ], + "bounds_y_physical": [ + 0.0, + 0.0 + ], + "bounds_y_voxels": [ + 0, + 0 + ], + "bounds_z_physical": [ + 0.0, + 0.0 + ], + "bounds_z_voxels": [ + 0, + 0 + ], + "crop_x": true, + "crop_y": true, + "crop_z": true, + "is_2d": false, + "type": 0 + }, + "version": 1 + }, "exact_xy_dimensions": { "value": [ 100, @@ -66,6 +108,10 @@ ], "version": 1 }, + "origin_spacing_processing_index": { + "value": 1, + "version": 1 + }, "output_image_geometry_path": { "value": "RoboMet.3D Image Stack", "version": 1 @@ -206,8 +252,16 @@ }, { "args": { - "input_grid_geometry_path": { - "value": "RoboMet.3D Image Stack", + "bounding_box_skin_mode_index": { + "value": 0, + "version": 1 + }, + "face_data_group_name": { + "value": "Face Data", + "version": 1 + }, + "face_labels_array_name": { + "value": "FaceLabels", "version": 1 }, "feature_ids_path": { @@ -222,31 +276,27 @@ "value": [], "version": 1 }, - "repair_triangle_winding": { - "value": true, - "version": 1 - }, - "output_triangle_geometry_path": { - "value": "TriangleDataContainer", - "version": 1 - }, - "vertex_data_group_name": { - "value": "Vertex Data", + "input_grid_geometry_path": { + "value": "RoboMet.3D Image Stack", "version": 1 }, "node_types_array_name": { "value": "NodeTypes", "version": 1 }, - "face_data_group_name": { - "value": "Face Data", + "output_triangle_geometry_path": { + "value": "TriangleDataContainer", "version": 1 }, - "face_labels_array_name": { - "value": "FaceLabels", + "parameters_version": 2, + "repair_triangle_winding": { + "value": true, "version": 1 }, - "parameters_version": 1 + "vertex_data_group_name": { + "value": "Vertex Data", + "version": 1 + } }, "comments": "", "filter": { @@ -257,11 +307,19 @@ }, { "args": { + "compression_level": { + "value": 5, + "version": 1 + }, "export_file_path": { "value": "Data/Output/SurfaceMesh/M3C_Demo.dream3d", "version": 1 }, - "parameters_version": 1, + "parameters_version": 2, + "use_compression": { + "value": true, + "version": 1 + }, "write_xdmf_file": { "value": true, "version": 1 diff --git a/src/Plugins/SimplnxCore/pipelines/SurfaceNets_Demo.d3dpipeline b/src/Plugins/SimplnxCore/pipelines/SurfaceNets_Demo.d3dpipeline index 97256e6470..59218e6166 100644 --- a/src/Plugins/SimplnxCore/pipelines/SurfaceNets_Demo.d3dpipeline +++ b/src/Plugins/SimplnxCore/pipelines/SurfaceNets_Demo.d3dpipeline @@ -256,6 +256,10 @@ "value": true, "version": 1 }, + "bounding_box_skin_mode_index": { + "value": 0, + "version": 1 + }, "face_data_group_name": { "value": "Face Data", "version": 1 @@ -292,7 +296,7 @@ "value": "TriangleDataContainer", "version": 1 }, - "parameters_version": 1, + "parameters_version": 2, "relaxation_factor": { "value": 0.5, "version": 1 diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.cpp index f866f6c1b5..1451c735bb 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.cpp @@ -73,7 +73,34 @@ Result<> ExtractInternalSurfacesFromTriangleGeometry::operator()() auto numVerts = triangleGeom.getNumberOfVertices(); auto numTris = triangleGeom.getNumberOfFaces(); - auto& nodeTypes = m_DataStructure.getDataRefAs(m_InputValues->NodeTypesPath); + using MeshIndexType = IGeometry::MeshIndexType; + + // Only the array the selected criterion needs is read. + const Int8AbstractDataStore* nodeTypesPtr = nullptr; + const Int32AbstractDataStore* faceLabelsPtr = nullptr; + if(m_InputValues->CriterionMode == 0) + { + nodeTypesPtr = &m_DataStructure.getDataRefAs(m_InputValues->NodeTypesPath).getDataStoreRef(); + } + else + { + faceLabelsPtr = &m_DataStructure.getDataRefAs(m_InputValues->FaceLabelsPath).getDataStoreRef(); + } + + // True when this triangle belongs to the extracted internal surface. + const auto isInternalTriangle = [&](MeshIndexType triIndex, MeshIndexType v0Index, MeshIndexType v1Index, MeshIndexType v2Index) -> bool { + if(m_InputValues->CriterionMode == 0) + { + const auto& nodeTypes = *nodeTypesPtr; + return (nodeTypes[v0Index] >= minMaxNodeValues[0] && nodeTypes[v0Index] <= minMaxNodeValues[1]) && (nodeTypes[v1Index] >= minMaxNodeValues[0] && nodeTypes[v1Index] <= minMaxNodeValues[1]) && + (nodeTypes[v2Index] >= minMaxNodeValues[0] && nodeTypes[v2Index] <= minMaxNodeValues[1]); + } + // Face Labels mode: drop only the bounding box wall backed by background. + const auto& faceLabels = *faceLabelsPtr; + const int32 labelA = faceLabels[triIndex * 2]; + const int32 labelB = faceLabels[triIndex * 2 + 1]; + return !((labelA == -1 && labelB == 0) || (labelB == -1 && labelA == 0)); + }; auto internalVerticesPath = internalTrianglesPath.createChildPath(TriangleGeom::k_SharedVertexListName); internalTriangleGeom.setVertices(*m_DataStructure.getDataAs(internalVerticesPath)); @@ -85,8 +112,6 @@ Result<> ExtractInternalSurfacesFromTriangleGeometry::operator()() // int64 prog = 1; // int64 progressInt = 0; // int64 counter = 0; - using MeshIndexType = IGeometry::MeshIndexType; - const MeshIndexType notSeen = std::numeric_limits::max(); std::vector vertNewIndex(numVerts, notSeen); @@ -100,11 +125,9 @@ Result<> ExtractInternalSurfacesFromTriangleGeometry::operator()() MeshIndexType v0Index = triangles[3 * triIndex + 0]; MeshIndexType v1Index = triangles[3 * triIndex + 1]; MeshIndexType v2Index = triangles[3 * triIndex + 2]; - // Check if the NodeType is either 2, 3, 4 - if((nodeTypes[v0Index] >= minMaxNodeValues[0] && nodeTypes[v0Index] <= minMaxNodeValues[1]) && (nodeTypes[v1Index] >= minMaxNodeValues[0] && nodeTypes[v1Index] <= minMaxNodeValues[1]) && - (nodeTypes[v2Index] >= minMaxNodeValues[0] && nodeTypes[v2Index] <= minMaxNodeValues[1])) + if(isInternalTriangle(triIndex, v0Index, v1Index, v2Index)) { - // All Nodes are the correct type + // This triangle belongs to the extracted internal surface triNewIndex[triIndex] = currentNewTriIndex; currentNewTriIndex++; // increment the index into which this triangle would be place in the new triangle array // Now figure out if we have seen each vertex diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.hpp index 60504734d5..8cec172225 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ExtractInternalSurfacesFromTriangleGeometry.hpp @@ -26,8 +26,10 @@ struct SIMPLNXCORE_EXPORT ExtractInternalSurfacesFromTriangleGeometryInputValues MultiArraySelectionParameter::ValueType CopyTriangleArrayPaths; MultiArraySelectionParameter::ValueType CopyVertexArrayPaths; GeometrySelectionParameter::ValueType InputTriangleGeometryPath; + uint64 CriterionMode; VectorInt8Parameter::ValueType NodeTypeRange; ArraySelectionParameter::ValueType NodeTypesPath; + DataPath FaceLabelsPath; DataGroupCreationParameter::ValueType OutputTriangleGeometryPath; DataObjectNameParameter::ValueType TriangleAttributeMatrixName; DataObjectNameParameter::ValueType VertexAttributeMatrixName; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp index 226dee276c..2013369d2a 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.cpp @@ -2535,13 +2535,109 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput { const int64 nTriangle = static_cast(triangles.size()); + // Faces suppressed by the prune below. Stays 0 when the mode is Off, or when the mode is on but + // nothing matched. Read after the winding-repair pass to decide which (if either) of the two + // Bounding Box Skin warnings to emit. + int64 numFacesPruned = 0; + + // Bounding Box Skin option, 'Background-Backed Walls Only' mode: drop faces whose output Face Labels would be {-1, 0}. In the + // internal representation that is one negative ghost label paired with maxGrainId, which + // is the renumbered zero-feature (see toFaceLabel below). Pruning the scratch vectors here + // means the output TriangleGeom is sized from the surviving count and never over-allocated. + if(inputValues->BoundingBoxSkinMode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) + { + messageHandler("Omitting bounding box skin faces..."); + + // True when this triangle is a bounding-box wall face backed by background (i.e. its output + // Face Labels would be {-1, 0}). M3C's single sequential pass over `triangles` (below) is the + // only place this predicate is evaluated, so -- unlike QuickSurfaceMesh's SkipWallFace and + // SurfaceNets' SkipPaddingQuad -- there is no second pass it must stay in agreement with. + const auto SkipBackgroundSkinFace = [maxGrainId](const Triangle& triangle) -> bool { + const int spinA = triangle.nSpin[0]; + const int spinB = triangle.nSpin[1]; + return (spinA < 0 && spinB == maxGrainId) || (spinB < 0 && spinA == maxGrainId); + }; + + // Count how many triangles will be dropped before allocating droppedNodeIds: pushing onto an + // unreserved vector here causes dozens of reallocations (and a transient ~1.5x peak) on a + // representative dataset. This adds a second pass over `triangles`, but it only evaluates the + // same boolean predicate the compaction loop below already does -- no allocation -- so it is + // negligible next to the reallocations it avoids. + int64 numToDrop = 0; + for(int64 i = 0; i < nTriangle; i++) + { + if(SkipBackgroundSkinFace(triangles[static_cast(i)])) + { + numToDrop++; + } + } + + int64 survivingCount = 0; + // node_id values touched by a DROPPED triangle, recorded before the in-place compaction below + // overwrites them. Used to narrow the nodeType clear (see below) to exactly the nodes the prune + // itself orphaned, at a cost of O(3 * droppedCount) instead of a second full 7*numSites mask. + std::vector droppedNodeIds; + droppedNodeIds.reserve(static_cast(3 * numToDrop)); + for(int64 i = 0; i < nTriangle; i++) + { + const Triangle& triangle = triangles[static_cast(i)]; + if(SkipBackgroundSkinFace(triangle)) + { + droppedNodeIds.push_back(triangle.node_id[0]); + droppedNodeIds.push_back(triangle.node_id[1]); + droppedNodeIds.push_back(triangle.node_id[2]); + } + else + { + triangles[static_cast(survivingCount)] = triangle; + mCubeID[static_cast(survivingCount)] = mCubeID[static_cast(i)]; + survivingCount++; + } + } + triangles.resize(static_cast(survivingCount)); + mCubeID.resize(static_cast(survivingCount)); + numFacesPruned = nTriangle - survivingCount; + + // Clear nodeType only for nodes the prune itself orphaned: referenced by a DROPPED triangle and + // by no SURVIVING triangle. Nodes referenced only by survivors are left untouched, and + // "pre-existing" candidates that no triangle -- dropped or surviving -- ever referenced are left + // exactly as they were; the option no longer sweeps up orphan candidates it had no hand in creating. + if(!droppedNodeIds.empty()) + { + std::sort(droppedNodeIds.begin(), droppedNodeIds.end()); + droppedNodeIds.erase(std::unique(droppedNodeIds.begin(), droppedNodeIds.end()), droppedNodeIds.end()); + + std::vector referencedBySurvivor(droppedNodeIds.size(), false); + for(const auto& triangle : triangles) + { + for(const SiteId nodeId : triangle.node_id) + { + const auto it = std::lower_bound(droppedNodeIds.begin(), droppedNodeIds.end(), nodeId); + if(it != droppedNodeIds.end() && *it == nodeId) + { + referencedBySurvivor[static_cast(it - droppedNodeIds.begin())] = true; + } + } + } + for(usize i = 0; i < droppedNodeIds.size(); i++) + { + if(!referencedBySurvivor[i]) + { + nodeType[static_cast(droppedNodeIds[i])] = M3CNodeType::k_Unused; + } + } + } + } + + 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 < nTriangle; j++) + for(int64 j = 0; j < nTriangleFinal; j++) { if(triangles[j].nSpin[0] * triangles[j].nSpin[1] < 0) { @@ -2604,15 +2700,15 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput auto& triangleGeom = dataStructure.getDataRefAs(inputValues->TriangleGeometryPath); triangleGeom.resizeVertexList(static_cast(nNodes)); - triangleGeom.resizeFaceList(static_cast(nTriangle)); + triangleGeom.resizeFaceList(static_cast(nTriangleFinal)); triangleGeom.getVertexAttributeMatrix()->resizeTuples({static_cast(nNodes)}); - triangleGeom.getFaceAttributeMatrix()->resizeTuples({static_cast(nTriangle)}); + triangleGeom.getFaceAttributeMatrix()->resizeTuples({static_cast(nTriangleFinal)}); auto& vertexStore = triangleGeom.getVertices()->getDataStoreRef(); auto& triStore = triangleGeom.getFaces()->getDataStoreRef(); auto& faceLabels = dataStructure.getDataRefAs(inputValues->FaceLabelsDataPath).getDataStoreRef(); auto& nodeTypesOut = dataStructure.getDataRefAs(inputValues->NodeTypesDataPath).getDataStoreRef(); - faceLabels.resizeTuples({static_cast(nTriangle)}); + faceLabels.resizeTuples({static_cast(nTriangleFinal)}); nodeTypesOut.resizeTuples({static_cast(nNodes)}); // Compact nodes: scatter coordinates + node types to their new (sequential) ids. Walking candidates @@ -2638,7 +2734,7 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput const auto toFaceLabel = [maxGrainId](int nSpin) -> int32 { return (nSpin < 0) ? -1 : ((nSpin == maxGrainId) ? 0 : nSpin); }; // Triangles: remap to compacted node ids and write the ordered FaceLabels. - for(int64 i = 0; i < nTriangle; i++) + for(int64 i = 0; i < nTriangleFinal; i++) { triStore[static_cast(i) * 3 + 0] = static_cast(compactedNodeId(triangles[i].node_id[0])); triStore[static_cast(i) * 3 + 1] = static_cast(compactedNodeId(triangles[i].node_id[1])); @@ -2668,7 +2764,7 @@ Result<> finalizeMesh(DataStructure& dataStructure, const M3CSurfaceMeshingInput AddFeatureTupleTransferInstance(dataStructure, inputValues->SelectedFeatureDataArrayPaths[i], inputValues->CreatedDataArrayPaths[numCellArrays + i], inputValues->FeatureIdsArrayPath, transfers); } - for(int64 i = 0; i < nTriangle; i++) + for(int64 i = 0; i < nTriangleFinal; i++) { // Match the smaller-first FaceLabel ordering above so each transferred component aligns with // the feature in the same FaceLabels component. @@ -2717,6 +2813,26 @@ 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. + if(nTriangleFinal == 0) + { + return MeshingUtilities::MakeEmptyMeshWarning(inputValues->TriangleGeometryPath, dataStructure.getDataRefAs(inputValues->FeatureIdsArrayPath).getNumberOfTuples(), + static_cast(nNodes)); + } + // A fully-indexed volume (no Feature Id 0) makes the option a no-op: nothing was pruned, and + // the user otherwise gets byte-identical output with no feedback that the option had no effect. + if(numFacesPruned == 0) + { + return MeshingUtilities::MakeNoFacesPrunedWarning(inputValues->TriangleGeometryPath); + } + } + return {}; } } // namespace @@ -2749,6 +2865,21 @@ Result<> M3CSurfaceMeshing::operator()() // M3C_SERIAL=1 -> runWindowed(false): serial sliding window (same tessellation as legacy) // M3C_WHOLE_VOLUME=1 -> runEntireVolume(): serial whole-volume (O(volume) memory) // Both serial paths are byte-identical to each other. + + // Reject Feature Ids that collide with this algorithm's internal sentinel space: maxGrainId+1 is + // signed overflow (undefined behavior) when INT32_MAX is present, and negative Feature Ids collide + // with M3C's nSpin < 0 ghost convention. This is a mitigation for the underlying sentinel-collision + // design, not a fix -- see simplnx#1705. Run here (execute), not preflight: a full-volume scan is + // too expensive to repeat on every GUI parameter edit. + { + const auto& featureIdsStore = m_DataStructure.getDataRefAs(m_InputValues->FeatureIdsArrayPath).getDataStoreRef(); + Result<> sentinelCheck = MeshingUtilities::ValidateFeatureIdsAgainstSentinels(featureIdsStore, m_InputValues->FeatureIdsArrayPath, /*rejectMaxInt32=*/true, m_ShouldCancel, m_MessageHandler); + if(sentinelCheck.invalid()) + { + return sentinelCheck; + } + } + if(const char* wholeVol = std::getenv("M3C_WHOLE_VOLUME"); wholeVol != nullptr && std::string_view(wholeVol) == "1") { return runEntireVolume(); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp index 71fc8b38e1..f3ff9124f5 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/M3CSurfaceMeshing.hpp @@ -6,6 +6,7 @@ #include "simplnx/DataStructure/DataStructure.hpp" #include "simplnx/DataStructure/Geometry/IGeometry.hpp" #include "simplnx/Filter/IFilter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/MultiArraySelectionParameter.hpp" namespace nx::core @@ -14,6 +15,7 @@ namespace nx::core struct SIMPLNXCORE_EXPORT M3CSurfaceMeshingInputValues { bool RepairTriangleWinding; + ChoicesParameter::ValueType BoundingBoxSkinMode; DataPath GridGeomDataPath; DataPath FeatureIdsArrayPath; MultiArraySelectionParameter::ValueType SelectedCellDataArrayPaths; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp index e0016d3d54..c4f717cd56 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.cpp @@ -233,6 +233,21 @@ void GetGridCoordinates(const IGridGeometry* grid, size_t x, size_t y, size_t z, verts[nodeIndex + 2] = static_cast(tmpCoords[2]); } +/** + * @brief True when a bounding-box wall face backed by this Feature Id must be skipped. + * A wall face always gets Face Labels {-1, featureId}, so the {-1, 0} rule reduces to + * "the voxel behind the wall is background". Both the counting pass and the emit pass + * MUST call this same function -- if they disagree, node ids go unassigned and + * ownerLists is indexed out of bounds. + * Takes the store and index (rather than the already-read Feature Id) so the mode check + * short-circuits before touching the DataStore on the default (mode == Off) path, which the + * counting pass otherwise runs for every boundary voxel even when the option is disabled. + */ +inline bool SkipWallFace(ChoicesParameter::ValueType mode, const Int32AbstractDataStore& featureIds, usize point) +{ + return mode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly && featureIds[point] == 0; +} + // ----------------------------------------------------------------------------- void FlipProblemVoxelCase1(Int32AbstractDataStore& featureIds, QuickSurfaceMesh::MeshIndexType v1, QuickSurfaceMesh::MeshIndexType v2, QuickSurfaceMesh::MeshIndexType v3, QuickSurfaceMesh::MeshIndexType v4, QuickSurfaceMesh::MeshIndexType v5, QuickSurfaceMesh::MeshIndexType v6) @@ -329,6 +344,19 @@ QuickSurfaceMesh::~QuickSurfaceMesh() noexcept = default; // ----------------------------------------------------------------------------- Result<> QuickSurfaceMesh::operator()() { + // Reject Feature Ids that collide with this algorithm's hard-coded exterior Face Label (-1) and + // owner-set marker: a negative Feature Id would be silently misinterpreted downstream. This is a + // mitigation for the underlying sentinel-collision design, not a fix -- see simplnx#1705. Run here + // (execute), not preflight: a full-volume scan is too expensive to repeat on every GUI parameter edit. + { + const auto& featureIdsStore = m_DataStructure.getDataRefAs(m_InputValues->FeatureIdsArrayPath).getDataStoreRef(); + Result<> sentinelCheck = MeshingUtilities::ValidateFeatureIdsAgainstSentinels(featureIdsStore, m_InputValues->FeatureIdsArrayPath, /*rejectMaxInt32=*/false, m_ShouldCancel, m_MessageHandler); + if(sentinelCheck.invalid()) + { + return sentinelCheck; + } + } + // Get the ImageGeometry auto& grid = m_DataStructure.getDataRefAs(m_InputValues->GridGeomDataPath); @@ -348,6 +376,7 @@ Result<> QuickSurfaceMesh::operator()() MeshIndexType nodeCount = 0; MeshIndexType triangleCount = 0; + MeshIndexType suppressedFaceCount = 0; if(m_InputValues->FixProblemVoxels) { @@ -358,7 +387,7 @@ Result<> QuickSurfaceMesh::operator()() return {}; } - determineActiveNodes(nodeIds, nodeCount, triangleCount); + determineActiveNodes(nodeIds, nodeCount, triangleCount, suppressedFaceCount); if(m_ShouldCancel) { return {}; @@ -476,6 +505,24 @@ Result<> QuickSurfaceMesh::operator()() } #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()) + { + // 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. + if(triangleGeom.getNumberOfFaces() == 0) + { + return MeshingUtilities::MakeEmptyMeshWarning(m_InputValues->TriangleGeometryPath, m_DataStructure.getDataRefAs(m_InputValues->FeatureIdsArrayPath).getNumberOfTuples(), + triangleGeom.getNumberOfVertices()); + } + // A fully-indexed volume (no Feature Id 0) makes the option a no-op: nothing was pruned, and + // the user otherwise gets byte-identical output with no feedback that the option had no effect. + if(suppressedFaceCount == 0) + { + return MeshingUtilities::MakeNoFacesPrunedWarning(m_InputValues->TriangleGeometryPath); + } + } + return windingResult; } @@ -651,7 +698,7 @@ void QuickSurfaceMesh::correctProblemVoxels() } // ----------------------------------------------------------------------------- -void QuickSurfaceMesh::determineActiveNodes(std::vector& nodeIds, MeshIndexType& nodeCount, MeshIndexType& triangleCount) +void QuickSurfaceMesh::determineActiveNodes(std::vector& nodeIds, MeshIndexType& nodeCount, MeshIndexType& triangleCount, MeshIndexType& suppressedFaceCount) { m_MessageHandler(IFilter::Message::Type::Info, "Determining active Nodes"); @@ -689,119 +736,147 @@ void QuickSurfaceMesh::determineActiveNodes(std::vector& nodeIds, if(i == 0) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - if(nodeIds[nodeId1] == std::numeric_limits::max()) - { - nodeIds[nodeId1] = nodeCount; - nodeCount++; - } - nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - if(nodeIds[nodeId2] == std::numeric_limits::max()) - { - nodeIds[nodeId2] = nodeCount; - nodeCount++; - } - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - if(nodeIds[nodeId3] == std::numeric_limits::max()) + if(::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) { - nodeIds[nodeId3] = nodeCount; - nodeCount++; + suppressedFaceCount++; } - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - if(nodeIds[nodeId4] == std::numeric_limits::max()) + else { - nodeIds[nodeId4] = nodeCount; - nodeCount++; + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + if(nodeIds[nodeId1] == std::numeric_limits::max()) + { + nodeIds[nodeId1] = nodeCount; + nodeCount++; + } + nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + if(nodeIds[nodeId2] == std::numeric_limits::max()) + { + nodeIds[nodeId2] = nodeCount; + nodeCount++; + } + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + if(nodeIds[nodeId3] == std::numeric_limits::max()) + { + nodeIds[nodeId3] = nodeCount; + nodeCount++; + } + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + if(nodeIds[nodeId4] == std::numeric_limits::max()) + { + nodeIds[nodeId4] = nodeCount; + nodeCount++; + } + triangleCount++; + triangleCount++; } - triangleCount++; - triangleCount++; } if(j == 0) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - if(nodeIds[nodeId1] == std::numeric_limits::max()) + if(::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) { - nodeIds[nodeId1] = nodeCount; - nodeCount++; + suppressedFaceCount++; } - nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - if(nodeIds[nodeId2] == std::numeric_limits::max()) + else { - nodeIds[nodeId2] = nodeCount; - nodeCount++; - } - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - if(nodeIds[nodeId3] == std::numeric_limits::max()) - { - nodeIds[nodeId3] = nodeCount; - nodeCount++; - } - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - if(nodeIds[nodeId4] == std::numeric_limits::max()) - { - nodeIds[nodeId4] = nodeCount; - nodeCount++; + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + if(nodeIds[nodeId1] == std::numeric_limits::max()) + { + nodeIds[nodeId1] = nodeCount; + nodeCount++; + } + nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + if(nodeIds[nodeId2] == std::numeric_limits::max()) + { + nodeIds[nodeId2] = nodeCount; + nodeCount++; + } + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + if(nodeIds[nodeId3] == std::numeric_limits::max()) + { + nodeIds[nodeId3] = nodeCount; + nodeCount++; + } + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + if(nodeIds[nodeId4] == std::numeric_limits::max()) + { + nodeIds[nodeId4] = nodeCount; + nodeCount++; + } + triangleCount++; + triangleCount++; } - triangleCount++; - triangleCount++; } if(k == 0) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - if(nodeIds[nodeId1] == std::numeric_limits::max()) + if(::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) { - nodeIds[nodeId1] = nodeCount; - nodeCount++; + suppressedFaceCount++; } - nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - if(nodeIds[nodeId2] == std::numeric_limits::max()) + else { - nodeIds[nodeId2] = nodeCount; - nodeCount++; - } - nodeId3 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - if(nodeIds[nodeId3] == std::numeric_limits::max()) - { - nodeIds[nodeId3] = nodeCount; - nodeCount++; - } - nodeId4 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - if(nodeIds[nodeId4] == std::numeric_limits::max()) - { - nodeIds[nodeId4] = nodeCount; - nodeCount++; + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + if(nodeIds[nodeId1] == std::numeric_limits::max()) + { + nodeIds[nodeId1] = nodeCount; + nodeCount++; + } + nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + if(nodeIds[nodeId2] == std::numeric_limits::max()) + { + nodeIds[nodeId2] = nodeCount; + nodeCount++; + } + nodeId3 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + if(nodeIds[nodeId3] == std::numeric_limits::max()) + { + nodeIds[nodeId3] = nodeCount; + nodeCount++; + } + nodeId4 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + if(nodeIds[nodeId4] == std::numeric_limits::max()) + { + nodeIds[nodeId4] = nodeCount; + nodeCount++; + } + triangleCount++; + triangleCount++; } - triangleCount++; - triangleCount++; } if(i == (xP - 1)) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - if(nodeIds[nodeId1] == std::numeric_limits::max()) + if(::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) { - nodeIds[nodeId1] = nodeCount; - nodeCount++; + suppressedFaceCount++; } - nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - if(nodeIds[nodeId2] == std::numeric_limits::max()) + else { - nodeIds[nodeId2] = nodeCount; - nodeCount++; - } - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - if(nodeIds[nodeId3] == std::numeric_limits::max()) - { - nodeIds[nodeId3] = nodeCount; - nodeCount++; - } - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - if(nodeIds[nodeId4] == std::numeric_limits::max()) - { - nodeIds[nodeId4] = nodeCount; - nodeCount++; + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + if(nodeIds[nodeId1] == std::numeric_limits::max()) + { + nodeIds[nodeId1] = nodeCount; + nodeCount++; + } + nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + if(nodeIds[nodeId2] == std::numeric_limits::max()) + { + nodeIds[nodeId2] = nodeCount; + nodeCount++; + } + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + if(nodeIds[nodeId3] == std::numeric_limits::max()) + { + nodeIds[nodeId3] = nodeCount; + nodeCount++; + } + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + if(nodeIds[nodeId4] == std::numeric_limits::max()) + { + nodeIds[nodeId4] = nodeCount; + nodeCount++; + } + triangleCount++; + triangleCount++; } - triangleCount++; - triangleCount++; } else if(featureIds[point] != featureIds[neigh1]) { @@ -834,32 +909,39 @@ void QuickSurfaceMesh::determineActiveNodes(std::vector& nodeIds, } if(j == (yP - 1)) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - if(nodeIds[nodeId1] == std::numeric_limits::max()) - { - nodeIds[nodeId1] = nodeCount; - nodeCount++; - } - nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - if(nodeIds[nodeId2] == std::numeric_limits::max()) - { - nodeIds[nodeId2] = nodeCount; - nodeCount++; - } - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - if(nodeIds[nodeId3] == std::numeric_limits::max()) + if(::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) { - nodeIds[nodeId3] = nodeCount; - nodeCount++; + suppressedFaceCount++; } - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - if(nodeIds[nodeId4] == std::numeric_limits::max()) + else { - nodeIds[nodeId4] = nodeCount; - nodeCount++; + nodeId1 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + if(nodeIds[nodeId1] == std::numeric_limits::max()) + { + nodeIds[nodeId1] = nodeCount; + nodeCount++; + } + nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + if(nodeIds[nodeId2] == std::numeric_limits::max()) + { + nodeIds[nodeId2] = nodeCount; + nodeCount++; + } + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + if(nodeIds[nodeId3] == std::numeric_limits::max()) + { + nodeIds[nodeId3] = nodeCount; + nodeCount++; + } + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + if(nodeIds[nodeId4] == std::numeric_limits::max()) + { + nodeIds[nodeId4] = nodeCount; + nodeCount++; + } + triangleCount++; + triangleCount++; } - triangleCount++; - triangleCount++; } else if(featureIds[point] != featureIds[neigh2]) { @@ -892,32 +974,39 @@ void QuickSurfaceMesh::determineActiveNodes(std::vector& nodeIds, } if(k == (zP - 1)) { - nodeId1 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - if(nodeIds[nodeId1] == std::numeric_limits::max()) + if(::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) { - nodeIds[nodeId1] = nodeCount; - nodeCount++; + suppressedFaceCount++; } - nodeId2 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - if(nodeIds[nodeId2] == std::numeric_limits::max()) - { - nodeIds[nodeId2] = nodeCount; - nodeCount++; - } - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - if(nodeIds[nodeId3] == std::numeric_limits::max()) - { - nodeIds[nodeId3] = nodeCount; - nodeCount++; - } - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - if(nodeIds[nodeId4] == std::numeric_limits::max()) + else { - nodeIds[nodeId4] = nodeCount; - nodeCount++; + nodeId1 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + if(nodeIds[nodeId1] == std::numeric_limits::max()) + { + nodeIds[nodeId1] = nodeCount; + nodeCount++; + } + nodeId2 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + if(nodeIds[nodeId2] == std::numeric_limits::max()) + { + nodeIds[nodeId2] = nodeCount; + nodeCount++; + } + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + if(nodeIds[nodeId3] == std::numeric_limits::max()) + { + nodeIds[nodeId3] = nodeCount; + nodeCount++; + } + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + if(nodeIds[nodeId4] == std::numeric_limits::max()) + { + nodeIds[nodeId4] = nodeCount; + nodeCount++; + } + triangleCount++; + triangleCount++; } - triangleCount++; - triangleCount++; } else if(k < zP - 1 && featureIds[point] != featureIds[neigh3]) { @@ -1049,199 +1138,211 @@ void QuickSurfaceMesh::createNodesAndTriangles(std::vector& m_Nod if(i == 0) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j, k, vertex, (m_NodeIds[nodeId1] * 3)); + if(!::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) + { + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j, k, vertex, (m_NodeIds[nodeId1] * 3)); - nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j + 1, k, vertex, (m_NodeIds[nodeId2] * 3)); + nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j + 1, k, vertex, (m_NodeIds[nodeId2] * 3)); - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - ::GetGridCoordinates(grid, i + 1, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId2]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId2]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId4]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId4]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId1]].insert(-1); - ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId2]].insert(-1); - ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId3]].insert(-1); - ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId4]].insert(-1); + ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId1]].insert(-1); + ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId2]].insert(-1); + ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId3]].insert(-1); + ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId4]].insert(-1); + } } if(j == 0) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j, k, vertex, (m_NodeIds[nodeId1] * 3)); + if(!::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) + { + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j, k, vertex, (m_NodeIds[nodeId1] * 3)); - nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j, k, vertex, (m_NodeIds[nodeId2] * 3)); + nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j, k, vertex, (m_NodeIds[nodeId2] * 3)); - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId4]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId4]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId1]].insert(-1); - ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId2]].insert(-1); - ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId3]].insert(-1); - ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId4]].insert(-1); + ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId1]].insert(-1); + ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId2]].insert(-1); + ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId3]].insert(-1); + ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId4]].insert(-1); + } } if(k == 0) { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j, k, vertex, (m_NodeIds[nodeId1] * 3)); + if(!::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) + { + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j, k, vertex, (m_NodeIds[nodeId1] * 3)); - nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j, k, vertex, (m_NodeIds[nodeId2] * 3)); + nodeId2 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j, k, vertex, (m_NodeIds[nodeId2] * 3)); - nodeId3 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j + 1, k, vertex, (m_NodeIds[nodeId3] * 3)); + nodeId3 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j + 1, k, vertex, (m_NodeIds[nodeId3] * 3)); - nodeId4 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j + 1, k, vertex, (m_NodeIds[nodeId4] * 3)); + nodeId4 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j + 1, k, vertex, (m_NodeIds[nodeId4] * 3)); - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId2]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId2]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId4]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId4]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId1]].insert(-1); - ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId2]].insert(-1); - ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId3]].insert(-1); - ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId4]].insert(-1); + ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId1]].insert(-1); + ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId2]].insert(-1); + ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId3]].insert(-1); + ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId4]].insert(-1); + } } if(i == (xP - 1)) // Takes care of the end of a Row... { - nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j, k, vertex, (m_NodeIds[nodeId1] * 3)); + if(!::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) + { + nodeId1 = (k * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j, k, vertex, (m_NodeIds[nodeId1] * 3)); - nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j + 1, k, vertex, (m_NodeIds[nodeId2] * 3)); + nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j + 1, k, vertex, (m_NodeIds[nodeId2] * 3)); - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId4]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId4]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId1]].insert(-1); - ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId2]].insert(-1); - ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId3]].insert(-1); - ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId4]].insert(-1); + ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId1]].insert(-1); + ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId2]].insert(-1); + ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId3]].insert(-1); + ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId4]].insert(-1); + } } else if(featureIds[point] != featureIds[neigh1]) { @@ -1309,52 +1410,55 @@ void QuickSurfaceMesh::createNodesAndTriangles(std::vector& m_Nod } if(j == (yP - 1)) // Takes care of the end of a column { - nodeId1 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j + 1, k, vertex, (m_NodeIds[nodeId1] * 3)); + if(!::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) + { + nodeId1 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j + 1, k, vertex, (m_NodeIds[nodeId1] * 3)); - nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j + 1, k, vertex, (m_NodeIds[nodeId2] * 3)); + nodeId2 = (k * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j + 1, k, vertex, (m_NodeIds[nodeId2] * 3)); - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j + 1, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j + 1, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId4]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId4]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId3]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId1]].insert(-1); - ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId2]].insert(-1); - ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId3]].insert(-1); - ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId4]].insert(-1); + ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId1]].insert(-1); + ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId2]].insert(-1); + ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId3]].insert(-1); + ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId4]].insert(-1); + } } else if(featureIds[point] != featureIds[neigh2]) { @@ -1421,52 +1525,55 @@ void QuickSurfaceMesh::createNodesAndTriangles(std::vector& m_Nod } if(k == (zP - 1)) // Takes care of the end of a Pillar { - nodeId1 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j, k + 1, vertex, (m_NodeIds[nodeId1] * 3)); + if(!::SkipWallFace(m_InputValues->BoundingBoxSkinMode, featureIds, point)) + { + nodeId1 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j, k + 1, vertex, (m_NodeIds[nodeId1] * 3)); - nodeId2 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j, k + 1, vertex, (m_NodeIds[nodeId2] * 3)); + nodeId2 = ((k + 1) * (xP + 1) * (yP + 1)) + (j * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j, k + 1, vertex, (m_NodeIds[nodeId2] * 3)); - nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); - ::GetGridCoordinates(grid, i + 1, j + 1, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); + nodeId3 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + (i + 1); + ::GetGridCoordinates(grid, i + 1, j + 1, k + 1, vertex, (m_NodeIds[nodeId3] * 3)); - nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; - ::GetGridCoordinates(grid, i, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); + nodeId4 = ((k + 1) * (xP + 1) * (yP + 1)) + ((j + 1) * (xP + 1)) + i; + ::GetGridCoordinates(grid, i, j + 1, k + 1, vertex, (m_NodeIds[nodeId4] * 3)); - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId2]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId1]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId2]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; - triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; - triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId4]; - faceLabelsStore[triangleIndex * 2] = -1; - faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; + triangle[triangleIndex * 3 + 0] = m_NodeIds[nodeId2]; + triangle[triangleIndex * 3 + 1] = m_NodeIds[nodeId3]; + triangle[triangleIndex * 3 + 2] = m_NodeIds[nodeId4]; + faceLabelsStore[triangleIndex * 2] = -1; + faceLabelsStore[triangleIndex * 2 + 1] = featureIds[point]; - for(const auto& tupleTransferFunction : tupleTransferFunctions) - { - tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); - } + for(const auto& tupleTransferFunction : tupleTransferFunctions) + { + tupleTransferFunction->quickSurfaceTransfer(triangleIndex, point, point, faceLabelsStore); + } - triangleIndex++; + triangleIndex++; - ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId1]].insert(-1); - ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId2]].insert(-1); - ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId3]].insert(-1); - ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); - ownerLists[m_NodeIds[nodeId4]].insert(-1); + ownerLists[m_NodeIds[nodeId1]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId1]].insert(-1); + ownerLists[m_NodeIds[nodeId2]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId2]].insert(-1); + ownerLists[m_NodeIds[nodeId3]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId3]].insert(-1); + ownerLists[m_NodeIds[nodeId4]].insert(featureIds[point]); + ownerLists[m_NodeIds[nodeId4]].insert(-1); + } } else if(featureIds[point] != featureIds[neigh3]) { diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp index 27bf7da0fa..f46a98cda2 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/QuickSurfaceMesh.hpp @@ -6,6 +6,7 @@ #include "simplnx/DataStructure/DataStructure.hpp" #include "simplnx/DataStructure/Geometry/IGridGeometry.hpp" #include "simplnx/Filter/IFilter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/MultiArraySelectionParameter.hpp" #include @@ -19,6 +20,7 @@ struct SIMPLNXCORE_EXPORT QuickSurfaceMeshInputValues bool FixProblemVoxels; bool RepairTriangleWinding; bool GenerateTripleLines; + ChoicesParameter::ValueType BoundingBoxSkinMode; DataPath GridGeomDataPath; DataPath FeatureIdsArrayPath; @@ -59,8 +61,11 @@ class SIMPLNXCORE_EXPORT QuickSurfaceMesh * @param m_NodeIds * @param nodeCount * @param triangleCount + * @param suppressedFaceCount Incremented once per bounding-box wall face that 'Omit Bounding Box + * Skin' (BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) suppresses. Always 0 when the mode is + * Off. Lets the caller warn when the option is on but pruned nothing. */ - void determineActiveNodes(std::vector& m_NodeIds, MeshIndexType& nodeCount, MeshIndexType& triangleCount); + void determineActiveNodes(std::vector& m_NodeIds, MeshIndexType& nodeCount, MeshIndexType& triangleCount, MeshIndexType& suppressedFaceCount); /** * @brief diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.cpp index ac95808ad2..4b7b0d5fb0 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.cpp @@ -13,23 +13,14 @@ #include "SimplnxCore/SurfaceNets/MMGeometryOBJ.h" #include "SimplnxCore/SurfaceNets/MMSurfaceNet.h" +#include +#include + using namespace nx::core; namespace { using LabelType = int32; -constexpr inline int8 CalculatePadding(int8 value) -{ - return value + ((9 * static_cast(value < 10)) + 1); -} - -inline void HandlePadding(std::array vertexIndices, AbstractDataStore& nodeTypes) -{ - nodeTypes.setValue(vertexIndices[0], CalculatePadding(nodeTypes.getValue(vertexIndices[0]))); - nodeTypes.setValue(vertexIndices[1], CalculatePadding(nodeTypes.getValue(vertexIndices[1]))); - nodeTypes.setValue(vertexIndices[2], CalculatePadding(nodeTypes.getValue(vertexIndices[2]))); - nodeTypes.setValue(vertexIndices[3], CalculatePadding(nodeTypes.getValue(vertexIndices[3]))); -}; struct VertexData { @@ -86,6 +77,21 @@ void getQuadTriangleIDs(std::array& vData, bool isQuadFrontFacing triangleVtxIDs[4] = vData[2].VertexId; triangleVtxIDs[5] = vData[3].VertexId; } + +/** + * @brief True when this quad is bounding-box wall backed by background and must be skipped. + * Called from both the counting pass and the emit pass -- they must agree exactly. + * Note this reads the RAW quadLabels, where MMSurfaceNet::Padding is still distinct from a + * real Feature Id 0. Do not call it after the Padding -> -1 remap. + */ +inline bool SkipPaddingQuad(ChoicesParameter::ValueType mode, const std::array& quadLabels) +{ + if(mode != BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) + { + return false; + } + return (quadLabels[0] == MMSurfaceNet::Padding && quadLabels[1] == 0) || (quadLabels[1] == MMSurfaceNet::Padding && quadLabels[0] == 0); +} } // namespace // ----------------------------------------------------------------------------- SurfaceNets::SurfaceNets(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, SurfaceNetsInputValues* inputValues) @@ -108,6 +114,20 @@ const std::atomic_bool& SurfaceNets::getCancel() // ----------------------------------------------------------------------------- Result<> SurfaceNets::operator()() { + // Reject Feature Ids that collide with MMSurfaceNet::Padding (INT32_MAX), the sentinel this + // algorithm uses for "outside the volume" throughout MMCellMap: a real Feature Id of INT32_MAX + // would be silently treated as exterior. This is a mitigation for the underlying + // sentinel-collision design, not a fix -- see simplnx#1705. Run here (execute), not preflight: a + // full-volume scan is too expensive to repeat on every GUI parameter edit. + { + const auto& featureIdsStore = m_DataStructure.getDataRefAs(m_InputValues->FeatureIdsArrayPath).getDataStoreRef(); + Result<> sentinelCheck = MeshingUtilities::ValidateFeatureIdsAgainstSentinels(featureIdsStore, m_InputValues->FeatureIdsArrayPath, /*rejectMaxInt32=*/true, m_ShouldCancel, m_MessageHandler); + if(sentinelCheck.invalid()) + { + return sentinelCheck; + } + } + // Get the ImageGeometry auto& imageGeom = m_DataStructure.getDataRefAs(m_InputValues->GridGeomDataPath); @@ -155,15 +175,18 @@ Result<> SurfaceNets::operator()() { cellMapPtr->getVertexPosition(vertIndex, position.data()); // Relocate the vertex correctly based on the origin of the ImageGeometry - position = position + origin - Point3Df(0.5f * voxelSize[0], 0.5f * voxelSize[1], 0.5f * voxelSize[1]); + position = position + origin - Point3Df(0.5f * voxelSize[0], 0.5f * voxelSize[1], 0.5f * voxelSize[2]); triangleGeom.setVertexCoordinate(static_cast(vertIndex), position); cellMapPtr->getVertexCellIndex(vertIndex, vertCellIndex.data()); - MMCellMap::Cell* currentCellPtr = cellMapPtr->getCell(vertCellIndex.data()); - nodeTypes[static_cast(vertIndex)] = static_cast(currentCellPtr->flag.numJunctions()); + nodeTypes[static_cast(vertIndex)] = cellMapPtr->nodeType(vertCellIndex.data()); } usize triangleCount = 0; + // Counts quads suppressed by the Bounding Box Skin option's 'Background-Backed Walls Only' mode + // (BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) in this counting pass. Always 0 when the mode is + // Off. Lets the caller warn when the option is on but pruned nothing. + usize suppressedFaceCount = 0; std::array quadNxArrayIndices = {0, 0}; // First Pass through to just count the number of triangles: for(int idxVtx = 0; idxVtx < nodeCount; idxVtx++) @@ -173,27 +196,36 @@ Result<> SurfaceNets::operator()() if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::BackBottomEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data())) { - if(quadLabels[0] == MMSurfaceNet::Padding || quadLabels[1] == MMSurfaceNet::Padding) + if(::SkipPaddingQuad(m_InputValues->BoundingBoxSkinMode, quadLabels)) + { + suppressedFaceCount++; + } + else { - HandlePadding(vertexIndices, nodeTypes); + triangleCount += 2; } - triangleCount += 2; } if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::LeftBottomEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data())) { - if(quadLabels[0] == MMSurfaceNet::Padding || quadLabels[1] == MMSurfaceNet::Padding) + if(::SkipPaddingQuad(m_InputValues->BoundingBoxSkinMode, quadLabels)) { - HandlePadding(vertexIndices, nodeTypes); + suppressedFaceCount++; + } + else + { + triangleCount += 2; } - triangleCount += 2; } if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::LeftBackEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data())) { - if(quadLabels[0] == MMSurfaceNet::Padding || quadLabels[1] == MMSurfaceNet::Padding) + if(::SkipPaddingQuad(m_InputValues->BoundingBoxSkinMode, quadLabels)) + { + suppressedFaceCount++; + } + else { - HandlePadding(vertexIndices, nodeTypes); + triangleCount += 2; } - triangleCount += 2; } } @@ -238,7 +270,8 @@ Result<> SurfaceNets::operator()() { cellMapPtr->getVertexCellIndex(idxVtx, cellIndex.data()); // Back-bottom edge - if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::BackBottomEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data())) + if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::BackBottomEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data()) && + !::SkipPaddingQuad(m_InputValues->BoundingBoxSkinMode, quadLabels)) { vData[0] = {vertexIndices[0], 00.0f, 0.0f, 0.0f}; vData[1] = {vertexIndices[1], 00.0f, 0.0f, 0.0f}; @@ -246,13 +279,15 @@ Result<> SurfaceNets::operator()() vData[3] = {vertexIndices[3], 00.0f, 0.0f, 0.0f}; const bool isQuadFrontFacing = (quadLabels[0] < quadLabels[1]); + // Map the exterior padding sentinel straight to the shared exterior Face Label (-1). + // Going through 0 as an intermediate would collide with real Feature Id 0. if(quadLabels[0] == MMSurfaceNet::Padding) { - quadLabels[0] = 0; + quadLabels[0] = -1; } if(quadLabels[1] == MMSurfaceNet::Padding) { - quadLabels[1] = 0; + quadLabels[1] = -1; } getQuadTriangleIDs(vData, isQuadFrontFacing, triangleVtxIDs); @@ -298,7 +333,8 @@ Result<> SurfaceNets::operator()() } // Left-bottom edge - if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::LeftBottomEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data())) + if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::LeftBottomEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data()) && + !::SkipPaddingQuad(m_InputValues->BoundingBoxSkinMode, quadLabels)) { vData[0] = {vertexIndices[0], 00.0f, 0.0f, 0.0f}; vData[1] = {vertexIndices[1], 00.0f, 0.0f, 0.0f}; @@ -306,13 +342,15 @@ Result<> SurfaceNets::operator()() vData[3] = {vertexIndices[3], 00.0f, 0.0f, 0.0f}; const bool isQuadFrontFacing = (quadLabels[0] < quadLabels[1]); /// + // Map the exterior padding sentinel straight to the shared exterior Face Label (-1). + // Going through 0 as an intermediate would collide with real Feature Id 0. if(quadLabels[0] == MMSurfaceNet::Padding) { - quadLabels[0] = 0; + quadLabels[0] = -1; } if(quadLabels[1] == MMSurfaceNet::Padding) { - quadLabels[1] = 0; + quadLabels[1] = -1; } getQuadTriangleIDs(vData, isQuadFrontFacing, triangleVtxIDs); t1 = {static_cast(triangleVtxIDs[0]), static_cast(triangleVtxIDs[1]), static_cast(triangleVtxIDs[2])}; @@ -356,7 +394,8 @@ Result<> SurfaceNets::operator()() } // Left-back edge - if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::LeftBackEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data())) + if(cellMapPtr->getEdgeQuad(idxVtx, MMCellFlag::Edge::LeftBackEdge, vertexIndices.data(), quadLabels.data(), quadNxArrayIndices.data()) && + !::SkipPaddingQuad(m_InputValues->BoundingBoxSkinMode, quadLabels)) { vData[0] = {vertexIndices[0], 00.0f, 0.0f, 0.0f}; vData[1] = {vertexIndices[1], 00.0f, 0.0f, 0.0f}; @@ -364,13 +403,15 @@ Result<> SurfaceNets::operator()() vData[3] = {vertexIndices[3], 00.0f, 0.0f, 0.0f}; const bool isQuadFrontFacing = (quadLabels[0] < quadLabels[1]); + // Map the exterior padding sentinel straight to the shared exterior Face Label (-1). + // Going through 0 as an intermediate would collide with real Feature Id 0. if(quadLabels[0] == MMSurfaceNet::Padding) { - quadLabels[0] = 0; + quadLabels[0] = -1; } if(quadLabels[1] == MMSurfaceNet::Padding) { - quadLabels[1] = 0; + quadLabels[1] = -1; } getQuadTriangleIDs(vData, isQuadFrontFacing, triangleVtxIDs); t1 = {static_cast(triangleVtxIDs[0]), static_cast(triangleVtxIDs[1]), static_cast(triangleVtxIDs[2])}; @@ -414,12 +455,64 @@ Result<> SurfaceNets::operator()() } } - // Now run through the FaceLabels to make them consistent with Quick Surface Mesh - for(usize tIdx = 0; tIdx < triangleCount * 2; tIdx++) + // Dropping faces can orphan vertices, which come from the cell map before any triangle + // exists. Compact them so the vertex list, Node Types and vertex AttributeMatrix agree. + // Skipped entirely when the option is off, because then no face was dropped. + if(m_InputValues->BoundingBoxSkinMode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) { - if(faceLabels[tIdx] == 0) + m_MessageHandler("Removing vertices orphaned by omitted bounding box faces..."); + + auto& facesRef = triangleGeom.getFaces()->getDataStoreRef(); + const usize numFaceIndices = triangleCount * 3; + + // First pass: find which of the nodeCount vertices are actually referenced by a face. + std::vector isReferenced(nodeCount, false); + for(usize i = 0; i < numFaceIndices; i++) { - faceLabels[tIdx] = -1; + isReferenced[static_cast(facesRef[i])] = true; + } + + // Second pass: assign each referenced vertex's new index and copy its data in the same + // step, walking in ascending order of OLD index (not order of first use in the face + // list). Assigning destIndex from a running counter as oldIndex ascends guarantees + // newVertexIndex[oldIndex] <= oldIndex for every referenced vertex, which is what makes + // the in-place copy below safe -- a destination slot is always at or before its source + // slot, so writing it never clobbers data that a later iteration still needs to read. + // The two passes this replaces (assign-then-copy) walked all nodeCount vertices twice for + // no benefit, since the copy consumes newVertexIndex[oldIndex] immediately after it is + // assigned. + constexpr usize k_NotUsed = std::numeric_limits::max(); + std::vector newVertexIndex(nodeCount, k_NotUsed); + usize survivingVertexCount = 0; + auto& verticesRef = triangleGeom.getVertices()->getDataStoreRef(); + for(usize oldIndex = 0; oldIndex < nodeCount; oldIndex++) + { + if(!isReferenced[oldIndex]) + { + continue; + } + const usize destIndex = survivingVertexCount; + newVertexIndex[oldIndex] = destIndex; + for(usize comp = 0; comp < 3; comp++) + { + verticesRef[destIndex * 3 + comp] = verticesRef[oldIndex * 3 + comp]; + } + nodeTypes[destIndex] = nodeTypes[oldIndex]; + survivingVertexCount++; + } + + if(survivingVertexCount < nodeCount) + { + // Remap the face indices to the compacted vertex ids. Must run after the copy loop + // above (it reads the pre-remap face indices to look up newVertexIndex). + for(usize i = 0; i < numFaceIndices; i++) + { + facesRef[i] = static_cast(newVertexIndex[static_cast(facesRef[i])]); + } + + triangleGeom.resizeVertexList(survivingVertexCount); + triangleGeom.getVertexAttributeMatrix()->resizeTuples({survivingVertexCount}); + nodeTypes.resizeTuples({survivingVertexCount}); } } @@ -447,5 +540,23 @@ Result<> SurfaceNets::operator()() m_DataStructure.removeData(triangleGeom.getElementNeighborsId().value()); } + // Guarded on windingResult still being valid so a genuine winding-repair error is never discarded. + if(m_InputValues->BoundingBoxSkinMode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly && windingResult.valid()) + { + // 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. + if(triangleGeom.getNumberOfFaces() == 0) + { + return MeshingUtilities::MakeEmptyMeshWarning(m_InputValues->TriangleGeometryPath, m_DataStructure.getDataRefAs(m_InputValues->FeatureIdsArrayPath).getNumberOfTuples(), + triangleGeom.getNumberOfVertices()); + } + // A fully-indexed volume (no Feature Id 0) makes the option a no-op: nothing was pruned, and + // the user otherwise gets byte-identical output with no feedback that the option had no effect. + if(suppressedFaceCount == 0) + { + return MeshingUtilities::MakeNoFacesPrunedWarning(m_InputValues->TriangleGeometryPath); + } + } + return windingResult; } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.hpp index 27a7c621ef..9e2631df4b 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/SurfaceNets.hpp @@ -5,6 +5,7 @@ #include "simplnx/DataStructure/DataPath.hpp" #include "simplnx/DataStructure/DataStructure.hpp" #include "simplnx/Filter/IFilter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/MultiArraySelectionParameter.hpp" namespace nx::core @@ -14,6 +15,7 @@ struct SIMPLNXCORE_EXPORT SurfaceNetsInputValues { bool ApplySmoothing; bool RepairTriangleWinding; + ChoicesParameter::ValueType BoundingBoxSkinMode; int32 SmoothingIterations; float32 MaxDistanceFromVoxel; float32 RelaxationFactor; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.cpp index 9cefb20a6a..8b996fe381 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.cpp @@ -5,6 +5,7 @@ #include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" #include "simplnx/Filter/Actions/CreateArrayAction.hpp" #include "simplnx/Filter/Actions/CreateGeometry2DAction.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/DataGroupSelectionParameter.hpp" #include "simplnx/Utilities/SIMPLConversion.hpp" @@ -63,11 +64,19 @@ Parameters ExtractInternalSurfacesFromTriangleGeometryFilter::parameters() const params.insert(std::make_unique(k_SelectedTriangleGeometryPath_Key, "Triangle Geometry", "Path to the existing Triangle Geometry", DataPath(), GeometrySelectionParameter::AllowedTypes{IGeometry::Type::Triangle})); params.insertSeparator(Parameters::Separator{"Input Vertex Data"}); + params.insertLinkableParameter(std::make_unique( + k_CriterionMode_Key, "Internal Surface Criterion", + "How an internal surface is identified. 'Node Type Range' keeps triangles whose three nodes all fall inside the Node Type range; this also removes a one-triangle-wide rim wherever an " + "internal surface meets the bounding box. 'Face Labels' removes only the bounding box wall backed by the background (Feature Id 0), which leaves Features flush with the box closed.", + 0, ChoicesParameter::Choices{"Node Type Range", "Face Labels"})); + params.insert(std::make_unique(k_NodeTypesPath_Key, "Node Types Array", "Path to the Node Types array", DataPath(), ArraySelectionParameter::AllowedTypes{DataType::int8}, ArraySelectionParameter::AllowedComponentShapes{{1}})); params.insert(std::make_unique(k_NodeTypeRange_Key, "Internal Surface Node Type Min & Max", "The min and max (inclusive) Node Type values that distinguish an internal surface from an external surface", std::vector{0, 8}, std::vector{"Min", "Max"})); + params.insert(std::make_unique(k_FaceLabelsPath_Key, "Face Labels Array", "Path to the Face Labels array", DataPath(), + ArraySelectionParameter::AllowedTypes{DataType::int32}, ArraySelectionParameter::AllowedComponentShapes{{2}})); params.insertSeparator(Parameters::Separator{"Output Data Object(s)"}); params.insert(std::make_unique(k_CreatedTriangleGeometryPath_Key, "Created Triangle Geometry Path", "Path to create the new Triangle Geometry", DataPath())); @@ -81,13 +90,26 @@ Parameters ExtractInternalSurfacesFromTriangleGeometryFilter::parameters() const std::vector{}, MultiArraySelectionParameter::AllowedTypes{IArray::ArrayType::DataArray}, GetAllDataTypes())); params.insert(std::make_unique(k_CopyTrianglePaths_Key, "Copy Face Arrays", "Paths to face-related DataArrays that should be copied to the new geometry", std::vector{}, MultiArraySelectionParameter::AllowedTypes{IArray::ArrayType::DataArray}, GetAllDataTypes())); + + params.linkParameters(k_CriterionMode_Key, k_NodeTypesPath_Key, static_cast(0)); + params.linkParameters(k_CriterionMode_Key, k_NodeTypeRange_Key, static_cast(0)); + params.linkParameters(k_CriterionMode_Key, k_FaceLabelsPath_Key, static_cast(1)); + return params; } //------------------------------------------------------------------------------ IFilter::VersionType ExtractInternalSurfacesFromTriangleGeometryFilter::parametersVersion() const { - return 1; + return 2; + // Version 1 -> 2 + // Change 1: + // Added - k_CriterionMode_Key = "internal_surface_criterion_index"; + // Solution - set the value to 0 (Node Type Range, preserves prior behavior); + // Change 2: + // Added - k_FaceLabelsPath_Key = "face_labels_path"; + // Solution - set the value to an empty DataPath (inactive unless Criterion Mode is Face Labels); + // } //------------------------------------------------------------------------------ @@ -102,7 +124,9 @@ IFilter::PreflightResult ExtractInternalSurfacesFromTriangleGeometryFilter::pref { auto triangleGeomPath = filterArgs.value(k_SelectedTriangleGeometryPath_Key); auto internalTrianglesGeomPath = filterArgs.value(k_CreatedTriangleGeometryPath_Key); + auto criterionMode = filterArgs.value(k_CriterionMode_Key); auto nodeTypesArrayPath = filterArgs.value(k_NodeTypesPath_Key); + auto faceLabelsArrayPath = filterArgs.value(k_FaceLabelsPath_Key); auto copyVertexPaths = filterArgs.value>(k_CopyVertexPaths_Key); auto copyTrianglePaths = filterArgs.value>(k_CopyTrianglePaths_Key); auto vertexDataName = filterArgs.value(k_VertexAttributeMatrixName_Key); @@ -111,13 +135,22 @@ IFilter::PreflightResult ExtractInternalSurfacesFromTriangleGeometryFilter::pref OutputActions actions; const auto& triangleGeom = dataStructure.getDataRefAs(triangleGeomPath); - // Validate NodeTypes and SharedVertexList all have the same number of tuples + if(triangleGeom.getVertices() == nullptr) { - if(triangleGeom.getVertices() == nullptr) - { - std::string ss = fmt::format("Triangle Geometry does not have an assigned vertices array"); - return {MakeErrorResult(k_MissingTriangleVerticesArray, ss)}; - } + std::string ss = fmt::format("Triangle Geometry does not have an assigned vertices array"); + return {MakeErrorResult(k_MissingTriangleVerticesArray, ss)}; + } + + if(triangleGeom.getFaces() == nullptr) + { + std::string ss = fmt::format("Triangle Geometry does not a Shared Face List"); + return {MakeErrorResult(k_MissingTriangleFacesArray, ss)}; + } + + // Only the array the selected criterion needs is validated for matching tuple counts. + if(criterionMode == 0) + { + // Validate NodeTypes and SharedVertexList all have the same number of tuples std::vector vertexArrays; vertexArrays.push_back(triangleGeom.getVertices()->getDataPaths().front()); vertexArrays.push_back(nodeTypesArrayPath); @@ -128,11 +161,18 @@ IFilter::PreflightResult ExtractInternalSurfacesFromTriangleGeometryFilter::pref return MakePreflightErrorResult(-2071, fmt::format("The following DataArrays all must have equal number of tuples but this was not satisfied.\n{}", tupleValidityCheck.error())); } } - - if(triangleGeom.getFaces() == nullptr) + else { - std::string ss = fmt::format("Triangle Geometry does not a Shared Face List"); - return {MakeErrorResult(k_MissingTriangleFacesArray, ss)}; + // Validate FaceLabels and SharedFaceList all have the same number of tuples + std::vector faceArrays; + faceArrays.push_back(triangleGeom.getFaces()->getDataPaths().front()); + faceArrays.push_back(faceLabelsArrayPath); + + auto tupleValidityCheck = dataStructure.validateNumberOfTuples(faceArrays); + if(!tupleValidityCheck) + { + return MakePreflightErrorResult(-2072, fmt::format("The following DataArrays all must have equal number of tuples but this was not satisfied.\n{}", tupleValidityCheck.error())); + } } ShapeType cDims(1, 1); @@ -215,8 +255,10 @@ Result<> ExtractInternalSurfacesFromTriangleGeometryFilter::executeImpl(DataStru inputValues.CopyTriangleArrayPaths = filterArgs.value(k_CopyTrianglePaths_Key); inputValues.CopyVertexArrayPaths = filterArgs.value(k_CopyVertexPaths_Key); inputValues.InputTriangleGeometryPath = filterArgs.value(k_SelectedTriangleGeometryPath_Key); + inputValues.CriterionMode = filterArgs.value(k_CriterionMode_Key); inputValues.NodeTypeRange = filterArgs.value(k_NodeTypeRange_Key); inputValues.NodeTypesPath = filterArgs.value(k_NodeTypesPath_Key); + inputValues.FaceLabelsPath = filterArgs.value(k_FaceLabelsPath_Key); inputValues.OutputTriangleGeometryPath = filterArgs.value(k_CreatedTriangleGeometryPath_Key); inputValues.TriangleAttributeMatrixName = filterArgs.value(k_TriangleAttributeMatrixName_Key); inputValues.VertexAttributeMatrixName = filterArgs.value(k_VertexAttributeMatrixName_Key); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.hpp index acbd23d411..35ed69f0a2 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.hpp @@ -29,6 +29,8 @@ class SIMPLNXCORE_EXPORT ExtractInternalSurfacesFromTriangleGeometryFilter : pub static constexpr StringLiteral k_VertexAttributeMatrixName_Key = "vertex_attribute_matrix_name"; static constexpr StringLiteral k_TriangleAttributeMatrixName_Key = "triangle_attribute_matrix_name"; static constexpr StringLiteral k_NodeTypeRange_Key = "node_type_range"; + static constexpr StringLiteral k_CriterionMode_Key = "internal_surface_criterion_index"; + static constexpr StringLiteral k_FaceLabelsPath_Key = "face_labels_path"; /** * @brief Reads SIMPL json and converts it simplnx Arguments. diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp index 804846cfbe..3408934201 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.cpp @@ -8,11 +8,13 @@ #include "simplnx/Filter/Actions/CreateGeometry2DAction.hpp" #include "simplnx/Parameters/ArraySelectionParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/DataGroupCreationParameter.hpp" #include "simplnx/Parameters/DataGroupSelectionParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Parameters/GeometrySelectionParameter.hpp" #include "simplnx/Parameters/MultiArraySelectionParameter.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" #include "simplnx/Utilities/SIMPLConversion.hpp" using namespace nx::core; @@ -59,6 +61,12 @@ Parameters M3CSurfaceMeshingFilter::parameters() const "If true, runs a winding-consistency repair pass after meshing. The M3C per-triangle winding heuristic does not " "guarantee globally consistent normals, so this is recommended.", true)); + params.insert(std::make_unique(k_BoundingBoxSkinMode_Key, "Bounding Box Skin", + "Controls how triangles are generated on the outer wall of the bounding box. 'Off' generates the " + "wall as normal. 'Background-Backed Walls Only' omits wall faces where the wall borders the " + "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.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", @@ -96,7 +104,12 @@ Parameters M3CSurfaceMeshingFilter::parameters() const //------------------------------------------------------------------------------ IFilter::VersionType M3CSurfaceMeshingFilter::parametersVersion() const { - return 1; + return 2; + // 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); + // } //------------------------------------------------------------------------------ @@ -186,6 +199,7 @@ Result<> M3CSurfaceMeshingFilter::executeImpl(DataStructure& dataStructure, cons M3CSurfaceMeshingInputValues inputValues; inputValues.RepairTriangleWinding = filterArgs.value(k_RepairTriangleWinding_Key); + inputValues.BoundingBoxSkinMode = filterArgs.value(k_BoundingBoxSkinMode_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 4d2570cc80..d44b7cc684 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp @@ -39,6 +39,7 @@ class SIMPLNXCORE_EXPORT M3CSurfaceMeshingFilter : public IFilter static constexpr StringLiteral k_SelectedDataArrayPaths_Key = "input_data_array_paths"; 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_CreatedTriangleGeometryPath_Key = "output_triangle_geometry_path"; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp index bc0fba90bc..5542a3d6ae 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.cpp @@ -10,11 +10,13 @@ #include "simplnx/Filter/Actions/CreateGeometry2DAction.hpp" #include "simplnx/Parameters/ArraySelectionParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/DataGroupCreationParameter.hpp" #include "simplnx/Parameters/DataGroupSelectionParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Parameters/GeometrySelectionParameter.hpp" #include "simplnx/Parameters/MultiArraySelectionParameter.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" #include "simplnx/Utilities/SIMPLConversion.hpp" using namespace nx::core; @@ -62,6 +64,12 @@ Parameters QuickSurfaceMeshFilter::parameters() const params.insert(std::make_unique(k_FixProblemVoxels_Key, "Attempt to Fix Problem Voxels", "See help page.", false)); params.insert(std::make_unique(k_RepairTriangleWinding_Key, "Attempt to Make Windings Consistent", "If true, attempts to repair the windings for the mesh. This may not be possible. See help page.", true)); + params.insert(std::make_unique(k_BoundingBoxSkinMode_Key, "Bounding Box Skin", + "Controls how triangles are generated on the outer wall of the bounding box. 'Off' generates the " + "wall as normal. 'Background-Backed Walls Only' omits wall faces where the wall borders the " + "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{}, @@ -100,7 +108,12 @@ Parameters QuickSurfaceMeshFilter::parameters() const //------------------------------------------------------------------------------ IFilter::VersionType QuickSurfaceMeshFilter::parametersVersion() const { - return 2; + 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"; @@ -211,6 +224,7 @@ Result<> QuickSurfaceMeshFilter::executeImpl(DataStructure& dataStructure, const // 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); inputValues.GridGeomDataPath = filterArgs.value(k_GridGeometryDataPath_Key); inputValues.FeatureIdsArrayPath = filterArgs.value(k_CellFeatureIdsArrayPath_Key); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp index a9dcc96f39..91c8b0672c 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp @@ -28,6 +28,7 @@ class SIMPLNXCORE_EXPORT QuickSurfaceMeshFilter : public IFilter // 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"; static constexpr StringLiteral k_GridGeometryDataPath_Key = "input_grid_geometry_path"; static constexpr StringLiteral k_CellFeatureIdsArrayPath_Key = "feature_ids_path"; static constexpr StringLiteral k_SelectedDataArrayPaths_Key = "input_data_array_paths"; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp index c9fb9862b7..e504f84408 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.cpp @@ -12,11 +12,13 @@ #include "simplnx/Filter/Actions/CreateGeometry2DAction.hpp" #include "simplnx/Parameters/ArraySelectionParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/DataGroupCreationParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Parameters/GeometrySelectionParameter.hpp" #include "simplnx/Parameters/MultiArraySelectionParameter.hpp" #include "simplnx/Parameters/NumberParameter.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" using namespace nx::core; @@ -59,6 +61,12 @@ Parameters SurfaceNetsFilter::parameters() const params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); params.insert(std::make_unique(k_RepairTriangleWinding_Key, "Attempt to Make Windings Consistent", "If true, attempts to repair the windings for the mesh. This may not be possible. See help page.", true)); + params.insert(std::make_unique(k_BoundingBoxSkinMode_Key, "Bounding Box Skin", + "Controls how triangles are generated on the outer wall of the bounding box. 'Off' generates the " + "wall as normal. 'Background-Backed Walls Only' omits wall faces where the wall borders the " + "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.insertLinkableParameter(std::make_unique(k_ApplySmoothing_Key, "Apply smoothing operations", "Use the built in smoothing operation.", false)); params.insert(std::make_unique(k_SmoothingIterations_Key, "Relaxation Iterations", "Number of relaxation iterations to perform. More iterations causes more smoothing.", 20)); @@ -108,7 +116,12 @@ Parameters SurfaceNetsFilter::parameters() const //------------------------------------------------------------------------------ IFilter::VersionType SurfaceNetsFilter::parametersVersion() const { - return 1; + return 2; + // 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); + // } //------------------------------------------------------------------------------ @@ -201,6 +214,7 @@ Result<> SurfaceNetsFilter::executeImpl(DataStructure& dataStructure, const Argu inputValues.ApplySmoothing = filterArgs.value(k_ApplySmoothing_Key); inputValues.RepairTriangleWinding = filterArgs.value(k_RepairTriangleWinding_Key); + inputValues.BoundingBoxSkinMode = filterArgs.value(k_BoundingBoxSkinMode_Key); inputValues.SmoothingIterations = filterArgs.value(k_SmoothingIterations_Key); inputValues.MaxDistanceFromVoxel = filterArgs.value(k_MaxDistanceFromVoxelCenter_Key); inputValues.RelaxationFactor = filterArgs.value(k_RelaxationFactor_Key); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.hpp index e0fa0f1f8f..3047e98c9d 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/SurfaceNetsFilter.hpp @@ -40,6 +40,7 @@ class SIMPLNXCORE_EXPORT SurfaceNetsFilter : public IFilter static constexpr StringLiteral k_ApplySmoothing_Key = "apply_smoothing"; static constexpr StringLiteral k_RepairTriangleWinding_Key = "repair_triangle_winding"; + static constexpr StringLiteral k_BoundingBoxSkinMode_Key = "bounding_box_skin_mode_index"; static constexpr StringLiteral k_SmoothingIterations_Key = "smoothing_iterations"; static constexpr StringLiteral k_MaxDistanceFromVoxelCenter_Key = "max_distance_from_voxel"; static constexpr StringLiteral k_RelaxationFactor_Key = "relaxation_factor"; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.cpp index 0b080194a4..0efc4f685b 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.cpp @@ -139,7 +139,6 @@ bool MMCellMap::setCellVertices() // Set cell type and count cell vertices. There are no vertices in right, front, // top faces. size_t numVertices = 0; - int32 cellIndex[3] = {0, 0, 0}; for(int k = 0; k < m_arraySize[2] - 1; k++) { for(int j = 0; j < m_arraySize[1] - 1; j++) @@ -148,51 +147,8 @@ bool MMCellMap::setCellVertices() { Cell* pCell = getCell(i, j, k); int32_t cellLabels[8]; - - cellIndex[0] = i; - cellIndex[1] = j; - cellIndex[2] = k; - // Label[0] - cellLabels[0] = label(cellIndex); - - // Label[1] - cellIndex[0] = i + 1; - cellLabels[1] = label(cellIndex); - - // Label[2] - cellIndex[0] = i + 1; - cellIndex[1] = j + 1; - cellLabels[2] = label(cellIndex); - - // Label[3] - cellIndex[0] = i; - cellIndex[1] = j + 1; - cellLabels[3] = label(cellIndex); - - // Label[4] - cellIndex[0] = i; - cellIndex[1] = j; - cellIndex[2] = k + 1; - cellLabels[4] = label(cellIndex); - - // Label[5] - cellIndex[0] = i + 1; - cellIndex[1] = j; - cellIndex[2] = k + 1; - cellLabels[5] = label(cellIndex); - - // Label[6] - cellIndex[0] = i + 1; - cellIndex[1] = j + 1; - cellIndex[2] = k + 1; - cellLabels[6] = label(cellIndex); - - // Label[7] - cellIndex[0] = i; - cellIndex[1] = j + 1; - cellIndex[2] = k + 1; - cellLabels[7] = label(cellIndex); - + const int cornerCellIndex[3] = {i, j, k}; + getCornerLabels(cornerCellIndex, cellLabels); pCell->flag.set(cellLabels); if(pCell->flag.vertexType() != MMCellFlag::VertexType::NoVertex) { @@ -665,41 +621,87 @@ size_t MMCellMap::cellArrayIndex(int i, int j, int k) const return (ii + m_arraySize[0] * jj + m_arraySize[0] * m_arraySize[1] * kk); } -void MMCellMap::getCellLabels(Cell* pCell, int32_t labels[8]) const +void MMCellMap::getCornerLabels(const int cellIndex[3], int32_t labels[8]) const { - // Labels of cell's 8 corner vertices. This ordering is used when computing cell - // flags. - Cell* cellPtr = pCell; - usize vertIdx = cellPtr->vertexIndex; - labels[0] = label(m_VertexArray.at(vertIdx).cellIndex); + // The cell's 8 corners are the 8 voxels surrounding a grid corner, ordered + // left-to-right, back-to-front, bottom-to-top. Must match the gather in setCellVertices(). + const int i = cellIndex[0]; + const int j = cellIndex[1]; + const int k = cellIndex[2]; + + int32 corner[3] = {i, j, k}; + labels[0] = label(corner); + + corner[0] = i + 1; + labels[1] = label(corner); + + corner[0] = i + 1; + corner[1] = j + 1; + labels[2] = label(corner); - cellPtr = pCell + 1; - vertIdx = cellPtr->vertexIndex; - labels[1] = label(m_VertexArray.at(vertIdx).cellIndex); + corner[0] = i; + corner[1] = j + 1; + labels[3] = label(corner); - cellPtr = (pCell + 1 + m_arraySize[0]); - vertIdx = cellPtr->vertexIndex; - labels[2] = label(m_VertexArray.at(vertIdx).cellIndex); + corner[0] = i; + corner[1] = j; + corner[2] = k + 1; + labels[4] = label(corner); - cellPtr = (pCell + m_arraySize[0]); - vertIdx = cellPtr->vertexIndex; - labels[3] = label(m_VertexArray.at(vertIdx).cellIndex); + corner[0] = i + 1; + corner[1] = j; + corner[2] = k + 1; + labels[5] = label(corner); - cellPtr = (pCell + m_arraySize[0] * m_arraySize[1]); - vertIdx = cellPtr->vertexIndex; - labels[4] = label(m_VertexArray.at(vertIdx).cellIndex); + corner[0] = i + 1; + corner[1] = j + 1; + corner[2] = k + 1; + labels[6] = label(corner); - cellPtr = (pCell + 1 + m_arraySize[0] * m_arraySize[1]); - vertIdx = cellPtr->vertexIndex; - labels[5] = label(m_VertexArray.at(vertIdx).cellIndex); + corner[0] = i; + corner[1] = j + 1; + corner[2] = k + 1; + labels[7] = label(corner); +} + +int8_t MMCellMap::nodeType(const int cellIndex[3]) const +{ + int32_t labels[8]; + getCornerLabels(cellIndex, labels); - cellPtr = (pCell + 1 + m_arraySize[0] + m_arraySize[0] * m_arraySize[1]); - vertIdx = cellPtr->vertexIndex; - labels[6] = label(m_VertexArray.at(vertIdx).cellIndex); + // Distinct-label count with Padding counted as one member, exactly as QuickSurfaceMesh + // counts -1 as a member of its owner list. + int32_t distinct[8]; + int numDistinct = 0; + bool hasPadding = false; + for(int idx = 0; idx < 8; idx++) + { + if(labels[idx] == MMSurfaceNet::ReservedLabel::Padding) + { + hasPadding = true; + } + bool alreadySeen = false; + for(int seenIdx = 0; seenIdx < numDistinct; seenIdx++) + { + if(distinct[seenIdx] == labels[idx]) + { + alreadySeen = true; + break; + } + } + if(!alreadySeen) + { + distinct[numDistinct] = labels[idx]; + numDistinct++; + } + } - cellPtr = (pCell + m_arraySize[0] + m_arraySize[0] * m_arraySize[1]); - vertIdx = cellPtr->vertexIndex; - labels[7] = label(m_VertexArray.at(vertIdx).cellIndex); + int8_t result = static_cast(numDistinct > 4 ? 4 : numDistinct); + if(hasPadding) + { + result = static_cast(result + 10); + } + return result; } bool MMCellMap::isEdgeCrossing(size_t cellMapIndex, MMCellFlag::Edge edge) const diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.h b/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.h index 71cf6ce598..16c25ead8f 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.h +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/SurfaceNets/MMCellMap.h @@ -41,6 +41,11 @@ class MMCellMap MMCellFlag::VertexType cellVertexType(size_t cellArrayIndex) const; + // Node type in the shared simplnx NodeType convention: min(distinct corner labels, 4), + // plus 10 when any corner is exterior padding. This is the same quantity QuickSurfaceMesh + // computes from its per-node owner list, over the same 8 surrounding voxels. + int8_t nodeType(const int cellIndex[3]) const; + struct Vertex { int32_t cellIndex[3]; @@ -76,7 +81,7 @@ class MMCellMap // Access cell map size_t cellArrayIndex(int i, int j, int k) const; - void getCellLabels(Cell* cell, int32_t labels[8]) const; + void getCornerLabels(const int cellIndex[3], int32_t labels[8]) const; bool isEdgeCrossing(size_t cellArrayIndex, MMCellFlag::Edge edge) const; void getEdgeLabels(int cellIndex[3], MMCellFlag::Edge edge, int32_t quadLabels[2], size_t quadNxArrayIndices[2]) const; void getEdgeQuadPositions(int cellIndex[3], MMCellFlag::Edge edge, float quadCorners[12]) const; diff --git a/src/Plugins/SimplnxCore/test/CMakeLists.txt b/src/Plugins/SimplnxCore/test/CMakeLists.txt index 23fe986f96..a8066f52e3 100644 --- a/src/Plugins/SimplnxCore/test/CMakeLists.txt +++ b/src/Plugins/SimplnxCore/test/CMakeLists.txt @@ -105,6 +105,7 @@ set(${PLUGIN_NAME}UnitTest_SRCS MoveDataTest.cpp MultiThresholdObjectsTest.cpp NearestPointFuseRegularGridsTest.cpp + OmitBoundingBoxSkinTest.cpp PadImageGeometryTest.cpp PartitionGeometryTest.cpp PipelineTest.cpp @@ -148,6 +149,9 @@ set(${PLUGIN_NAME}UnitTest_SRCS SliceTriangleGeometryTest.cpp SplitDataArrayByComponentTest.cpp SplitDataArrayByTupleTest.cpp + SurfaceMeshingTestUtilsTest.cpp + SurfaceNetsFaceLabelsTest.cpp + SurfaceNetsNodeTypesTest.cpp SurfaceNetsTest.cpp TriangleCentroidTest.cpp TriangleDihedralAngleFilterTest.cpp @@ -284,7 +288,7 @@ if(EXISTS "${DREAM3D_DATA_DIR}" AND SIMPLNX_DOWNLOAD_TEST_FILES) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME Small_IN100_dream3d_v3.tar.gz SHA512 bfa9547e787b0f8e8122702da0eb4e519f48a48bf4f94aad020f72479d071d32dfc96a1425705874c68507a61ed391d28606d9c4f4acd559043ef0ace64fd33f) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME Small_IN100_h5ebsd.tar.gz SHA512 31e606285ea9e8235dcb5f608fd2b252a5ab1492abd975e5ec33a21d083aa9720fe16fb8f752742c140f40e963d692f1a46256b9d36e96b1b09796c1e4ea3db9) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME SurfaceMeshTest.tar.gz SHA512 a74e9fa40ccec78174dbbac90969bfa17367c3a7e14b7b84624032317c351de89957750803d7cb43c67dec19281ee4f647de022d474566fd43e25b8230cce6d6) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME SurfaceNetsTest_v3.tar.gz SHA512 06865d006a5fb37b66c8045858112af6e2e2e077ee685246acb5516670954a9d87e0791b7e75aa74011e8187fc4625f3b3f97237b09d02475968c2768a65bd7c) + download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME SurfaceNetsTest_v4.tar.gz SHA512 9a89b55630120dc9c2701500e81da3f8d126fbc6e8a2de8ec21e1a8ef97ae90fda6c7a093a8217c65e1db5f5cebd091c37c8f6561cd44d3def911c8acbf83e49) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME volume_graphics_test.tar.gz SHA512 94d996fbf2b8b42cd715fb3adf33548f097970211e7a26eb9ccc5b073a78014eecbd10d40fc0451ff9cc7c92e23ed21582fc082d64e1f62b3714e4d9640c975f) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME vtk_rectilinear_grid_writer.tar.gz SHA512 9fef02b5269609503d03dd0126cc635cc1c1156894cff18b0184b334d705b850ca1a06ae0d1c66a352a32dd9ad9fb74f24255c7de1399b06bbec7d2e2b41941b) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME winding_quick_surface_mesh.tar.gz SHA512 ed9944b969c4c2feea8902f98d03024248780607848a40ad374d9a1c7861df1d971a355d779a7af8c775f2225b6364b9a17d61c2350375293e531bcee59255b7) diff --git a/src/Plugins/SimplnxCore/test/ExtractInternalSurfacesFromTriangleGeometryTest.cpp b/src/Plugins/SimplnxCore/test/ExtractInternalSurfacesFromTriangleGeometryTest.cpp index a6c67a014b..b2e0e28214 100644 --- a/src/Plugins/SimplnxCore/test/ExtractInternalSurfacesFromTriangleGeometryTest.cpp +++ b/src/Plugins/SimplnxCore/test/ExtractInternalSurfacesFromTriangleGeometryTest.cpp @@ -1,11 +1,16 @@ #include "SimplnxCore/Filters/ExtractInternalSurfacesFromTriangleGeometryFilter.hpp" +#include "SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" +#include "SurfaceMeshingTestUtils.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/DataArray.hpp" #include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" #include @@ -182,3 +187,97 @@ TEST_CASE("SimplnxCore::ExtractInternalSurfacesFromTriangleGeometryFilter: SIMPL } } } + +namespace +{ +// Meshes the flush cylinder from SurfaceMeshingTestUtils with QuickSurfaceMesh, leaving Fix +// Problem Voxels off so the mesh is deterministic. omitSkin selects whether QuickSurfaceMesh's own +// Bounding Box Skin option runs, so the same helper can build both the "full" mesh (skin left +// on, to be pruned by this filter) and the "direct" oracle mesh (skin already omitted by the mesher). +const DataPath k_QuickMeshPath({"QuickMesh"}); + +SurfaceMeshingTest::MeshResult RunQuickSurfaceMeshForExtraction(ChoicesParameter::ValueType boundingBoxSkinMode) +{ + return SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(true), k_QuickMeshPath, boundingBoxSkinMode, + [](Arguments& args) { args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); }); +} +} // namespace + +TEST_CASE("SimplnxCore::ExtractInternalSurfacesFromTriangleGeometryFilter: Face Labels criterion", "[SimplnxCore][ExtractInternalSurfacesFromTriangleGeometryFilter]") +{ + UnitTest::LoadPlugins(); + + // Mesh the flush cylinder WITHOUT omitting the skin, then strip it with the Face Labels + // criterion. The result must match what QuickSurfaceMesh produces with the option ON. + SurfaceMeshingTest::MeshResult fullMeshResult = RunQuickSurfaceMeshForExtraction(BoundingBoxSkinMode::k_Off); + DataStructure dataStructure = std::move(fullMeshResult.Structure); + const DataPath faceLabelsPath = fullMeshResult.FaceLabelsPath; + + const DataPath extractedPath({"Internal"}); + { + ExtractInternalSurfacesFromTriangleGeometryFilter filter; + Arguments args; + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_SelectedTriangleGeometryPath_Key, std::make_any(k_QuickMeshPath)); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CriterionMode_Key, std::make_any(1)); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_FaceLabelsPath_Key, std::make_any(faceLabelsPath)); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CreatedTriangleGeometryPath_Key, std::make_any(extractedPath)); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_VertexAttributeMatrixName_Key, std::make_any("Vertex Data")); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_TriangleAttributeMatrixName_Key, std::make_any("Face Data")); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CopyVertexPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType())); + // Copy Face Labels along into the extracted geometry too, so the "both code paths agree" claim + // below can be checked array-for-array rather than just by counting faces and vertices. + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CopyTrianglePaths_Key, + std::make_any(MultiArraySelectionParameter::ValueType{faceLabelsPath})); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + } + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(extractedPath)); + const auto& extractedGeom = dataStructure.getDataRefAs(extractedPath); + + // Face-label stripping does not erode the rim, so the cylinder stays closed. + REQUIRE(SurfaceMeshingTest::IsWatertight(extractedGeom)); + + // Same face and vertex counts as meshing with QuickSurfaceMesh's own option turned on directly: + // both code paths must agree on what "internal" means. + SurfaceMeshingTest::MeshResult directMeshResult = RunQuickSurfaceMeshForExtraction(BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + const auto& directGeom = directMeshResult.Structure.getDataRefAs(k_QuickMeshPath); + + REQUIRE(extractedGeom.getNumberOfFaces() == directGeom.getNumberOfFaces()); + REQUIRE(extractedGeom.getNumberOfVertices() == directGeom.getNumberOfVertices()); + + // The two paths must agree exactly, not just on counts: compare the Face Labels array copied + // into the extracted geometry against the one QuickSurfaceMesh produced directly. + const DataPath extractedFaceLabelsPath = extractedPath.createChildPath("Face Data").createChildPath(faceLabelsPath.getTargetName()); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(extractedFaceLabelsPath)); + REQUIRE_NOTHROW(directMeshResult.Structure.getDataRefAs(directMeshResult.FaceLabelsPath)); + UnitTest::CompareDataArrays(dataStructure.getDataRefAs(extractedFaceLabelsPath), directMeshResult.Structure.getDataRefAs(directMeshResult.FaceLabelsPath)); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ExtractInternalSurfacesFromTriangleGeometryFilter: Face Labels criterion requires a valid array", "[SimplnxCore][ExtractInternalSurfacesFromTriangleGeometryFilter]") +{ + UnitTest::LoadPlugins(); + + SurfaceMeshingTest::MeshResult meshResult = RunQuickSurfaceMeshForExtraction(BoundingBoxSkinMode::k_Off); + DataStructure dataStructure = std::move(meshResult.Structure); + + ExtractInternalSurfacesFromTriangleGeometryFilter filter; + Arguments args; + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_SelectedTriangleGeometryPath_Key, std::make_any(k_QuickMeshPath)); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CriterionMode_Key, std::make_any(1)); + // Face Labels path is left at its default (an empty DataPath), which does not exist in the + // DataStructure. Preflight must reject this with a clear error instead of crashing in execute. + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CreatedTriangleGeometryPath_Key, std::make_any(DataPath({"Internal"}))); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_VertexAttributeMatrixName_Key, std::make_any("Vertex Data")); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_TriangleAttributeMatrixName_Key, std::make_any("Face Data")); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CopyVertexPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType())); + args.insertOrAssign(ExtractInternalSurfacesFromTriangleGeometryFilter::k_CopyTrianglePaths_Key, std::make_any(MultiArraySelectionParameter::ValueType())); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); +} diff --git a/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp b/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp index f1f044621f..fa61f01ea9 100644 --- a/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp +++ b/src/Plugins/SimplnxCore/test/M3CSurfaceMeshingTest.cpp @@ -1,5 +1,6 @@ #include "SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" +#include "SurfaceMeshingTestUtils.hpp" #include "simplnx/Core/Application.hpp" #include "simplnx/DataStructure/AttributeMatrix.hpp" @@ -14,10 +15,13 @@ #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" #include +#include #include +#include using namespace nx::core; using namespace nx::core::UnitTest; @@ -524,3 +528,148 @@ TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Exemplar Comparison", "[Simplnx UnitTest::CheckArraysInheritTupleDims(dataStructure); } + +namespace +{ +// Portable environment-variable mutators. POSIX declares setenv()/unsetenv(), but MSVC does not, +// so plain std::getenv() is used for reads everywhere else in this repo while writes need this +// shim. MSVC's _putenv_s() removes a variable when given an empty string, which is the closest +// portable equivalent to unsetenv(). +#ifdef _WIN32 +void SetEnvVar(const std::string& name, const char* value) +{ + _putenv_s(name.c_str(), value != nullptr ? value : ""); +} + +void UnsetEnvVar(const std::string& name) +{ + _putenv_s(name.c_str(), ""); +} +#else +void SetEnvVar(const std::string& name, const char* value) +{ + ::setenv(name.c_str(), value, 1); +} + +void UnsetEnvVar(const std::string& name) +{ + ::unsetenv(name.c_str()); +} +#endif + +// Temporarily sets (or clears) an environment variable for the lifetime of the object, restoring +// whatever was there before on destruction. Used below to select M3C's sweep path, which is read +// once from the environment at the top of M3CSurfaceMeshing::operator()(). +class ScopedEnvVar +{ +public: + ScopedEnvVar(std::string name, const char* value) + : m_Name(std::move(name)) + { + if(const char* previous = std::getenv(m_Name.c_str()); previous != nullptr) + { + m_HadPrevious = true; + m_PreviousValue = previous; + } + if(value != nullptr) + { + SetEnvVar(m_Name, value); + } + else + { + UnsetEnvVar(m_Name); + } + } + + ~ScopedEnvVar() + { + if(m_HadPrevious) + { + SetEnvVar(m_Name, m_PreviousValue.c_str()); + } + else + { + UnsetEnvVar(m_Name); + } + } + + ScopedEnvVar(const ScopedEnvVar&) = delete; + ScopedEnvVar(ScopedEnvVar&&) noexcept = delete; + ScopedEnvVar& operator=(const ScopedEnvVar&) = delete; + ScopedEnvVar& operator=(ScopedEnvVar&&) noexcept = delete; + +private: + std::string m_Name; + bool m_HadPrevious = false; + std::string m_PreviousValue; +}; + +const DataPath k_SweepPathTriGeomPath({"M3CSweepPathMesh"}); + +// Runs the flush-with-bottom cylinder (Task 1's shared test dataset) through M3C with the Bounding +// Box Skin option's 'Background-Backed Walls Only' mode enabled, forcing the sweep path selected by the two M3C_* environment variables (see +// M3CSurfaceMeshing::operator()()). Passing false/false for both selects the default parallel +// sliding-window path. +SurfaceMeshingTest::MeshResult RunM3COmitSkinOnPath(bool wholeVolume, bool serial) +{ + const ScopedEnvVar wholeVolumeVar("M3C_WHOLE_VOLUME", wholeVolume ? "1" : nullptr); + const ScopedEnvVar serialVar("M3C_SERIAL", serial ? "1" : nullptr); + return SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(true), k_SweepPathTriGeomPath, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly, + [](Arguments&) {}); +} +} // namespace + +TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Bounding Box Skin agrees across sweep paths", "[SimplnxCore][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // M3C's two serial paths (runEntireVolume and runWindowed(false)) are documented as byte-identical to + // each other, including the exact triangulation. The default parallel path (runWindowed(true)) is + // documented to produce byte-identical vertices, FaceLabels, and NodeTypes, but MAY legitimately + // triangulate the same interfaces differently (the legacy per-cube loop triangulation depends on + // cross-cube edge-flip propagation, which is inherently serial) -- so its Shared Faces List is not + // compared for exact equality here. Confirm those same guarantees still hold with the Bounding Box + // Skin option's 'Background-Backed Walls Only' mode enabled: the prune runs once, inside the shared + // finalizeMesh(), after all three paths have produced their triangles/mCubeID vectors, so it should + // not disturb this invariant either way. + SurfaceMeshingTest::MeshResult wholeVolumeResult = RunM3COmitSkinOnPath(/*wholeVolume*/ true, /*serial*/ false); + SurfaceMeshingTest::MeshResult serialWindowedResult = RunM3COmitSkinOnPath(/*wholeVolume*/ false, /*serial*/ true); + SurfaceMeshingTest::MeshResult parallelWindowedResult = RunM3COmitSkinOnPath(/*wholeVolume*/ false, /*serial*/ false); + + REQUIRE_NOTHROW(wholeVolumeResult.Structure.getDataRefAs(k_SweepPathTriGeomPath)); + REQUIRE_NOTHROW(serialWindowedResult.Structure.getDataRefAs(k_SweepPathTriGeomPath)); + REQUIRE_NOTHROW(parallelWindowedResult.Structure.getDataRefAs(k_SweepPathTriGeomPath)); + const auto& wholeVolumeGeom = wholeVolumeResult.Structure.getDataRefAs(k_SweepPathTriGeomPath); + const auto& serialWindowedGeom = serialWindowedResult.Structure.getDataRefAs(k_SweepPathTriGeomPath); + const auto& parallelWindowedGeom = parallelWindowedResult.Structure.getDataRefAs(k_SweepPathTriGeomPath); + + REQUIRE(wholeVolumeGeom.getNumberOfVertices() == serialWindowedGeom.getNumberOfVertices()); + REQUIRE(wholeVolumeGeom.getNumberOfVertices() == parallelWindowedGeom.getNumberOfVertices()); + REQUIRE(wholeVolumeGeom.getNumberOfFaces() == serialWindowedGeom.getNumberOfFaces()); + REQUIRE(wholeVolumeGeom.getNumberOfFaces() == parallelWindowedGeom.getNumberOfFaces()); + + // Vertices (Shared Vertex List) must match exactly across all three paths. + UnitTest::CompareArrays(wholeVolumeGeom.getVertices(), serialWindowedGeom.getVertices()); + UnitTest::CompareArrays(wholeVolumeGeom.getVertices(), parallelWindowedGeom.getVertices()); + + // Shared Faces List: only the two serial paths are guaranteed to match exactly (see comment above). + UnitTest::CompareArrays(wholeVolumeGeom.getFaces(), serialWindowedGeom.getFaces()); + + // RunM3COmitSkinOnPath (via SurfaceMeshingTest::RunMesher) creates "FaceLabels" and "NodeTypes" -- + // NOT the nx::core::UnitTest::k_Face_Labels ("Face Labels") / k_NodeTypeArrayName ("Node Type") + // constants used elsewhere in this file for the SIMPL-compatibility/exemplar tests. + const DataPath faceLabelsPath = wholeVolumeResult.FaceLabelsPath; + const DataPath nodeTypesPath = k_SweepPathTriGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + + // Face Labels must match exactly across all three paths. + UnitTest::CompareArrays(wholeVolumeResult.Structure.getDataAs(faceLabelsPath), serialWindowedResult.Structure.getDataAs(faceLabelsPath)); + UnitTest::CompareArrays(wholeVolumeResult.Structure.getDataAs(faceLabelsPath), parallelWindowedResult.Structure.getDataAs(faceLabelsPath)); + + // Node Types must match exactly across all three paths. + UnitTest::CompareArrays(wholeVolumeResult.Structure.getDataAs(nodeTypesPath), serialWindowedResult.Structure.getDataAs(nodeTypesPath)); + UnitTest::CompareArrays(wholeVolumeResult.Structure.getDataAs(nodeTypesPath), parallelWindowedResult.Structure.getDataAs(nodeTypesPath)); + + UnitTest::CheckArraysInheritTupleDims(wholeVolumeResult.Structure); + UnitTest::CheckArraysInheritTupleDims(serialWindowedResult.Structure); + UnitTest::CheckArraysInheritTupleDims(parallelWindowedResult.Structure); +} diff --git a/src/Plugins/SimplnxCore/test/OmitBoundingBoxSkinTest.cpp b/src/Plugins/SimplnxCore/test/OmitBoundingBoxSkinTest.cpp new file mode 100644 index 0000000000..c4ecf0acc8 --- /dev/null +++ b/src/Plugins/SimplnxCore/test/OmitBoundingBoxSkinTest.cpp @@ -0,0 +1,1021 @@ +#include "SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp" +#include "SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp" +#include "SimplnxCore/Filters/SurfaceNetsFilter.hpp" +#include "SurfaceMeshingTestUtils.hpp" + +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/MultiArraySelectionParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +using namespace nx::core; + +namespace +{ +const DataPath k_TriangleGeomPath({"QuickMesh"}); + +SurfaceMeshingTest::MeshResult RunQuickSurfaceMesh(bool flushWithBottom, ChoicesParameter::ValueType boundingBoxSkinMode) +{ + return SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(flushWithBottom), k_TriangleGeomPath, boundingBoxSkinMode, + [](Arguments& args) { args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); }); +} +} // namespace + +TEST_CASE("SimplnxCore::QuickSurfaceMeshFilter: Bounding Box Skin", "[SimplnxCore][QuickSurfaceMeshFilter]") +{ + UnitTest::LoadPlugins(); + + SECTION("Option off leaves the box skin over background in place") + { + SurfaceMeshingTest::MeshResult meshResult = RunQuickSurfaceMesh(true, BoundingBoxSkinMode::k_Off); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + REQUIRE(labelPairs.count({-1, 0}) == 1); + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("Option on removes only the background skin and keeps the cylinder closed") + { + SurfaceMeshingTest::MeshResult meshResult = RunQuickSurfaceMesh(true, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + + // The artificial background skin is gone. + REQUIRE(labelPairs.count({-1, 0}) == 0); + // The cylinder's bottom cap survives -- this is the issue-2 fix. + REQUIRE(labelPairs.count({-1, 1}) == 1); + // The cylinder wall is untouched. + REQUIRE(labelPairs.count({0, 1}) == 1); + + // With the cap kept, the cylinder is a closed surface. + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + REQUIRE(SurfaceMeshingTest::IsWatertight(triangleGeom)); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("Option on is a no-op when nothing touches the wall except background") + { + // Inset cylinder: the whole box wall is background, so ALL skin is dropped and only + // the cylinder surface remains -- which is closed on its own. + SurfaceMeshingTest::MeshResult meshResult = RunQuickSurfaceMesh(false, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + REQUIRE(labelPairs.count({-1, 0}) == 0); + REQUIRE(labelPairs.count({-1, 1}) == 0); + REQUIRE(labelPairs.count({0, 1}) == 1); + + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + REQUIRE(SurfaceMeshingTest::IsWatertight(triangleGeom)); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("Node Types of surviving nodes match the full mesh") + { + SurfaceMeshingTest::MeshResult fullMesh = RunQuickSurfaceMesh(true, BoundingBoxSkinMode::k_Off); + SurfaceMeshingTest::MeshResult prunedMesh = RunQuickSurfaceMesh(true, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + const DataPath nodeTypesPath = k_TriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(fullMesh.Structure.getDataRefAs(k_TriangleGeomPath)); + const auto& fullVertsRef = fullMesh.Structure.getDataRefAs(k_TriangleGeomPath).getVertices()->getDataStoreRef(); + REQUIRE_NOTHROW(fullMesh.Structure.getDataRefAs(nodeTypesPath)); + const auto& fullTypesRef = fullMesh.Structure.getDataRefAs(nodeTypesPath).getDataStoreRef(); + REQUIRE_NOTHROW(prunedMesh.Structure.getDataRefAs(k_TriangleGeomPath)); + const auto& prunedVertsRef = prunedMesh.Structure.getDataRefAs(k_TriangleGeomPath).getVertices()->getDataStoreRef(); + REQUIRE_NOTHROW(prunedMesh.Structure.getDataRefAs(nodeTypesPath)); + const auto& prunedTypesRef = prunedMesh.Structure.getDataRefAs(nodeTypesPath).getDataStoreRef(); + + // Map full-mesh vertex coordinate -> node type, then check every surviving vertex agrees. + std::map, int8> fullTypeByCoord; + for(usize i = 0; i < fullTypesRef.getNumberOfTuples(); i++) + { + fullTypeByCoord[{fullVertsRef[i * 3], fullVertsRef[i * 3 + 1], fullVertsRef[i * 3 + 2]}] = fullTypesRef[i]; + } + + for(usize i = 0; i < prunedTypesRef.getNumberOfTuples(); i++) + { + const std::tuple coord = {prunedVertsRef[i * 3], prunedVertsRef[i * 3 + 1], prunedVertsRef[i * 3 + 2]}; + REQUIRE(fullTypeByCoord.count(coord) == 1); + REQUIRE(fullTypeByCoord[coord] == prunedTypesRef[i]); + } + + UnitTest::CheckArraysInheritTupleDims(fullMesh.Structure); + UnitTest::CheckArraysInheritTupleDims(prunedMesh.Structure); + } +} + +namespace +{ +const DataPath k_SurfaceNetsTriangleGeomPath({"SurfaceNets"}); + +SurfaceMeshingTest::MeshResult RunSurfaceNets(bool flushWithBottom, ChoicesParameter::ValueType boundingBoxSkinMode) +{ + return SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(flushWithBottom), k_SurfaceNetsTriangleGeomPath, boundingBoxSkinMode, [](Arguments& args) { + args.insertOrAssign(SurfaceNetsFilter::k_ApplySmoothing_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_SmoothingIterations_Key, std::make_any(20)); + args.insertOrAssign(SurfaceNetsFilter::k_MaxDistanceFromVoxelCenter_Key, std::make_any(1.0F)); + args.insertOrAssign(SurfaceNetsFilter::k_RelaxationFactor_Key, std::make_any(0.5F)); + }); +} +} // namespace + +TEST_CASE("SimplnxCore::SurfaceNetsFilter: Bounding Box Skin", "[SimplnxCore][SurfaceNetsFilter]") +{ + UnitTest::LoadPlugins(); + + SECTION("Option off leaves the box skin over background in place") + { + SurfaceMeshingTest::MeshResult meshResult = RunSurfaceNets(true, BoundingBoxSkinMode::k_Off); + REQUIRE(SurfaceMeshingTest::CollectLabelPairs(meshResult).count({-1, 0}) == 1); + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("Option on removes only the background skin and keeps the cylinder closed") + { + SurfaceMeshingTest::MeshResult meshResult = RunSurfaceNets(true, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + REQUIRE(labelPairs.count({-1, 0}) == 0); + REQUIRE(labelPairs.count({-1, 1}) == 1); + REQUIRE(labelPairs.count({0, 1}) == 1); + + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + REQUIRE(SurfaceMeshingTest::IsWatertight(triangleGeom)); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("Option on leaves no orphan vertices") + { + SurfaceMeshingTest::MeshResult meshResult = RunSurfaceNets(true, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + const auto& facesRef = triangleGeom.getFaces()->getDataStoreRef(); + + std::set referencedVertices; + for(usize i = 0; i < triangleGeom.getNumberOfFaces() * 3; i++) + { + referencedVertices.insert(static_cast(facesRef[i])); + } + REQUIRE(referencedVertices.size() == triangleGeom.getNumberOfVertices()); + + const DataPath nodeTypesPath = meshResult.TriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(nodeTypesPath)); + const auto& nodeTypesRef = meshResult.Structure.getDataRefAs(nodeTypesPath).getDataStoreRef(); + REQUIRE(nodeTypesRef.getNumberOfTuples() == triangleGeom.getNumberOfVertices()); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } +} + +namespace +{ +const DataPath k_M3CTriangleGeomPath({"M3CMesh"}); + +SurfaceMeshingTest::MeshResult RunM3C(bool flushWithBottom, ChoicesParameter::ValueType boundingBoxSkinMode) +{ + return SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(flushWithBottom), k_M3CTriangleGeomPath, boundingBoxSkinMode, [](Arguments&) {}); +} +} // namespace + +TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Bounding Box Skin", "[SimplnxCore][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + SECTION("Option off leaves the box skin over background in place") + { + SurfaceMeshingTest::MeshResult meshResult = RunM3C(true, BoundingBoxSkinMode::k_Off); + REQUIRE(SurfaceMeshingTest::CollectLabelPairs(meshResult).count({-1, 0}) == 1); + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("Option on removes only the background skin and keeps the cylinder closed") + { + SurfaceMeshingTest::MeshResult meshResult = RunM3C(true, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + REQUIRE(labelPairs.count({-1, 0}) == 0); + REQUIRE(labelPairs.count({-1, 1}) == 1); + REQUIRE(labelPairs.count({0, 1}) == 1); + + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + REQUIRE(SurfaceMeshingTest::IsWatertight(triangleGeom)); + + 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. + SECTION("Option on leaves no vertex newly orphaned by the prune") + { + SurfaceMeshingTest::MeshResult fullMesh = RunM3C(true, BoundingBoxSkinMode::k_Off); + SurfaceMeshingTest::MeshResult prunedMesh = RunM3C(true, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + const auto isReferencedByCoord = [](const TriangleGeom& triangleGeom) { + const auto& vertsRef = triangleGeom.getVertices()->getDataStoreRef(); + const auto& facesRef = triangleGeom.getFaces()->getDataStoreRef(); + + std::set referencedIndices; + for(usize i = 0; i < triangleGeom.getNumberOfFaces() * 3; i++) + { + referencedIndices.insert(static_cast(facesRef[i])); + } + + std::map, bool> result; + for(usize v = 0; v < triangleGeom.getNumberOfVertices(); v++) + { + const std::tuple coord = {vertsRef[v * 3], vertsRef[v * 3 + 1], vertsRef[v * 3 + 2]}; + result[coord] = referencedIndices.count(v) > 0; + } + return result; + }; + + REQUIRE_NOTHROW(fullMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath)); + REQUIRE_NOTHROW(prunedMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto fullReferenced = isReferencedByCoord(fullMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto prunedReferenced = isReferencedByCoord(prunedMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath)); + + usize newlyOrphanedCount = 0; + for(const auto& [coord, isReferencedInPruned] : prunedReferenced) + { + if(isReferencedInPruned) + { + continue; + } + // A vertex that survives the prune unreferenced must have also been unreferenced pre-prune. + REQUIRE(fullReferenced.count(coord) == 1); + if(fullReferenced.at(coord)) + { + newlyOrphanedCount++; + } + } + + INFO("Vertices newly orphaned by the prune (must be 0): " << newlyOrphanedCount); + REQUIRE(newlyOrphanedCount == 0); + + UnitTest::CheckArraysInheritTupleDims(fullMesh.Structure); + UnitTest::CheckArraysInheritTupleDims(prunedMesh.Structure); + } +} + +TEST_CASE("SimplnxCore::M3CSurfaceMeshingFilter: Node Types after omitting skin", "[SimplnxCore][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + SurfaceMeshingTest::MeshResult fullMesh = RunM3C(true, BoundingBoxSkinMode::k_Off); + SurfaceMeshingTest::MeshResult prunedMesh = RunM3C(true, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + const DataPath nodeTypesPath = k_M3CTriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(fullMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto& fullVertsRef = fullMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath).getVertices()->getDataStoreRef(); + REQUIRE_NOTHROW(fullMesh.Structure.getDataRefAs(nodeTypesPath)); + const auto& fullTypesRef = fullMesh.Structure.getDataRefAs(nodeTypesPath).getDataStoreRef(); + REQUIRE_NOTHROW(prunedMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto& prunedVertsRef = prunedMesh.Structure.getDataRefAs(k_M3CTriangleGeomPath).getVertices()->getDataStoreRef(); + REQUIRE_NOTHROW(prunedMesh.Structure.getDataRefAs(nodeTypesPath)); + const auto& prunedTypesRef = prunedMesh.Structure.getDataRefAs(nodeTypesPath).getDataStoreRef(); + + // Map full-mesh vertex coordinate -> node type, then check every surviving vertex against it. + std::map, int8> fullTypeByCoord; + for(usize i = 0; i < fullTypesRef.getNumberOfTuples(); i++) + { + fullTypeByCoord[{fullVertsRef[i * 3], fullVertsRef[i * 3 + 1], fullVertsRef[i * 3 + 2]}] = fullTypesRef[i]; + } + + usize divergentCount = 0; + for(usize i = 0; i < prunedTypesRef.getNumberOfTuples(); i++) + { + const std::tuple coord = {prunedVertsRef[i * 3], prunedVertsRef[i * 3 + 1], prunedVertsRef[i * 3 + 2]}; + REQUIRE(fullTypeByCoord.count(coord) == 1); + if(fullTypeByCoord[coord] != prunedTypesRef[i]) + { + divergentCount++; + } + } + + // The spec deliberately makes no preservation claim for M3C. Record the finding: if this + // is non-zero, characterize which nodes diverge and document it in the filter docs. + INFO("M3C nodes whose Node Type changed when the skin was omitted: " << divergentCount); + REQUIRE(divergentCount == 0); +} + +namespace +{ +// Raw (unchecked-result) runners built on the shared RunMesherRaw helper, reusing the same +// Triangle Geometry paths and mesher-specific extra args as the RunQuickSurfaceMesh / +// RunSurfaceNets / RunM3C wrappers above, so degenerate cases can inspect the execute Result<> +// (e.g. warnings) instead of asserting validity and aborting. +Result<> RunQuickSurfaceMeshRaw(DataStructure& dataStructure, ChoicesParameter::ValueType boundingBoxSkinMode, bool repairWinding = false) +{ + return SurfaceMeshingTest::RunMesherRaw( + dataStructure, k_TriangleGeomPath, boundingBoxSkinMode, [](Arguments& args) { args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); }, repairWinding); +} + +Result<> RunSurfaceNetsRaw(DataStructure& dataStructure, ChoicesParameter::ValueType boundingBoxSkinMode, bool repairWinding = false) +{ + return SurfaceMeshingTest::RunMesherRaw( + dataStructure, k_SurfaceNetsTriangleGeomPath, boundingBoxSkinMode, + [](Arguments& args) { + args.insertOrAssign(SurfaceNetsFilter::k_ApplySmoothing_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_SmoothingIterations_Key, std::make_any(20)); + args.insertOrAssign(SurfaceNetsFilter::k_MaxDistanceFromVoxelCenter_Key, std::make_any(1.0F)); + args.insertOrAssign(SurfaceNetsFilter::k_RelaxationFactor_Key, std::make_any(0.5F)); + }, + repairWinding); +} + +Result<> RunM3CRaw(DataStructure& dataStructure, ChoicesParameter::ValueType boundingBoxSkinMode, bool repairWinding = false) +{ + return SurfaceMeshingTest::RunMesherRaw( + dataStructure, k_M3CTriangleGeomPath, boundingBoxSkinMode, [](Arguments&) {}, repairWinding); +} +} // namespace + +TEST_CASE("SimplnxCore::Bounding Box Skin is a no-op without background", "[SimplnxCore][QuickSurfaceMeshFilter][SurfaceNetsFilter][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // A fully-indexed volume has no Feature Id 0, so no face can match {-1, 0} and the + // option must change nothing -- every boundary Feature keeps its wall cap. + SECTION("QuickSurfaceMesh") + { + DataStructure offStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + DataStructure onStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + // Store each Result<> before handing it to SIMPLNX_RESULT_REQUIRE_VALID: that macro expands + // its argument multiple times (once per accessor), so passing the RunXRaw(...) call directly + // would run the filter more than once against the same DataStructure. + const Result<> offResult = RunQuickSurfaceMeshRaw(offStructure, BoundingBoxSkinMode::k_Off); + SIMPLNX_RESULT_REQUIRE_VALID(offResult); + const Result<> onResult = RunQuickSurfaceMeshRaw(onStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + SIMPLNX_RESULT_REQUIRE_VALID(onResult); + + REQUIRE_NOTHROW(offStructure.getDataRefAs(k_TriangleGeomPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(k_TriangleGeomPath)); + const auto& offGeom = offStructure.getDataRefAs(k_TriangleGeomPath); + const auto& onGeom = onStructure.getDataRefAs(k_TriangleGeomPath); + REQUIRE(onGeom.getNumberOfFaces() == offGeom.getNumberOfFaces()); + REQUIRE(onGeom.getNumberOfVertices() == offGeom.getNumberOfVertices()); + UnitTest::CompareDataArrays(*offGeom.getFaces(), *onGeom.getFaces()); + UnitTest::CompareDataArrays(*offGeom.getVertices(), *onGeom.getVertices()); + + const DataPath faceLabelsPath = k_TriangleGeomPath.createChildPath("Face Data").createChildPath("FaceLabels"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(faceLabelsPath)); + 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. + const DataPath nodeTypesPath = k_TriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(nodeTypesPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(nodeTypesPath)); + UnitTest::CompareDataArrays(offStructure.getDataRefAs(nodeTypesPath), onStructure.getDataRefAs(nodeTypesPath)); + + UnitTest::CheckArraysInheritTupleDims(offStructure); + UnitTest::CheckArraysInheritTupleDims(onStructure); + } + + SECTION("SurfaceNets") + { + DataStructure offStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + DataStructure onStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + const Result<> offResult = RunSurfaceNetsRaw(offStructure, BoundingBoxSkinMode::k_Off); + SIMPLNX_RESULT_REQUIRE_VALID(offResult); + const Result<> onResult = RunSurfaceNetsRaw(onStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + SIMPLNX_RESULT_REQUIRE_VALID(onResult); + + REQUIRE_NOTHROW(offStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath)); + const auto& offGeom = offStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath); + const auto& onGeom = onStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath); + REQUIRE(onGeom.getNumberOfFaces() == offGeom.getNumberOfFaces()); + REQUIRE(onGeom.getNumberOfVertices() == offGeom.getNumberOfVertices()); + UnitTest::CompareDataArrays(*offGeom.getFaces(), *onGeom.getFaces()); + UnitTest::CompareDataArrays(*offGeom.getVertices(), *onGeom.getVertices()); + + const DataPath faceLabelsPath = k_SurfaceNetsTriangleGeomPath.createChildPath("Face Data").createChildPath("FaceLabels"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(faceLabelsPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(faceLabelsPath)); + UnitTest::CompareDataArrays(offStructure.getDataRefAs(faceLabelsPath), onStructure.getDataRefAs(faceLabelsPath)); + + const DataPath nodeTypesPath = k_SurfaceNetsTriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(nodeTypesPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(nodeTypesPath)); + UnitTest::CompareDataArrays(offStructure.getDataRefAs(nodeTypesPath), onStructure.getDataRefAs(nodeTypesPath)); + + UnitTest::CheckArraysInheritTupleDims(offStructure); + UnitTest::CheckArraysInheritTupleDims(onStructure); + } + + SECTION("M3CSurfaceMeshing") + { + DataStructure offStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + DataStructure onStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + const Result<> offResult = RunM3CRaw(offStructure, BoundingBoxSkinMode::k_Off); + SIMPLNX_RESULT_REQUIRE_VALID(offResult); + const Result<> onResult = RunM3CRaw(onStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + SIMPLNX_RESULT_REQUIRE_VALID(onResult); + + REQUIRE_NOTHROW(offStructure.getDataRefAs(k_M3CTriangleGeomPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto& offGeom = offStructure.getDataRefAs(k_M3CTriangleGeomPath); + const auto& onGeom = onStructure.getDataRefAs(k_M3CTriangleGeomPath); + REQUIRE(onGeom.getNumberOfFaces() == offGeom.getNumberOfFaces()); + REQUIRE(onGeom.getNumberOfVertices() == offGeom.getNumberOfVertices()); + UnitTest::CompareDataArrays(*offGeom.getFaces(), *onGeom.getFaces()); + UnitTest::CompareDataArrays(*offGeom.getVertices(), *onGeom.getVertices()); + + const DataPath faceLabelsPath = k_M3CTriangleGeomPath.createChildPath("Face Data").createChildPath("FaceLabels"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(faceLabelsPath)); + 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. + const DataPath nodeTypesPath = k_M3CTriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(nodeTypesPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(nodeTypesPath)); + UnitTest::CompareDataArrays(offStructure.getDataRefAs(nodeTypesPath), onStructure.getDataRefAs(nodeTypesPath)); + + UnitTest::CheckArraysInheritTupleDims(offStructure); + UnitTest::CheckArraysInheritTupleDims(onStructure); + } +} + +TEST_CASE("SimplnxCore::Bounding Box Skin warns when nothing is pruned", "[SimplnxCore][QuickSurfaceMeshFilter][SurfaceNetsFilter][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // A fully-indexed volume has no Feature Id 0, so the option suppresses zero faces. That is the + // most common dataset shape in practice, and byte-identical output with no feedback is not + // acceptable -- the user cannot tell the option had no effect. This is the companion to the + // all-background case (which suppresses every face); the two warnings must never fire together. + SECTION("QuickSurfaceMesh") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + const Result<> executeResult = RunQuickSurfaceMeshRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_NoFacesPrunedWarning); + } + + SECTION("SurfaceNets") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + const Result<> executeResult = RunSurfaceNetsRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_NoFacesPrunedWarning); + } + + SECTION("M3CSurfaceMeshing") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + const Result<> executeResult = RunM3CRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_NoFacesPrunedWarning); + } +} + +TEST_CASE("SimplnxCore::Bounding Box Skin warns when background is fully enclosed", "[SimplnxCore][QuickSurfaceMeshFilter][SurfaceNetsFilter][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // CreateEnclosedPorosity() is full of Feature Id 0 internally, but every wall voxel is Feature 1 -- + // the interior pocket never touches a bounding-box wall. k_NoFacesPrunedWarning's text says only + // that no WALL face is background-backed, not that the volume has no background; this is the input + // that would falsify the old (incorrect) wording, which claimed the latter. The option must still + // prune zero faces and warn, and its output must be byte-identical to leaving the option off -- + // exactly the same no-op contract as the fully-indexed case above, but for a different reason. + SECTION("QuickSurfaceMesh") + { + DataStructure offStructure = SurfaceMeshingTest::CreateEnclosedPorosity(); + DataStructure onStructure = SurfaceMeshingTest::CreateEnclosedPorosity(); + const Result<> offResult = RunQuickSurfaceMeshRaw(offStructure, BoundingBoxSkinMode::k_Off); + SIMPLNX_RESULT_REQUIRE_VALID(offResult); + const Result<> onResult = RunQuickSurfaceMeshRaw(onStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + SIMPLNX_RESULT_REQUIRE_VALID(onResult); + REQUIRE(onResult.warnings().size() == 1); + REQUIRE(onResult.warnings()[0].code == MeshingUtilities::k_NoFacesPrunedWarning); + + REQUIRE_NOTHROW(offStructure.getDataRefAs(k_TriangleGeomPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(k_TriangleGeomPath)); + const auto& offGeom = offStructure.getDataRefAs(k_TriangleGeomPath); + const auto& onGeom = onStructure.getDataRefAs(k_TriangleGeomPath); + REQUIRE(onGeom.getNumberOfFaces() == offGeom.getNumberOfFaces()); + REQUIRE(onGeom.getNumberOfVertices() == offGeom.getNumberOfVertices()); + UnitTest::CompareDataArrays(*offGeom.getFaces(), *onGeom.getFaces()); + UnitTest::CompareDataArrays(*offGeom.getVertices(), *onGeom.getVertices()); + + const DataPath faceLabelsPath = k_TriangleGeomPath.createChildPath("Face Data").createChildPath("FaceLabels"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(faceLabelsPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(faceLabelsPath)); + UnitTest::CompareDataArrays(offStructure.getDataRefAs(faceLabelsPath), onStructure.getDataRefAs(faceLabelsPath)); + + UnitTest::CheckArraysInheritTupleDims(offStructure); + UnitTest::CheckArraysInheritTupleDims(onStructure); + } + + SECTION("SurfaceNets") + { + DataStructure offStructure = SurfaceMeshingTest::CreateEnclosedPorosity(); + DataStructure onStructure = SurfaceMeshingTest::CreateEnclosedPorosity(); + const Result<> offResult = RunSurfaceNetsRaw(offStructure, BoundingBoxSkinMode::k_Off); + SIMPLNX_RESULT_REQUIRE_VALID(offResult); + const Result<> onResult = RunSurfaceNetsRaw(onStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + SIMPLNX_RESULT_REQUIRE_VALID(onResult); + REQUIRE(onResult.warnings().size() == 1); + REQUIRE(onResult.warnings()[0].code == MeshingUtilities::k_NoFacesPrunedWarning); + + REQUIRE_NOTHROW(offStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath)); + const auto& offGeom = offStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath); + const auto& onGeom = onStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath); + REQUIRE(onGeom.getNumberOfFaces() == offGeom.getNumberOfFaces()); + REQUIRE(onGeom.getNumberOfVertices() == offGeom.getNumberOfVertices()); + UnitTest::CompareDataArrays(*offGeom.getFaces(), *onGeom.getFaces()); + UnitTest::CompareDataArrays(*offGeom.getVertices(), *onGeom.getVertices()); + + const DataPath faceLabelsPath = k_SurfaceNetsTriangleGeomPath.createChildPath("Face Data").createChildPath("FaceLabels"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(faceLabelsPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(faceLabelsPath)); + UnitTest::CompareDataArrays(offStructure.getDataRefAs(faceLabelsPath), onStructure.getDataRefAs(faceLabelsPath)); + + UnitTest::CheckArraysInheritTupleDims(offStructure); + UnitTest::CheckArraysInheritTupleDims(onStructure); + } + + SECTION("M3CSurfaceMeshing") + { + DataStructure offStructure = SurfaceMeshingTest::CreateEnclosedPorosity(); + DataStructure onStructure = SurfaceMeshingTest::CreateEnclosedPorosity(); + const Result<> offResult = RunM3CRaw(offStructure, BoundingBoxSkinMode::k_Off); + SIMPLNX_RESULT_REQUIRE_VALID(offResult); + const Result<> onResult = RunM3CRaw(onStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + SIMPLNX_RESULT_REQUIRE_VALID(onResult); + REQUIRE(onResult.warnings().size() == 1); + REQUIRE(onResult.warnings()[0].code == MeshingUtilities::k_NoFacesPrunedWarning); + + REQUIRE_NOTHROW(offStructure.getDataRefAs(k_M3CTriangleGeomPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto& offGeom = offStructure.getDataRefAs(k_M3CTriangleGeomPath); + const auto& onGeom = onStructure.getDataRefAs(k_M3CTriangleGeomPath); + REQUIRE(onGeom.getNumberOfFaces() == offGeom.getNumberOfFaces()); + REQUIRE(onGeom.getNumberOfVertices() == offGeom.getNumberOfVertices()); + UnitTest::CompareDataArrays(*offGeom.getFaces(), *onGeom.getFaces()); + UnitTest::CompareDataArrays(*offGeom.getVertices(), *onGeom.getVertices()); + + const DataPath faceLabelsPath = k_M3CTriangleGeomPath.createChildPath("Face Data").createChildPath("FaceLabels"); + REQUIRE_NOTHROW(offStructure.getDataRefAs(faceLabelsPath)); + REQUIRE_NOTHROW(onStructure.getDataRefAs(faceLabelsPath)); + UnitTest::CompareDataArrays(offStructure.getDataRefAs(faceLabelsPath), onStructure.getDataRefAs(faceLabelsPath)); + + UnitTest::CheckArraysInheritTupleDims(offStructure); + UnitTest::CheckArraysInheritTupleDims(onStructure); + } +} + +TEST_CASE("SimplnxCore::Bounding Box Skin warns on an all-background volume", "[SimplnxCore][QuickSurfaceMeshFilter]") +{ + UnitTest::LoadPlugins(); + + SECTION("Repair Triangle Winding off") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateAllBackground(); + const Result<> executeResult = RunQuickSurfaceMeshRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + // Success with a warning, not an error: the data is legal, just entirely background. + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_EmptyMeshAfterSkinRemovalWarning); + + // The geometry still exists, just empty. + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TriangleGeomPath)); + const auto& triangleGeom = dataStructure.getDataRefAs(k_TriangleGeomPath); + REQUIRE(triangleGeom.getNumberOfFaces() == 0); + REQUIRE(triangleGeom.getNumberOfVertices() == 0); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + // Repair Triangle Winding defaults to true on all three meshers, so this is the configuration + // shipped users actually get. It exercises the windingResult.valid() guard (QuickSurfaceMesh.cpp) + // and proves findElementNeighbors()/RepairTriangleWinding() are safe to run on a 0-face/0-vertex + // geometry: the contract (success, one warning, zero faces, zero vertices) must still hold. + SECTION("Repair Triangle Winding on (shipped default)") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateAllBackground(); + const Result<> executeResult = RunQuickSurfaceMeshRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly, /*repairWinding*/ true); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_EmptyMeshAfterSkinRemovalWarning); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_TriangleGeomPath)); + const auto& triangleGeom = dataStructure.getDataRefAs(k_TriangleGeomPath); + REQUIRE(triangleGeom.getNumberOfFaces() == 0); + REQUIRE(triangleGeom.getNumberOfVertices() == 0); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} + +TEST_CASE("SimplnxCore::Bounding Box Skin warns on an all-background volume (SurfaceNets)", "[SimplnxCore][SurfaceNetsFilter]") +{ + UnitTest::LoadPlugins(); + + SECTION("Repair Triangle Winding off") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateAllBackground(); + const Result<> executeResult = RunSurfaceNetsRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_EmptyMeshAfterSkinRemovalWarning); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath)); + const auto& triangleGeom = dataStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath); + REQUIRE(triangleGeom.getNumberOfFaces() == 0); + REQUIRE(triangleGeom.getNumberOfVertices() == 0); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + // See the QuickSurfaceMesh test case above for why this configuration matters: Repair Triangle + // Winding defaults to true, and this proves the warning contract holds there too (guard at + // SurfaceNets.cpp). + SECTION("Repair Triangle Winding on (shipped default)") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateAllBackground(); + const Result<> executeResult = RunSurfaceNetsRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly, /*repairWinding*/ true); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_EmptyMeshAfterSkinRemovalWarning); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath)); + const auto& triangleGeom = dataStructure.getDataRefAs(k_SurfaceNetsTriangleGeomPath); + REQUIRE(triangleGeom.getNumberOfFaces() == 0); + REQUIRE(triangleGeom.getNumberOfVertices() == 0); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} + +TEST_CASE("SimplnxCore::Bounding Box Skin warns on an all-background volume (M3CSurfaceMeshing)", "[SimplnxCore][M3CSurfaceMeshingFilter]") +{ + 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); + + SECTION("Repair Triangle Winding off") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateAllBackground(); + const Result<> executeResult = RunM3CRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_EmptyMeshAfterSkinRemovalWarning); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto& triangleGeom = dataStructure.getDataRefAs(k_M3CTriangleGeomPath); + REQUIRE(triangleGeom.getNumberOfFaces() == 0); + REQUIRE(triangleGeom.getNumberOfVertices() == preExistingOrphanCount); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + // See the QuickSurfaceMesh test case above for why this configuration matters. M3C does not + // route through the shared windingResult.valid() guard the same way, but it still must honor + // the same warning contract when Repair Triangle Winding is left at its shipped default of true. + SECTION("Repair Triangle Winding on (shipped default)") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateAllBackground(); + const Result<> executeResult = RunM3CRaw(dataStructure, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly, /*repairWinding*/ true); + + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + REQUIRE(executeResult.warnings().size() == 1); + REQUIRE(executeResult.warnings()[0].code == MeshingUtilities::k_EmptyMeshAfterSkinRemovalWarning); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_M3CTriangleGeomPath)); + const auto& triangleGeom = dataStructure.getDataRefAs(k_M3CTriangleGeomPath); + REQUIRE(triangleGeom.getNumberOfFaces() == 0); + REQUIRE(triangleGeom.getNumberOfVertices() == preExistingOrphanCount); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} + +TEST_CASE("SimplnxCore::Bounding Box Skin mesher inputs reject Feature Id sentinel collisions", "[SimplnxCore][QuickSurfaceMeshFilter][SurfaceNetsFilter][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // These validations are unconditional (not gated on the Bounding Box Skin mode), so each SECTION + // below leaves the option off to prove that. This is a mitigation for the sentinel-collision + // design described in simplnx#1705, not a fix for it. + SECTION("QuickSurfaceMesh rejects a negative Feature Id") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath)); + auto& featureIdsRef = dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath).getDataStoreRef(); + featureIdsRef[0] = -7; + + const Result<> executeResult = RunQuickSurfaceMeshRaw(dataStructure, BoundingBoxSkinMode::k_Off); + REQUIRE(executeResult.invalid()); + REQUIRE(executeResult.errors().size() == 1); + REQUIRE(executeResult.errors()[0].code == MeshingUtilities::k_InvalidFeatureIdError); + INFO(executeResult.errors()[0].message); + REQUIRE(executeResult.errors()[0].message.find("-7") != std::string::npos); + REQUIRE(executeResult.errors()[0].message.find(SurfaceMeshingTest::k_FeatureIdsPath.toString()) != std::string::npos); + } + + SECTION("SurfaceNets rejects a negative Feature Id") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath)); + auto& featureIdsRef = dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath).getDataStoreRef(); + featureIdsRef[0] = -7; + + const Result<> executeResult = RunSurfaceNetsRaw(dataStructure, BoundingBoxSkinMode::k_Off); + REQUIRE(executeResult.invalid()); + REQUIRE(executeResult.errors().size() == 1); + REQUIRE(executeResult.errors()[0].code == MeshingUtilities::k_InvalidFeatureIdError); + INFO(executeResult.errors()[0].message); + REQUIRE(executeResult.errors()[0].message.find("-7") != std::string::npos); + REQUIRE(executeResult.errors()[0].message.find(SurfaceMeshingTest::k_FeatureIdsPath.toString()) != std::string::npos); + } + + SECTION("SurfaceNets rejects a Feature Id of INT32_MAX") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath)); + auto& featureIdsRef = dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath).getDataStoreRef(); + featureIdsRef[0] = std::numeric_limits::max(); + + const Result<> executeResult = RunSurfaceNetsRaw(dataStructure, BoundingBoxSkinMode::k_Off); + REQUIRE(executeResult.invalid()); + REQUIRE(executeResult.errors().size() == 1); + REQUIRE(executeResult.errors()[0].code == MeshingUtilities::k_InvalidFeatureIdError); + INFO(executeResult.errors()[0].message); + REQUIRE(executeResult.errors()[0].message.find(std::to_string(std::numeric_limits::max())) != std::string::npos); + REQUIRE(executeResult.errors()[0].message.find(SurfaceMeshingTest::k_FeatureIdsPath.toString()) != std::string::npos); + } + + SECTION("M3CSurfaceMeshing rejects a negative Feature Id") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath)); + auto& featureIdsRef = dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath).getDataStoreRef(); + featureIdsRef[0] = -7; + + const Result<> executeResult = RunM3CRaw(dataStructure, BoundingBoxSkinMode::k_Off); + REQUIRE(executeResult.invalid()); + REQUIRE(executeResult.errors().size() == 1); + REQUIRE(executeResult.errors()[0].code == MeshingUtilities::k_InvalidFeatureIdError); + INFO(executeResult.errors()[0].message); + REQUIRE(executeResult.errors()[0].message.find("-7") != std::string::npos); + REQUIRE(executeResult.errors()[0].message.find(SurfaceMeshingTest::k_FeatureIdsPath.toString()) != std::string::npos); + } + + SECTION("M3CSurfaceMeshing rejects a Feature Id of INT32_MAX") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath)); + auto& featureIdsRef = dataStructure.getDataRefAs(SurfaceMeshingTest::k_FeatureIdsPath).getDataStoreRef(); + featureIdsRef[0] = std::numeric_limits::max(); + + const Result<> executeResult = RunM3CRaw(dataStructure, BoundingBoxSkinMode::k_Off); + REQUIRE(executeResult.invalid()); + REQUIRE(executeResult.errors().size() == 1); + REQUIRE(executeResult.errors()[0].code == MeshingUtilities::k_InvalidFeatureIdError); + INFO(executeResult.errors()[0].message); + REQUIRE(executeResult.errors()[0].message.find(std::to_string(std::numeric_limits::max())) != std::string::npos); + REQUIRE(executeResult.errors()[0].message.find(SurfaceMeshingTest::k_FeatureIdsPath.toString()) != std::string::npos); + } +} + +TEST_CASE("SimplnxCore::Bounding Box Skin: all three meshers agree on Face Label pairs", "[SimplnxCore][QuickSurfaceMeshFilter][SurfaceNetsFilter][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // The three meshers legitimately produce different geometry -- different vertex counts, + // triangle counts, and tessellations -- so those are NOT compared here. What must agree is the + // *semantic* result: which Feature-pair interfaces exist. CollectLabelPairs gives exactly that + // set, independent of mesh topology, which is why it (rather than raw array/geometry equality) + // is the right comparison across three independently-implemented meshers. CreateCylinderInBox(true) + // (via flushWithBottom=true below) is used so both the {-1, 1} cap (cylinder flush with the box + // floor) and the {0, 1} interior interface (cylinder side away from the floor) are present to compare, + // alongside the {-1, 0} background skin. + // + // Watertightness is only asserted for the "on" mode. With the mode off, a per-voxel-face + // boundary quad and the vertical wall quad next to it can share an edge with a THIRD quad from + // the neighboring (differently-labeled) voxel's own boundary quad -- a pre-existing T-junction + // at any point an internal boundary meets the box wall, independent of this option. "On" mode + // drops that neighboring background-backed quad, leaving exactly 2 quads at the edge, which is + // precisely the closure this option exists to provide (see the per-mesher "Option on removes + // only the background skin and keeps the cylinder closed" cases above). + // Mutual agreement alone would pass on an identical regression shared by all three meshers (e.g. + // all three suddenly dropping the {0, 1} interface), so each mode is also checked against the + // absolute set of label pairs it must contain/omit, independent of what the other two meshers did. + auto checkAgreement = [](ChoicesParameter::ValueType boundingBoxSkinMode) { + SurfaceMeshingTest::MeshResult qsmResult = RunQuickSurfaceMesh(true, boundingBoxSkinMode); + SurfaceMeshingTest::MeshResult snResult = RunSurfaceNets(true, boundingBoxSkinMode); + SurfaceMeshingTest::MeshResult m3cResult = RunM3C(true, boundingBoxSkinMode); + + const auto qsmLabelPairs = SurfaceMeshingTest::CollectLabelPairs(qsmResult); + const auto snLabelPairs = SurfaceMeshingTest::CollectLabelPairs(snResult); + const auto m3cLabelPairs = SurfaceMeshingTest::CollectLabelPairs(m3cResult); + + CHECK(qsmLabelPairs == snLabelPairs); + CHECK(qsmLabelPairs == m3cLabelPairs); + + // The {0, 1} interior interface (cylinder side away from the floor) is untouched by the option + // in either mode, so it must always be present. + CHECK(qsmLabelPairs.count({0, 1}) == 1); + // The cylinder's bottom cap ({-1, 1}) is the closure this option exists to preserve; it must + // survive regardless of mode. + CHECK(qsmLabelPairs.count({-1, 1}) == 1); + if(boundingBoxSkinMode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) + { + // The background-backed box skin must be gone. + CHECK(qsmLabelPairs.count({-1, 0}) == 0); + } + else + { + // With the option off, the artificial background skin is left in place. + CHECK(qsmLabelPairs.count({-1, 0}) == 1); + } + + REQUIRE_NOTHROW(qsmResult.Structure.getDataRefAs(qsmResult.TriangleGeomPath)); + REQUIRE_NOTHROW(snResult.Structure.getDataRefAs(snResult.TriangleGeomPath)); + REQUIRE_NOTHROW(m3cResult.Structure.getDataRefAs(m3cResult.TriangleGeomPath)); + if(boundingBoxSkinMode == BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) + { + CHECK(SurfaceMeshingTest::IsWatertight(qsmResult.Structure.getDataRefAs(qsmResult.TriangleGeomPath))); + CHECK(SurfaceMeshingTest::IsWatertight(snResult.Structure.getDataRefAs(snResult.TriangleGeomPath))); + CHECK(SurfaceMeshingTest::IsWatertight(m3cResult.Structure.getDataRefAs(m3cResult.TriangleGeomPath))); + } + + UnitTest::CheckArraysInheritTupleDims(qsmResult.Structure); + UnitTest::CheckArraysInheritTupleDims(snResult.Structure); + UnitTest::CheckArraysInheritTupleDims(m3cResult.Structure); + }; + + SECTION("Bounding Box Skin mode off") + { + checkAgreement(BoundingBoxSkinMode::k_Off); + } + + SECTION("Bounding Box Skin mode on (Background-Backed Walls Only)") + { + checkAgreement(BoundingBoxSkinMode::k_BackgroundBackedWallsOnly); + } +} + +namespace +{ +// Builds a 12x12x12 ImageGeom where a single Feature (Id 1) occupies a corner block touching the +// x==0, y==0, AND z==0 walls simultaneously, so three suppressed wall planes meet at a right-angle +// corner. CreateCylinderInBox only ever touches one wall (the floor) at a time, so this is built +// directly rather than forcing that helper into a shape it was not designed for. +DataStructure CreateCornerFeatureInBox() +{ + DataStructure dataStructure; + constexpr usize k_CornerExtent = 3; + + auto* imageGeomPtr = ImageGeom::Create(dataStructure, "ImageGeom"); + const std::vector dims = {SurfaceMeshingTest::k_BoxDim, SurfaceMeshingTest::k_BoxDim, SurfaceMeshingTest::k_BoxDim}; + imageGeomPtr->setDimensions(dims); + imageGeomPtr->setSpacing({1.0F, 1.0F, 1.0F}); + imageGeomPtr->setOrigin({0.0F, 0.0F, 0.0F}); + + auto* cellAMPtr = AttributeMatrix::Create(dataStructure, "CellData", {dims[2], dims[1], dims[0]}, imageGeomPtr->getId()); + imageGeomPtr->setCellData(*cellAMPtr); + auto* featureIdsPtr = Int32Array::CreateWithStore(dataStructure, "FeatureIds", {dims[2], dims[1], dims[0]}, {1}, cellAMPtr->getId()); + featureIdsPtr->fill(0); + + auto& featureIdsRef = featureIdsPtr->getDataStoreRef(); + for(usize z = 0; z < k_CornerExtent; z++) + { + for(usize y = 0; y < k_CornerExtent; y++) + { + for(usize x = 0; x < k_CornerExtent; x++) + { + featureIdsRef[(z * SurfaceMeshingTest::k_BoxDim * SurfaceMeshingTest::k_BoxDim) + (y * SurfaceMeshingTest::k_BoxDim) + x] = 1; + } + } + } + + return dataStructure; +} +} // namespace + +TEST_CASE("SimplnxCore::Bounding Box Skin: corner Feature stays watertight", "[SimplnxCore][QuickSurfaceMeshFilter][SurfaceNetsFilter][M3CSurfaceMeshingFilter]") +{ + UnitTest::LoadPlugins(); + + // The feature's whole justification is that a Feature flush with the box stays closed. Every + // other test in this file only exercises a single flush face (CreateCylinderInBox's floor). A + // per-face suppression rule could still fail where two or three suppressed wall planes meet at a + // right angle -- which only happens at a corner -- so this is the test that would catch it. + SECTION("QuickSurfaceMesh") + { + SurfaceMeshingTest::MeshResult meshResult = + SurfaceMeshingTest::RunMesher(CreateCornerFeatureInBox(), k_TriangleGeomPath, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly, + [](Arguments& args) { args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); }); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + CHECK(labelPairs.count({-1, 1}) == 1); + CHECK(labelPairs.count({0, 1}) == 1); + CHECK(labelPairs.count({-1, 0}) == 0); + + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + const auto counts = SurfaceMeshingTest::CountEdgeUses(triangleGeom); + INFO("QuickSurfaceMesh corner-Feature edge use counts -- Total: " << counts.TotalEdges << " UsedOnce: " << counts.EdgesUsedOnce << " UsedTwice: " << counts.EdgesUsedTwice + << " UsedMoreThanTwice: " << counts.EdgesUsedMoreThanTwice); + REQUIRE(SurfaceMeshingTest::IsWatertight(triangleGeom)); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("SurfaceNets") + { + SurfaceMeshingTest::MeshResult meshResult = + SurfaceMeshingTest::RunMesher(CreateCornerFeatureInBox(), k_SurfaceNetsTriangleGeomPath, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly, [](Arguments& args) { + args.insertOrAssign(SurfaceNetsFilter::k_ApplySmoothing_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_SmoothingIterations_Key, std::make_any(20)); + args.insertOrAssign(SurfaceNetsFilter::k_MaxDistanceFromVoxelCenter_Key, std::make_any(1.0F)); + args.insertOrAssign(SurfaceNetsFilter::k_RelaxationFactor_Key, std::make_any(0.5F)); + }); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + CHECK(labelPairs.count({-1, 1}) == 1); + CHECK(labelPairs.count({0, 1}) == 1); + CHECK(labelPairs.count({-1, 0}) == 0); + + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + const auto counts = SurfaceMeshingTest::CountEdgeUses(triangleGeom); + INFO("SurfaceNets corner-Feature edge use counts -- Total: " << counts.TotalEdges << " UsedOnce: " << counts.EdgesUsedOnce << " UsedTwice: " << counts.EdgesUsedTwice + << " UsedMoreThanTwice: " << counts.EdgesUsedMoreThanTwice); + REQUIRE(SurfaceMeshingTest::IsWatertight(triangleGeom)); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } + + SECTION("M3CSurfaceMeshing") + { + SurfaceMeshingTest::MeshResult meshResult = + SurfaceMeshingTest::RunMesher(CreateCornerFeatureInBox(), k_M3CTriangleGeomPath, BoundingBoxSkinMode::k_BackgroundBackedWallsOnly, [](Arguments&) {}); + const auto labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + CHECK(labelPairs.count({-1, 1}) == 1); + CHECK(labelPairs.count({0, 1}) == 1); + CHECK(labelPairs.count({-1, 0}) == 0); + + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(meshResult.TriangleGeomPath); + const auto counts = SurfaceMeshingTest::CountEdgeUses(triangleGeom); + INFO("M3CSurfaceMeshing corner-Feature edge use counts -- Total: " << counts.TotalEdges << " UsedOnce: " << counts.EdgesUsedOnce << " UsedTwice: " << counts.EdgesUsedTwice + << " UsedMoreThanTwice: " << counts.EdgesUsedMoreThanTwice); + REQUIRE(SurfaceMeshingTest::IsWatertight(triangleGeom)); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); + } +} diff --git a/src/Plugins/SimplnxCore/test/SurfaceMeshingTestUtils.hpp b/src/Plugins/SimplnxCore/test/SurfaceMeshingTestUtils.hpp new file mode 100644 index 0000000000..4c5666c957 --- /dev/null +++ b/src/Plugins/SimplnxCore/test/SurfaceMeshingTestUtils.hpp @@ -0,0 +1,373 @@ +#pragma once + +#include "SimplnxCore/Filters/M3CSurfaceMeshingFilter.hpp" + +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/DataStructure.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Filter/Arguments.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/MultiArraySelectionParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" + +#include +#include +#include +#include + +namespace SurfaceMeshingTest +{ +using namespace nx::core; + +inline constexpr usize k_BoxDim = 12; +inline constexpr usize k_CylinderRadius = 3; +inline constexpr usize k_CylinderCenter = 6; +inline constexpr usize k_CylinderTopZ = 8; + +// The ImageGeom/CellData/FeatureIds layout every builder in this file produces. +inline const DataPath k_ImageGeomPath({"ImageGeom"}); +inline const DataPath k_FeatureIdsPath({"ImageGeom", "CellData", "FeatureIds"}); + +/** + * @brief Builds a 12x12x12 ImageGeom holding a Z-axis cylinder (Feature Id 1) in a + * background of Feature Id 0. The cylinder is always inset from the four X/Y walls and + * from the top; flushWithBottom controls whether it touches the z == 0 wall. + * @param flushWithBottom When true the cylinder spans z = 0..8, otherwise z = 2..8. + * @param spacing ImageGeom voxel spacing. Defaults to {1,1,1} (isotropic) so existing callers + * are unaffected; pass an anisotropic value to exercise axis-specific spacing bugs. + * @return A DataStructure containing ImageGeom/CellData/FeatureIds. + */ +inline DataStructure CreateCylinderInBox(bool flushWithBottom, FloatVec3 spacing = {1.0F, 1.0F, 1.0F}) +{ + DataStructure dataStructure; + + auto* imageGeomPtr = ImageGeom::Create(dataStructure, "ImageGeom"); + const std::vector dims = {k_BoxDim, k_BoxDim, k_BoxDim}; + imageGeomPtr->setDimensions(dims); + imageGeomPtr->setSpacing(spacing); + imageGeomPtr->setOrigin({0.0F, 0.0F, 0.0F}); + + auto* cellAMPtr = AttributeMatrix::Create(dataStructure, "CellData", {dims[2], dims[1], dims[0]}, imageGeomPtr->getId()); + imageGeomPtr->setCellData(*cellAMPtr); + auto* featureIdsPtr = Int32Array::CreateWithStore(dataStructure, "FeatureIds", {dims[2], dims[1], dims[0]}, {1}, cellAMPtr->getId()); + featureIdsPtr->fill(0); + + const usize minZ = flushWithBottom ? 0 : 2; + auto& featureIdsRef = featureIdsPtr->getDataStoreRef(); + + for(usize z = minZ; z <= k_CylinderTopZ; z++) + { + for(usize y = 0; y < k_BoxDim; y++) + { + for(usize x = 0; x < k_BoxDim; x++) + { + const float64 dx = static_cast(x) + 0.5 - static_cast(k_CylinderCenter); + const float64 dy = static_cast(y) + 0.5 - static_cast(k_CylinderCenter); + if((dx * dx) + (dy * dy) <= static_cast(k_CylinderRadius * k_CylinderRadius)) + { + featureIdsRef[(z * k_BoxDim * k_BoxDim) + (y * k_BoxDim) + x] = 1; + } + } + } + } + + return dataStructure; +} + +/** + * @brief Builds a 12x12x12 ImageGeom with NO background: eight octant Features numbered + * 1..8 fill the volume. Omitting the bounding box skin must be a no-op on this data. + */ +inline DataStructure CreateFullyIndexedPolycrystal() +{ + DataStructure dataStructure; + + auto* imageGeomPtr = ImageGeom::Create(dataStructure, "ImageGeom"); + const std::vector dims = {k_BoxDim, k_BoxDim, k_BoxDim}; + imageGeomPtr->setDimensions(dims); + imageGeomPtr->setSpacing({1.0F, 1.0F, 1.0F}); + imageGeomPtr->setOrigin({0.0F, 0.0F, 0.0F}); + + auto* cellAMPtr = AttributeMatrix::Create(dataStructure, "CellData", {dims[2], dims[1], dims[0]}, imageGeomPtr->getId()); + imageGeomPtr->setCellData(*cellAMPtr); + auto* featureIdsPtr = Int32Array::CreateWithStore(dataStructure, "FeatureIds", {dims[2], dims[1], dims[0]}, {1}, cellAMPtr->getId()); + + auto& featureIdsRef = featureIdsPtr->getDataStoreRef(); + const usize half = k_BoxDim / 2; + for(usize z = 0; z < k_BoxDim; z++) + { + for(usize y = 0; y < k_BoxDim; y++) + { + for(usize x = 0; x < k_BoxDim; x++) + { + const int32 octant = static_cast((x >= half ? 1 : 0) + (y >= half ? 2 : 0) + (z >= half ? 4 : 0)); + featureIdsRef[(z * k_BoxDim * k_BoxDim) + (y * k_BoxDim) + x] = octant + 1; + } + } + } + + return dataStructure; +} + +/** + * @brief Builds a 12x12x12 ImageGeom in which every voxel is background (Feature Id 0). + * Omitting the bounding box skin must yield an empty mesh plus a warning. + */ +inline DataStructure CreateAllBackground() +{ + DataStructure dataStructure; + + auto* imageGeomPtr = ImageGeom::Create(dataStructure, "ImageGeom"); + const std::vector dims = {k_BoxDim, k_BoxDim, k_BoxDim}; + imageGeomPtr->setDimensions(dims); + imageGeomPtr->setSpacing({1.0F, 1.0F, 1.0F}); + imageGeomPtr->setOrigin({0.0F, 0.0F, 0.0F}); + + auto* cellAMPtr = AttributeMatrix::Create(dataStructure, "CellData", {dims[2], dims[1], dims[0]}, imageGeomPtr->getId()); + imageGeomPtr->setCellData(*cellAMPtr); + auto* featureIdsPtr = Int32Array::CreateWithStore(dataStructure, "FeatureIds", {dims[2], dims[1], dims[0]}, {1}, cellAMPtr->getId()); + featureIdsPtr->fill(0); + + return dataStructure; +} + +/** + * @brief Builds a 12x12x12 ImageGeom where Feature Id 1 fills the entire volume EXCEPT for a small + * interior pocket of Feature Id 0 (background), fully enclosed away from all six bounding-box walls + * (interior porosity). Every wall voxel is Feature 1, so no bounding-box wall face is + * background-backed: omitting the bounding box skin must prune zero faces and warn (code -56342), + * even though the volume is full of Feature Id 0 internally. This is the counterexample that + * distinguishes "no wall face is background-backed" from "the volume has no background voxels" -- + * CreateFullyIndexedPolycrystal() covers the latter, this covers the former without the latter. + */ +inline DataStructure CreateEnclosedPorosity() +{ + DataStructure dataStructure; + + auto* imageGeomPtr = ImageGeom::Create(dataStructure, "ImageGeom"); + const std::vector dims = {k_BoxDim, k_BoxDim, k_BoxDim}; + imageGeomPtr->setDimensions(dims); + imageGeomPtr->setSpacing({1.0F, 1.0F, 1.0F}); + imageGeomPtr->setOrigin({0.0F, 0.0F, 0.0F}); + + auto* cellAMPtr = AttributeMatrix::Create(dataStructure, "CellData", {dims[2], dims[1], dims[0]}, imageGeomPtr->getId()); + imageGeomPtr->setCellData(*cellAMPtr); + auto* featureIdsPtr = Int32Array::CreateWithStore(dataStructure, "FeatureIds", {dims[2], dims[1], dims[0]}, {1}, cellAMPtr->getId()); + featureIdsPtr->fill(1); + + // A 4x4x4 pocket of background centered in the box, at least two voxels inset from every wall on + // every axis so it can never be mistaken for a wall-adjacent (and therefore prunable) voxel. + constexpr usize k_PocketMin = 4; + constexpr usize k_PocketMax = 7; + auto& featureIdsRef = featureIdsPtr->getDataStoreRef(); + for(usize z = k_PocketMin; z <= k_PocketMax; z++) + { + for(usize y = k_PocketMin; y <= k_PocketMax; y++) + { + for(usize x = k_PocketMin; x <= k_PocketMax; x++) + { + featureIdsRef[(z * k_BoxDim * k_BoxDim) + (y * k_BoxDim) + x] = 0; + } + } + } + + return dataStructure; +} + +struct EdgeUseCounts +{ + usize TotalEdges = 0; + usize EdgesUsedOnce = 0; + usize EdgesUsedTwice = 0; + usize EdgesUsedMoreThanTwice = 0; +}; + +/** + * @brief Counts how many triangles use each undirected edge of the mesh. + */ +inline EdgeUseCounts CountEdgeUses(const TriangleGeom& triangleGeom) +{ + using VertexPair = std::pair; + std::map edgeUses; + + const auto& facesRef = triangleGeom.getFaces()->getDataStoreRef(); + const usize numFaces = triangleGeom.getNumberOfFaces(); + + for(usize faceIdx = 0; faceIdx < numFaces; faceIdx++) + { + const std::array vertIds = {static_cast(facesRef[faceIdx * 3 + 0]), static_cast(facesRef[faceIdx * 3 + 1]), static_cast(facesRef[faceIdx * 3 + 2])}; + for(usize edgeIdx = 0; edgeIdx < 3; edgeIdx++) + { + const usize vertA = vertIds[edgeIdx]; + const usize vertB = vertIds[(edgeIdx + 1) % 3]; + edgeUses[VertexPair{std::min(vertA, vertB), std::max(vertA, vertB)}]++; + } + } + + EdgeUseCounts counts; + counts.TotalEdges = edgeUses.size(); + for(const auto& [edge, useCount] : edgeUses) + { + if(useCount == 1) + { + counts.EdgesUsedOnce++; + } + else if(useCount == 2) + { + counts.EdgesUsedTwice++; + } + else + { + counts.EdgesUsedMoreThanTwice++; + } + } + return counts; +} + +/** + * @brief A mesh is watertight when every edge is shared by exactly two triangles. + */ +inline bool IsWatertight(const TriangleGeom& triangleGeom) +{ + const EdgeUseCounts counts = CountEdgeUses(triangleGeom); + return counts.TotalEdges > 0 && counts.EdgesUsedOnce == 0 && counts.EdgesUsedMoreThanTwice == 0; +} + +/** + * @brief Result of running a surface meshing filter: the DataStructure it produced, plus the + * paths to the created Triangle Geometry and its Face Labels array. + */ +struct MeshResult +{ + DataStructure Structure; + DataPath TriangleGeomPath; + DataPath FaceLabelsPath; +}; + +/** + * @brief Names the Cell Feature Ids parameter key for a surface mesher filter type. QuickSurfaceMesh + * and SurfaceNets both name it k_CellFeatureIdsArrayPath_Key, so that is the default; M3CSurfaceMeshingFilter + * names the same concept k_FeatureIdsArrayPath_Key, so it gets an explicit specialization below. This lets + * RunMesher stay a single shared implementation instead of forking per-mesher. + */ +template +struct FeatureIdsKeyTrait +{ + static constexpr auto k_Key = FilterT::k_CellFeatureIdsArrayPath_Key; +}; + +template <> +struct FeatureIdsKeyTrait +{ + static constexpr auto k_Key = M3CSurfaceMeshingFilter::k_FeatureIdsArrayPath_Key; +}; + +/** + * @brief Builds the Arguments common to every surface mesher (QuickSurfaceMeshFilter, + * SurfaceNetsFilter, M3CSurfaceMeshingFilter), then invokes addExtraArgs to set the + * mesher-specific ones (e.g. Fix Problem Voxels, smoothing options). Shared by RunMesher and + * RunMesherRaw so the argument list is defined exactly once. + * @param triangleGeomPath Path at which to create the Triangle Geometry. + * @param boundingBoxSkinMode Value for the Bounding Box Skin parameter (a BoundingBoxSkinMode + * value; ChoicesParameter::ValueType so bool literals from pre-existing call sites still convert). + * @param addExtraArgs Callback that inserts the mesher-specific arguments into the Arguments object. + * @param repairWinding Value for the Repair Triangle Winding parameter. Defaults to false (matching + * every pre-existing call site); pass true to exercise the shipped default configuration instead. + */ +template +inline Arguments BuildMesherArgs(const DataPath& triangleGeomPath, ChoicesParameter::ValueType boundingBoxSkinMode, ExtraArgsFn addExtraArgs, bool repairWinding = false) +{ + Arguments args; + args.insertOrAssign(FilterT::k_GridGeometryDataPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(FeatureIdsKeyTrait::k_Key, std::make_any(k_FeatureIdsPath)); + args.insertOrAssign(FilterT::k_SelectedDataArrayPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType())); + args.insertOrAssign(FilterT::k_SelectedFeatureDataArrayPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType())); + args.insertOrAssign(FilterT::k_CreatedTriangleGeometryPath_Key, std::make_any(triangleGeomPath)); + args.insertOrAssign(FilterT::k_VertexDataGroupName_Key, std::make_any("Vertex Data")); + args.insertOrAssign(FilterT::k_NodeTypesArrayName_Key, std::make_any("NodeTypes")); + args.insertOrAssign(FilterT::k_FaceDataGroupName_Key, std::make_any("Face Data")); + args.insertOrAssign(FilterT::k_FaceLabelsArrayName_Key, std::make_any("FaceLabels")); + args.insertOrAssign(FilterT::k_RepairTriangleWinding_Key, std::make_any(repairWinding)); + args.insertOrAssign(FilterT::k_BoundingBoxSkinMode_Key, std::make_any(boundingBoxSkinMode)); + + addExtraArgs(args); + return args; +} + +/** + * @brief Runs a surface meshing filter (QuickSurfaceMeshFilter, SurfaceNetsFilter, or + * M3CSurfaceMeshingFilter) on the given DataStructure, setting every argument that is common to + * all three meshers, then invoking addExtraArgs to set the mesher-specific ones (e.g. Fix Problem + * Voxels, smoothing options). Every mesher shares the same parameter key names for the arguments + * set here (feature ids excepted -- see FeatureIdsKeyTrait), so callers only need to supply the + * pieces that differ. Asserts both preflight and execute succeed; for cases that legitimately + * warn or fail (e.g. an all-background input), use RunMesherRaw instead. + * @param dataStructure Input DataStructure, e.g. from CreateCylinderInBox(). + * @param triangleGeomPath Path at which to create the Triangle Geometry. + * @param boundingBoxSkinMode Value for the Bounding Box Skin parameter (a BoundingBoxSkinMode + * value; ChoicesParameter::ValueType so bool literals from pre-existing call sites still convert). + * @param addExtraArgs Callback that inserts the mesher-specific arguments into the Arguments object. + */ +template +inline MeshResult RunMesher(DataStructure&& dataStructure, const DataPath& triangleGeomPath, ChoicesParameter::ValueType boundingBoxSkinMode, ExtraArgsFn addExtraArgs) +{ + MeshResult meshResult; + meshResult.Structure = std::move(dataStructure); + meshResult.TriangleGeomPath = triangleGeomPath; + meshResult.FaceLabelsPath = triangleGeomPath.createChildPath("Face Data").createChildPath("FaceLabels"); + + FilterT filter; + Arguments args = BuildMesherArgs(triangleGeomPath, boundingBoxSkinMode, addExtraArgs); + + auto preflightResult = filter.preflight(meshResult.Structure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(meshResult.Structure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + return meshResult; +} + +/** + * @brief Like RunMesher, but operates on a caller-owned DataStructure (by reference, so the + * caller keeps it around for inspection) and returns the execute Result<> unchecked instead of + * asserting validity. This lets degenerate-input tests (e.g. an all-background volume) assert on + * warnings/errors instead of aborting via SIMPLNX_RESULT_REQUIRE_VALID. Preflight is still + * asserted valid, since a preflight failure here would mean the test itself is set up wrong, not + * the runtime condition under test. + * @param dataStructure Input DataStructure, e.g. from CreateAllBackground(). Modified in place. + * @param triangleGeomPath Path at which to create the Triangle Geometry. + * @param boundingBoxSkinMode Value for the Bounding Box Skin parameter (a BoundingBoxSkinMode + * value; ChoicesParameter::ValueType so bool literals from pre-existing call sites still convert). + * @param addExtraArgs Callback that inserts the mesher-specific arguments into the Arguments object. + * @param repairWinding Value for the Repair Triangle Winding parameter. Defaults to false (matching + * every pre-existing call site); pass true to exercise the shipped default configuration instead. + */ +template +inline Result<> RunMesherRaw(DataStructure& dataStructure, const DataPath& triangleGeomPath, ChoicesParameter::ValueType boundingBoxSkinMode, ExtraArgsFn addExtraArgs, bool repairWinding = false) +{ + FilterT filter; + Arguments args = BuildMesherArgs(triangleGeomPath, boundingBoxSkinMode, addExtraArgs, repairWinding); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + return filter.execute(dataStructure, args).result; +} + +/** + * @brief Collects the distinct {comp0, comp1} Face Labels pairs present in a mesh result. + */ +inline std::set> CollectLabelPairs(const MeshResult& meshResult) +{ + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(meshResult.FaceLabelsPath)); + const auto& faceLabelsRef = meshResult.Structure.getDataRefAs(meshResult.FaceLabelsPath).getDataStoreRef(); + std::set> labelPairs; + for(usize i = 0; i < faceLabelsRef.getNumberOfTuples(); i++) + { + labelPairs.insert({faceLabelsRef[i * 2], faceLabelsRef[i * 2 + 1]}); + } + return labelPairs; +} +} // namespace SurfaceMeshingTest diff --git a/src/Plugins/SimplnxCore/test/SurfaceMeshingTestUtilsTest.cpp b/src/Plugins/SimplnxCore/test/SurfaceMeshingTestUtilsTest.cpp new file mode 100644 index 0000000000..8af9e8912b --- /dev/null +++ b/src/Plugins/SimplnxCore/test/SurfaceMeshingTestUtilsTest.cpp @@ -0,0 +1,160 @@ +#include "SurfaceMeshingTestUtils.hpp" + +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/DataStructure.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" + +#include + +#include + +using namespace nx::core; + +TEST_CASE("SimplnxCore::SurfaceMeshingTestUtils", "[SimplnxCore][SurfaceMeshingTestUtils]") +{ + SECTION("CreateCylinderInBox inset builds the expected volume") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateCylinderInBox(false); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"ImageGeom"}))); + const auto& imageGeom = dataStructure.getDataRefAs(DataPath({"ImageGeom"})); + REQUIRE(imageGeom.getDimensions() == SizeVec3{12, 12, 12}); + + const DataPath featureIdsPath({"ImageGeom", "CellData", "FeatureIds"}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(featureIdsPath)); + const auto& featureIdsRef = dataStructure.getDataRefAs(featureIdsPath).getDataStoreRef(); + + // Every voxel in the z == 0 plane is background when inset. + for(usize y = 0; y < 12; y++) + { + for(usize x = 0; x < 12; x++) + { + REQUIRE(featureIdsRef[(0 * 12 * 12) + (y * 12) + x] == 0); + } + } + // The cylinder axis voxel is Feature 1 at z == 4. + REQUIRE(featureIdsRef[(4 * 12 * 12) + (6 * 12) + 6] == 1); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + SECTION("CreateCylinderInBox flush touches the z == 0 plane") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateCylinderInBox(true); + const DataPath featureIdsPath({"ImageGeom", "CellData", "FeatureIds"}); + const auto& featureIdsRef = dataStructure.getDataRefAs(featureIdsPath).getDataStoreRef(); + + REQUIRE(featureIdsRef[(0 * 12 * 12) + (6 * 12) + 6] == 1); + // Still inset from the x/y walls. + REQUIRE(featureIdsRef[(0 * 12 * 12) + (6 * 12) + 0] == 0); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + SECTION("IsWatertight rejects an open mesh and accepts a closed one") + { + // A single triangle: 3 edges, each used once -> not watertight. + DataStructure dataStructure; + auto* openGeomPtr = TriangleGeom::Create(dataStructure, "OpenTri"); + auto* openVertsPtr = Float32Array::CreateWithStore(dataStructure, "Vertices", {3}, {3}, openGeomPtr->getId()); + openGeomPtr->setVertices(*openVertsPtr); + auto* openFacesPtr = IGeometry::MeshIndexArrayType::CreateWithStore>(dataStructure, "Faces", {1}, {3}, openGeomPtr->getId()); + openGeomPtr->setFaceList(*openFacesPtr); + + auto& openVertsRef = openGeomPtr->getVertices()->getDataStoreRef(); + const std::array openCoords = {0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F}; + for(usize i = 0; i < 9; i++) + { + openVertsRef[i] = openCoords[i]; + } + auto& openFacesRef = openGeomPtr->getFaces()->getDataStoreRef(); + openFacesRef[0] = 0; + openFacesRef[1] = 1; + openFacesRef[2] = 2; + + REQUIRE_FALSE(SurfaceMeshingTest::IsWatertight(*openGeomPtr)); + + // A tetrahedron: 4 triangles, 6 edges, each used twice -> watertight. + auto* closedGeomPtr = TriangleGeom::Create(dataStructure, "ClosedTet"); + auto* closedVertsPtr = Float32Array::CreateWithStore(dataStructure, "Vertices", {4}, {3}, closedGeomPtr->getId()); + closedGeomPtr->setVertices(*closedVertsPtr); + auto* closedFacesPtr = IGeometry::MeshIndexArrayType::CreateWithStore>(dataStructure, "Faces", {4}, {3}, closedGeomPtr->getId()); + closedGeomPtr->setFaceList(*closedFacesPtr); + + auto& closedVertsRef = closedGeomPtr->getVertices()->getDataStoreRef(); + const std::array tetCoords = {0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 1.0F}; + for(usize i = 0; i < 12; i++) + { + closedVertsRef[i] = tetCoords[i]; + } + auto& closedFacesRef = closedGeomPtr->getFaces()->getDataStoreRef(); + const std::array tetFaces = {0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3}; + for(usize i = 0; i < 12; i++) + { + closedFacesRef[i] = tetFaces[i]; + } + + REQUIRE(SurfaceMeshingTest::IsWatertight(*closedGeomPtr)); + + const auto counts = SurfaceMeshingTest::CountEdgeUses(*closedGeomPtr); + REQUIRE(counts.TotalEdges == 6); + REQUIRE(counts.EdgesUsedTwice == 6); + REQUIRE(counts.EdgesUsedOnce == 0); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + SECTION("CreateFullyIndexedPolycrystal has no background and eight distinct Features") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateFullyIndexedPolycrystal(); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"ImageGeom"}))); + const auto& imageGeom = dataStructure.getDataRefAs(DataPath({"ImageGeom"})); + REQUIRE(imageGeom.getDimensions() == SizeVec3{12, 12, 12}); + + const DataPath featureIdsPath({"ImageGeom", "CellData", "FeatureIds"}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(featureIdsPath)); + const auto& featureIdsRef = dataStructure.getDataRefAs(featureIdsPath).getDataStoreRef(); + + std::set distinctFeatures; + for(usize i = 0; i < featureIdsRef.getNumberOfTuples(); i++) + { + distinctFeatures.insert(featureIdsRef[i]); + } + + REQUIRE(distinctFeatures.size() == 8); + REQUIRE(distinctFeatures.count(0) == 0); + REQUIRE(distinctFeatures.count(1) == 1); + REQUIRE(distinctFeatures.count(2) == 1); + REQUIRE(distinctFeatures.count(3) == 1); + REQUIRE(distinctFeatures.count(4) == 1); + REQUIRE(distinctFeatures.count(5) == 1); + REQUIRE(distinctFeatures.count(6) == 1); + REQUIRE(distinctFeatures.count(7) == 1); + REQUIRE(distinctFeatures.count(8) == 1); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + SECTION("CreateAllBackground is entirely Feature Id 0") + { + DataStructure dataStructure = SurfaceMeshingTest::CreateAllBackground(); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"ImageGeom"}))); + const auto& imageGeom = dataStructure.getDataRefAs(DataPath({"ImageGeom"})); + REQUIRE(imageGeom.getDimensions() == SizeVec3{12, 12, 12}); + + const DataPath featureIdsPath({"ImageGeom", "CellData", "FeatureIds"}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(featureIdsPath)); + const auto& featureIdsRef = dataStructure.getDataRefAs(featureIdsPath).getDataStoreRef(); + + for(usize i = 0; i < featureIdsRef.getNumberOfTuples(); i++) + { + REQUIRE(featureIdsRef[i] == 0); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} diff --git a/src/Plugins/SimplnxCore/test/SurfaceNetsFaceLabelsTest.cpp b/src/Plugins/SimplnxCore/test/SurfaceNetsFaceLabelsTest.cpp new file mode 100644 index 0000000000..86fa5eab60 --- /dev/null +++ b/src/Plugins/SimplnxCore/test/SurfaceNetsFaceLabelsTest.cpp @@ -0,0 +1,52 @@ +#include "SimplnxCore/Filters/SurfaceNetsFilter.hpp" +#include "SurfaceMeshingTestUtils.hpp" + +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/MultiArraySelectionParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" + +#include + +#include +#include + +using namespace nx::core; + +namespace +{ +const DataPath k_TriangleGeomPath({"SurfaceNets"}); + +// Runs SurfaceNets on the flush cylinder, using the shared mesher-agnostic RunMesher helper (see +// SurfaceMeshingTestUtils.hpp) so the argument list is defined in one place instead of duplicated +// per test file. +SurfaceMeshingTest::MeshResult RunSurfaceNetsForFaceLabels() +{ + return SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(true), k_TriangleGeomPath, BoundingBoxSkinMode::k_Off, [](Arguments& args) { + args.insertOrAssign(SurfaceNetsFilter::k_ApplySmoothing_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_SmoothingIterations_Key, std::make_any(20)); + args.insertOrAssign(SurfaceNetsFilter::k_MaxDistanceFromVoxelCenter_Key, std::make_any(1.0F)); + args.insertOrAssign(SurfaceNetsFilter::k_RelaxationFactor_Key, std::make_any(0.5F)); + }); +} +} // namespace + +TEST_CASE("SimplnxCore::SurfaceNetsFilter: Face Label conventions", "[SimplnxCore][SurfaceNetsFilter]") +{ + UnitTest::LoadPlugins(); + + SurfaceMeshingTest::MeshResult meshResult = RunSurfaceNetsForFaceLabels(); + const std::set> labelPairs = SurfaceMeshingTest::CollectLabelPairs(meshResult); + + // The cylinder wall separates background (0) from the cylinder (1). + CHECK(labelPairs.count({0, 1}) == 1); + // The box wall backed by background must be distinguishable from a feature cap. + CHECK(labelPairs.count({-1, 0}) == 1); + // The cylinder's bottom cap: box wall backed by the cylinder. + CHECK(labelPairs.count({-1, 1}) == 1); + // The buggy {-1,-1} pair must not appear. + CHECK(labelPairs.count({-1, -1}) == 0); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); +} diff --git a/src/Plugins/SimplnxCore/test/SurfaceNetsNodeTypesTest.cpp b/src/Plugins/SimplnxCore/test/SurfaceNetsNodeTypesTest.cpp new file mode 100644 index 0000000000..4557882c98 --- /dev/null +++ b/src/Plugins/SimplnxCore/test/SurfaceNetsNodeTypesTest.cpp @@ -0,0 +1,142 @@ +#include "SimplnxCore/Filters/QuickSurfaceMeshFilter.hpp" +#include "SimplnxCore/Filters/SurfaceNetsFilter.hpp" +#include "SurfaceMeshingTestUtils.hpp" + +#include "simplnx/DataStructure/Geometry/INodeGeometry2D.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/MultiArraySelectionParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" + +#include + +#include +#include +#include +#include + +using namespace nx::core; + +namespace +{ +const DataPath k_SurfaceNetsTriangleGeomPath({"SurfaceNets"}); +const DataPath k_QuickMeshTriangleGeomPath({"QuickMesh"}); + +// Runs SurfaceNets on the flush cylinder, using the shared mesher-agnostic RunMesher helper (see +// SurfaceMeshingTestUtils.hpp) so the argument list is defined in one place instead of duplicated +// per test file. +SurfaceMeshingTest::MeshResult RunSurfaceNetsForNodeTypes() +{ + return SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(true), k_SurfaceNetsTriangleGeomPath, BoundingBoxSkinMode::k_Off, [](Arguments& args) { + args.insertOrAssign(SurfaceNetsFilter::k_ApplySmoothing_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_SmoothingIterations_Key, std::make_any(20)); + args.insertOrAssign(SurfaceNetsFilter::k_MaxDistanceFromVoxelCenter_Key, std::make_any(1.0F)); + args.insertOrAssign(SurfaceNetsFilter::k_RelaxationFactor_Key, std::make_any(0.5F)); + }); +} + +// Returns the set of distinct Node Type values present in meshResult's Vertex Data/NodeTypes array. +std::set CollectNodeTypeValues(const SurfaceMeshingTest::MeshResult& meshResult) +{ + const DataPath nodeTypesPath = meshResult.TriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(nodeTypesPath)); + const auto& nodeTypesRef = meshResult.Structure.getDataRefAs(nodeTypesPath).getDataStoreRef(); + + std::set distinctValues; + for(usize i = 0; i < nodeTypesRef.getNumberOfTuples(); i++) + { + distinctValues.insert(nodeTypesRef[i]); + } + return distinctValues; +} +} // namespace + +TEST_CASE("SimplnxCore::SurfaceNetsFilter: Node Types follow the NodeType convention", "[SimplnxCore][SurfaceNetsFilter]") +{ + UnitTest::LoadPlugins(); + + SurfaceMeshingTest::MeshResult meshResult = RunSurfaceNetsForNodeTypes(); + const std::set distinctValues = CollectNodeTypeValues(meshResult); + + // Only the shared convention values may appear: 2/3/4 interior, 12/13/14 on the box wall. + const std::set allowedValues = {NodeType::Default, NodeType::TriplePoint, NodeType::QuadPoint, NodeType::SurfaceDefault, NodeType::SurfaceTriplePoint, NodeType::SurfaceQuadPoint}; + for(const int8 value : distinctValues) + { + INFO("Unexpected Node Type value: " << static_cast(value)); + REQUIRE(allowedValues.count(value) == 1); + } + + // The flush cylinder guarantees both an interior node and a box-wall node exist. + REQUIRE(distinctValues.count(NodeType::Default) == 1); + REQUIRE(distinctValues.count(NodeType::SurfaceDefault) == 1); + + // 0 must never appear -- it means NodeType::Unused. + REQUIRE(distinctValues.count(0) == 0); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); +} + +TEST_CASE("SimplnxCore::SurfaceNetsFilter: Node Types agree with QuickSurfaceMesh", "[SimplnxCore][SurfaceNetsFilter]") +{ + UnitTest::LoadPlugins(); + + // Both meshers place a node near each grid corner where two or more Features meet, and + // both derive the node type from the same 8 surrounding voxels. With smoothing disabled + // the values must agree corner-for-corner. This is the check that proves the equivalence + // argument in the design spec rather than trusting it. + SurfaceMeshingTest::MeshResult surfaceNetsResult = RunSurfaceNetsForNodeTypes(); + SurfaceMeshingTest::MeshResult quickMeshResult = + SurfaceMeshingTest::RunMesher(SurfaceMeshingTest::CreateCylinderInBox(true), k_QuickMeshTriangleGeomPath, BoundingBoxSkinMode::k_Off, + [](Arguments& args) { args.insertOrAssign(QuickSurfaceMeshFilter::k_FixProblemVoxels_Key, std::make_any(false)); }); + + // Both meshers should produce the same number of nodes, since both emit one per grid + // corner where two or more Features meet. + REQUIRE_NOTHROW(surfaceNetsResult.Structure.getDataRefAs(k_SurfaceNetsTriangleGeomPath)); + REQUIRE_NOTHROW(quickMeshResult.Structure.getDataRefAs(k_QuickMeshTriangleGeomPath)); + const auto& snGeom = surfaceNetsResult.Structure.getDataRefAs(k_SurfaceNetsTriangleGeomPath); + const auto& qsmGeom = quickMeshResult.Structure.getDataRefAs(k_QuickMeshTriangleGeomPath); + REQUIRE(snGeom.getNumberOfVertices() == qsmGeom.getNumberOfVertices()); + + const auto& snVertsRef = snGeom.getVertices()->getDataStoreRef(); + const auto& qsmVertsRef = qsmGeom.getVertices()->getDataStoreRef(); + const DataPath snNodeTypesPath = k_SurfaceNetsTriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + const DataPath qsmNodeTypesPath = k_QuickMeshTriangleGeomPath.createChildPath("Vertex Data").createChildPath("NodeTypes"); + REQUIRE_NOTHROW(surfaceNetsResult.Structure.getDataRefAs(snNodeTypesPath)); + REQUIRE_NOTHROW(quickMeshResult.Structure.getDataRefAs(qsmNodeTypesPath)); + const auto& snTypesRef = surfaceNetsResult.Structure.getDataRefAs(snNodeTypesPath).getDataStoreRef(); + const auto& qsmTypesRef = quickMeshResult.Structure.getDataRefAs(qsmNodeTypesPath).getDataStoreRef(); + + // Exact, corner-for-corner comparison: the two meshers number their nodes in different orders + // (a histogram comparison alone would pass under a permutation of values across corners), so key + // QuickSurfaceMesh's vertices by coordinate and require every SurfaceNets vertex at the same + // coordinate to carry the identical Node Type value. This is the check that proves the + // equivalence argument in the design spec rather than trusting it. + std::map, int8> qsmTypeByCoord; + for(usize i = 0; i < qsmTypesRef.getNumberOfTuples(); i++) + { + qsmTypeByCoord[{qsmVertsRef[i * 3], qsmVertsRef[i * 3 + 1], qsmVertsRef[i * 3 + 2]}] = qsmTypesRef[i]; + } + + usize mismatchCount = 0; + std::ostringstream firstMismatch; + for(usize i = 0; i < snTypesRef.getNumberOfTuples(); i++) + { + const std::tuple coord = {snVertsRef[i * 3], snVertsRef[i * 3 + 1], snVertsRef[i * 3 + 2]}; + REQUIRE(qsmTypeByCoord.count(coord) == 1); + if(qsmTypeByCoord[coord] != snTypesRef[i]) + { + if(mismatchCount == 0) + { + firstMismatch << "(" << std::get<0>(coord) << ", " << std::get<1>(coord) << ", " << std::get<2>(coord) << "): QuickSurfaceMesh=" << static_cast(qsmTypeByCoord[coord]) + << " SurfaceNets=" << static_cast(snTypesRef[i]); + } + mismatchCount++; + } + } + INFO("Total mismatching vertices: " << mismatchCount << " / " << snTypesRef.getNumberOfTuples() << (mismatchCount > 0 ? (" -- first mismatch at " + firstMismatch.str()) : std::string{})); + REQUIRE(mismatchCount == 0); + + UnitTest::CheckArraysInheritTupleDims(surfaceNetsResult.Structure); + UnitTest::CheckArraysInheritTupleDims(quickMeshResult.Structure); +} diff --git a/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp b/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp index 8b8ce2d9b8..82be4163cd 100644 --- a/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp +++ b/src/Plugins/SimplnxCore/test/SurfaceNetsTest.cpp @@ -1,11 +1,14 @@ #include "SimplnxCore/Filters/SurfaceNetsFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" +#include "SurfaceMeshingTestUtils.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" #include "simplnx/Parameters/ArrayCreationParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" #include "simplnx/Parameters/MultiArraySelectionParameter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/Meshing/TriangleUtilities.hpp" #include @@ -13,14 +16,52 @@ using namespace nx::core; using namespace nx::core::UnitTest; using namespace nx::core::Constants; +TEST_CASE("SimplnxCore::SurfaceNetsFilter: Anisotropic spacing places Z correctly", "[SimplnxCore][SurfaceNetsFilter]") +{ + // Regression test for a bug where the Z half-voxel relocation offset used voxelSize[1] (Y + // spacing) instead of voxelSize[2] (Z spacing). On isotropic data (the exemplar dataset used + // by the other SurfaceNetsFilter tests above) voxelSize[1] == voxelSize[2], so the bug is + // invisible there. Y and Z spacing are chosen sharply different here (1.0 vs 4.0) so a + // Y-for-Z substitution is unmistakable in the resulting mesh's Z bounding box. + UnitTest::LoadPlugins(); + + const FloatVec3 k_Spacing = {0.25F, 1.0F, 4.0F}; + DataStructure dataStructure = SurfaceMeshingTest::CreateCylinderInBox(/*flushWithBottom=*/true, k_Spacing); + const DataPath k_TriangleGeomPath({"SurfaceNets"}); + + SurfaceMeshingTest::MeshResult meshResult = SurfaceMeshingTest::RunMesher(std::move(dataStructure), k_TriangleGeomPath, BoundingBoxSkinMode::k_Off, [](Arguments& args) { + args.insertOrAssign(SurfaceNetsFilter::k_ApplySmoothing_Key, std::make_any(false)); + args.insertOrAssign(SurfaceNetsFilter::k_SmoothingIterations_Key, std::make_any(20)); + args.insertOrAssign(SurfaceNetsFilter::k_MaxDistanceFromVoxelCenter_Key, std::make_any(1.0F)); + args.insertOrAssign(SurfaceNetsFilter::k_RelaxationFactor_Key, std::make_any(0.5F)); + }); + + REQUIRE_NOTHROW(meshResult.Structure.getDataRefAs(k_TriangleGeomPath)); + const auto& triangleGeom = meshResult.Structure.getDataRefAs(k_TriangleGeomPath); + const auto boundingBox = triangleGeom.getBoundingBox(); + + // The cylinder is flush with the box's bottom Z wall, and the box's top Z wall is pure + // background over its full X/Y extent, so the mesh's Z bounding box exactly spans the box: + // [0, dimZ * spacingZ]. With the bug (Y spacing substituted for Z spacing), every vertex's Z + // is shifted by 0.5*(spacingZ - spacingY) = 1.5, moving both bounds to [1.5, 49.5]. + const float32 k_ExpectedMinZ = 0.0F; + const float32 k_ExpectedMaxZ = static_cast(SurfaceMeshingTest::k_BoxDim) * k_Spacing[2]; + const float32 k_Tolerance = 1.0e-4F; + + REQUIRE_THAT(boundingBox.getMinPoint()[2], Catch::Matchers::WithinAbs(k_ExpectedMinZ, k_Tolerance)); + REQUIRE_THAT(boundingBox.getMaxPoint()[2], Catch::Matchers::WithinAbs(k_ExpectedMaxZ, k_Tolerance)); + + UnitTest::CheckArraysInheritTupleDims(meshResult.Structure); +} + TEST_CASE("SimplnxCore::SurfaceNetsFilter: Default", "[SimplnxCore][SurfaceNetsFilter]") { UnitTest::LoadPlugins(); - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v3.tar.gz", "SurfaceNetsTest_v3"); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v4.tar.gz", "SurfaceNetsTest_v4"); // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v3/SurfaceNetsTest_v3.dream3d", nx::core::unit_test::k_TestFilesDir)); + auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v4/SurfaceNetsTest_v4.dream3d", nx::core::unit_test::k_TestFilesDir)); DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); DataPath smallIn100Group({nx::core::Constants::k_DataContainer}); DataPath gridGeomDataPath({k_DataContainer}); @@ -118,10 +159,10 @@ TEST_CASE("SimplnxCore::SurfaceNetsFilter: Smoothing", "[SimplnxCore][SurfaceNet { UnitTest::LoadPlugins(); - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v3.tar.gz", "SurfaceNetsTest_v3"); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v4.tar.gz", "SurfaceNetsTest_v4"); // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v3/SurfaceNetsTest_v3.dream3d", nx::core::unit_test::k_TestFilesDir)); + auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v4/SurfaceNetsTest_v4.dream3d", nx::core::unit_test::k_TestFilesDir)); DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); DataPath smallIn100Group({nx::core::Constants::k_DataContainer}); DataPath gridGeomDataPath({k_DataContainer}); @@ -218,10 +259,10 @@ TEST_CASE("SimplnxCore::SurfaceNetsFilter: Winding", "[SimplnxCore][SurfaceNetsF { UnitTest::LoadPlugins(); - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v3.tar.gz", "SurfaceNetsTest_v3"); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v4.tar.gz", "SurfaceNetsTest_v4"); // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v3/SurfaceNetsTest_v3.dream3d", nx::core::unit_test::k_TestFilesDir)); + auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v4/SurfaceNetsTest_v4.dream3d", nx::core::unit_test::k_TestFilesDir)); DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); DataPath smallIn100Group({nx::core::Constants::k_DataContainer}); DataPath gridGeomDataPath({k_DataContainer}); @@ -317,10 +358,10 @@ TEST_CASE("SimplnxCore::SurfaceNetsFilter: Winding Smoothing", "[SimplnxCore][Su { UnitTest::LoadPlugins(); - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v3.tar.gz", "SurfaceNetsTest_v3"); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "SurfaceNetsTest_v4.tar.gz", "SurfaceNetsTest_v4"); // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v3/SurfaceNetsTest_v3.dream3d", nx::core::unit_test::k_TestFilesDir)); + auto baseDataFilePath = fs::path(fmt::format("{}/SurfaceNetsTest_v4/SurfaceNetsTest_v4.dream3d", nx::core::unit_test::k_TestFilesDir)); DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); DataPath smallIn100Group({nx::core::Constants::k_DataContainer}); DataPath gridGeomDataPath({k_DataContainer}); diff --git a/src/simplnx/Utilities/Meshing/TriangleUtilities.cpp b/src/simplnx/Utilities/Meshing/TriangleUtilities.cpp index 4bd36cc0d5..39858064c8 100644 --- a/src/simplnx/Utilities/Meshing/TriangleUtilities.cpp +++ b/src/simplnx/Utilities/Meshing/TriangleUtilities.cpp @@ -399,3 +399,58 @@ void MeshingUtilities::CalculateNormalsImpl::operator()(const nx::core::Range& r { generate(range.min(), range.max()); } + +Result<> MeshingUtilities::MakeEmptyMeshWarning(const DataPath& triangleGeomPath, usize numCells, usize numVertices) +{ + return MakeWarningVoidResult(k_EmptyMeshAfterSkinRemovalWarning, + fmt::format("The 'Bounding Box Skin' option's 'Background-Backed Walls Only' mode removed every face of geometry '{}'. All {} cells of the input have Feature Id 0 " + "(background), so there is no internal interface and no Feature to cap. The Triangle Geometry now has 0 faces and {} vertices remaining.", + triangleGeomPath.toString(), numCells, numVertices)); +} + +Result<> MeshingUtilities::MakeNoFacesPrunedWarning(const DataPath& triangleGeomPath) +{ + return MakeWarningVoidResult(k_NoFacesPrunedWarning, + fmt::format("The 'Bounding Box Skin' option's 'Background-Backed Walls Only' mode removed 0 faces of geometry '{}': no bounding-box wall face is backed by " + "background (Feature Id 0). This says nothing about the volume's interior -- a volume whose background is fully enclosed as interior porosity " + "reaches this same warning, because none of that background borders a wall. There is nothing for this option to prune on this input; the output " + "is identical to leaving it off.", + triangleGeomPath.toString())); +} + +Result<> MeshingUtilities::ValidateFeatureIdsAgainstSentinels(const Int32AbstractDataStore& featureIdsStore, const DataPath& featureIdsPath, bool rejectMaxInt32, const std::atomic_bool& shouldCancel, + const IFilter::MessageHandler& mesgHandler) +{ + const usize numTuples = featureIdsStore.getNumberOfTuples(); + mesgHandler(fmt::format("Validating {} Feature Ids against internal sentinel values...", numTuples)); + + // Polled every k_CancelCheckInterval tuples rather than every tuple: at 512^3 (~134M tuples) this + // loop is a full streaming pass under the out-of-core backend, and a per-tuple cancel check would + // add overhead to what is otherwise a tight, uncontested read loop. + constexpr usize k_CancelCheckInterval = 1'000'000; + for(usize i = 0; i < numTuples; i++) + { + if(i % k_CancelCheckInterval == 0 && shouldCancel) + { + return {}; + } + + const int32 featureId = featureIdsStore[i]; + if(featureId < 0) + { + return MakeErrorResult(k_InvalidFeatureIdError, fmt::format("Feature Ids array '{}' contains a negative value ({}) at tuple index {}. This mesher reserves negative Feature Ids for an " + "internal ghost/exterior sentinel convention; relabel this input so every Feature Id is >= 0. (Mitigation for the " + "sentinel-collision design tracked separately as simplnx#1705.)", + featureIdsPath.toString(), featureId, i)); + } + if(rejectMaxInt32 && featureId == std::numeric_limits::max()) + { + return MakeErrorResult(k_InvalidFeatureIdError, fmt::format("Feature Ids array '{}' contains the value {} at tuple index {}, which collides with an internal 'outside the volume' " + "sentinel used by this mesher. Relabel this input so no Feature Id equals INT32_MAX. (Mitigation for the sentinel-collision " + "design tracked separately as simplnx#1705.)", + featureIdsPath.toString(), featureId, i)); + } + } + + return {}; +} diff --git a/src/simplnx/Utilities/Meshing/TriangleUtilities.hpp b/src/simplnx/Utilities/Meshing/TriangleUtilities.hpp index b5f680525b..2dccc12420 100644 --- a/src/simplnx/Utilities/Meshing/TriangleUtilities.hpp +++ b/src/simplnx/Utilities/Meshing/TriangleUtilities.hpp @@ -2,12 +2,100 @@ #include "simplnx/Common/Range.hpp" #include "simplnx/Common/Result.hpp" +#include "simplnx/DataStructure/DataPath.hpp" #include "simplnx/DataStructure/Geometry/IGeometry.hpp" #include "simplnx/DataStructure/Geometry/INodeGeometry2D.hpp" #include "simplnx/Filter/IFilter.hpp" +namespace nx::core +{ +/** + * @brief Shared values for the "Bounding Box Skin" ChoicesParameter used by QuickSurfaceMeshFilter, + * SurfaceNetsFilter, and M3CSurfaceMeshingFilter. Named here (rather than as bare literals) so a + * future third mode can be added without every `== 1` comparison needing to be rediscovered. + * Typed as `uint64` (rather than `ChoicesParameter::ValueType`, which is itself just an alias for + * `uint64`) so this header does not need to pull in ChoicesParameter.hpp; every use site compares or + * assigns against a `ChoicesParameter::ValueType`, so the two types are interchangeable here. + */ +namespace BoundingBoxSkinMode +{ +inline constexpr uint64 k_Off = 0; +inline constexpr uint64 k_BackgroundBackedWallsOnly = 1; +} // namespace BoundingBoxSkinMode +} // namespace nx::core + namespace nx::core::MeshingUtilities { +/** + * @brief Warning emitted when the 'Bounding Box Skin' option's 'Background-Backed Walls Only' mode + * (BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) removes every face of the mesh -- i.e. the input is + * entirely background (Feature Id 0), so there is no internal interface and no Feature to cap any box + * wall. Shared verbatim by QuickSurfaceMesh, SurfaceNets, and M3CSurfaceMeshing so the warning text and + * code are defined exactly once. See MakeEmptyMeshWarning(). + */ +inline constexpr int32 k_EmptyMeshAfterSkinRemovalWarning = -56340; + +/** + * @brief Warning emitted when the 'Bounding Box Skin' option's 'Background-Backed Walls Only' mode + * (BoundingBoxSkinMode::k_BackgroundBackedWallsOnly) is enabled but suppressed zero bounding-box wall + * faces -- i.e. no wall face is backed by background (Feature Id 0), so the option had nothing to + * prune and the output is identical to leaving it off. This says nothing about whether the volume + * contains background elsewhere: a volume whose background is fully enclosed as interior porosity + * also reaches this warning, because none of that background borders a bounding-box wall. This is + * the most common dataset shape in practice, so silent no-feedback behavior here is not acceptable. + * See MakeNoFacesPrunedWarning(). + */ +inline constexpr int32 k_NoFacesPrunedWarning = -56342; + +/** + * @brief Error emitted when a Feature Ids array contains a value that collides with a mesher's internal + * "not a real Feature" sentinel space (see ValidateFeatureIdsAgainstSentinels()). This is a mitigation + * for the underlying sentinel-collision design, not a fix for it -- the architectural issue is tracked + * separately as simplnx#1705. + */ +inline constexpr int32 k_InvalidFeatureIdError = -56343; + +/** + * @brief Builds the warning Result for k_EmptyMeshAfterSkinRemovalWarning (see its docs above). + * The vertex count is reported rather than assumed to be zero: QuickSurfaceMesh and SurfaceNets + * both reach zero vertices in this case, but M3CSurfaceMeshing's marching-cubes candidate generation + * can leave a handful of pre-existing candidate nodes that no triangle -- dropped or surviving -- + * ever referenced, so its vertex count here is not necessarily zero. + * @param triangleGeomPath Path to the (now-empty, or near-empty) Triangle Geometry, named in the message. + * @param numCells Number of Feature Id cells in the input, named in the message. + * @param numVertices Number of vertices remaining in the Triangle Geometry after the prune. + */ +SIMPLNX_EXPORT Result<> MakeEmptyMeshWarning(const DataPath& triangleGeomPath, usize numCells, usize numVertices); + +/** + * @brief Builds the warning Result for k_NoFacesPrunedWarning (see its docs above). + * @param triangleGeomPath Path to the Triangle Geometry, named in the message. + */ +SIMPLNX_EXPORT Result<> MakeNoFacesPrunedWarning(const DataPath& triangleGeomPath); + +/** + * @brief Validates that no value in a Feature Ids array collides with a mesher's internal "not a real + * Feature" sentinel space (e.g. SurfaceNets' MMSurfaceNet::Padding == INT32_MAX, M3CSurfaceMeshing's + * maxGrainId+1 overflow and nSpin < 0 ghost convention, QuickSurfaceMesh's hard-coded -1 exterior Face + * Label). This is a mitigation, not a fix, for that design -- the architectural issue is tracked + * separately as simplnx#1705. + * + * Call this from an algorithm's execute entry point, never from preflight: a full-volume scan (e.g. + * ~134M reads at 512^3) is too expensive to repeat on every GUI parameter edit. Because that same + * full-volume scan is otherwise silent and uncancellable, this emits a message before scanning and + * polls shouldCancel periodically (not on every tuple, to keep the inner loop tight). + * @param featureIdsStore The Feature Ids to validate. + * @param featureIdsPath Path to the array, named in the error message so the user can locate it. + * @param rejectMaxInt32 When true, also reject a Feature Id of exactly INT32_MAX (SurfaceNets and + * M3CSurfaceMeshing both need this; QuickSurfaceMesh only collides on negative values, so it passes false). + * @param shouldCancel Checked periodically so the scan can be interrupted. + * @param mesgHandler Used to report that the scan is running before it starts. + * @returns An invalid Result<> naming the offending value, its tuple index, and featureIdsPath on the + * first rejected value found; an empty valid Result<> otherwise (including if cancelled). + */ +SIMPLNX_EXPORT Result<> ValidateFeatureIdsAgainstSentinels(const Int32AbstractDataStore& featureIdsStore, const DataPath& featureIdsPath, bool rejectMaxInt32, const std::atomic_bool& shouldCancel, + const IFilter::MessageHandler& mesgHandler); + namespace detail { inline static constexpr usize k_00 = 0;