diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index b38d9f3444..c3e5ac7e2e 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -18,6 +18,20 @@ The Axom project release numbers follow [Semantic Versioning](http://semver.org/ ## [Unreleased] - Release date yyyy-mm-dd +### Added +- Quest: `MarchingCubes` gained an optional `bump` backend, selected with `setUseBumpBackend(true)`. + It extends isocontour extraction to `unstructured` meshes (quads in 2D, hexs in 3D) + and to `uniform` and `rectilinear` topologies. The legacy backend requires a `structured` topology + with an `explicit` coordset. It runs on all runtime policies and preserves the existing output API. + +### Changed +- Quest: `MarchingCubes::setMesh()` now accepts either a single-domain or a multi-domain Blueprint mesh. + +### Fixed +- Bump: `dispatch_any_structured_topology` now detects strided-structured topologies. It previously + probed `offsets` and `strides` at the topology root. Blueprint stores them under `elements/dims`. + + ## [Version 0.15.0] - Release date 2026-08-28 ### Added diff --git a/data b/data index 8ac544afdc..358ef2f012 160000 --- a/data +++ b/data @@ -1 +1 @@ -Subproject commit 8ac544afdc0d75e9cfe0681f9eaa8f2150534dea +Subproject commit 358ef2f01209250c33e4851e4bf491217c5bf61e diff --git a/src/axom/bump/Unique.hpp b/src/axom/bump/Unique.hpp index 2fb2cbfa66..65eb1e99f5 100644 --- a/src/axom/bump/Unique.hpp +++ b/src/axom/bump/Unique.hpp @@ -219,6 +219,7 @@ struct Unique // Make unique values and store the indices. std::unordered_map unique_map; const axom::IndexType n = keys_orig_view.size(); + unique_map.reserve(static_cast(n)); for(axom::IndexType index = 0; index < n; ++index) { const auto k = keys_orig_view[index]; diff --git a/src/axom/bump/tests/bump_views.cpp b/src/axom/bump/tests/bump_views.cpp index 1cb52228ae..f44e7631bc 100644 --- a/src/axom/bump/tests/bump_views.cpp +++ b/src/axom/bump/tests/bump_views.cpp @@ -582,6 +582,32 @@ struct test_strided_structured TEST(bump_views, strided_structured_seq) { test_strided_structured::test(); } +template +void test_strided_structured_any_dispatch() +{ + conduit::Node hostMesh; + axom::blueprint::testing::data::strided_structured(hostMesh); + + bool callback_invoked = false; + bool supports_strided_structured = false; + views::dispatch_structured_topologies( + hostMesh["topologies/mesh"], + [&](const std::string&, auto topoView) { + callback_invoked = true; + supports_strided_structured = + views::view_traits::supports_strided_structured(); + }); + + EXPECT_TRUE(callback_invoked); + EXPECT_TRUE(supports_strided_structured); +} + +TEST(bump_views, strided_structured_any_dispatch) +{ + test_strided_structured_any_dispatch<2>(); + test_strided_structured_any_dispatch<3>(); +} + //------------------------------------------------------------------------------ template struct test_braid2d_mat diff --git a/src/axom/bump/views/dispatch_structured_topology.hpp b/src/axom/bump/views/dispatch_structured_topology.hpp index a329a170e0..cb247efff7 100644 --- a/src/axom/bump/views/dispatch_structured_topology.hpp +++ b/src/axom/bump/views/dispatch_structured_topology.hpp @@ -431,7 +431,7 @@ struct dispatch_any_structured_topology */ static void execute(const conduit::Node& topo, FuncType&& func) { - const std::string offsetsKey("offsets"), stridesKey("strides"); + const std::string offsetsKey("elements/dims/offsets"), stridesKey("elements/dims/strides"); const std::string type = topo.fetch_existing("type").as_string(); const std::string shape("hex"); @@ -470,7 +470,7 @@ struct dispatch_any_structured_topology */ static void execute(const conduit::Node& topo, FuncType&& func) { - const std::string offsetsKey("offsets"), stridesKey("strides"); + const std::string offsetsKey("elements/dims/offsets"), stridesKey("elements/dims/strides"); const std::string type = topo.fetch_existing("type").as_string(); const std::string shape("quad"); if(type == "structured" && topo.has_path(offsetsKey) && topo.has_path(stridesKey)) diff --git a/src/axom/quest/CMakeLists.txt b/src/axom/quest/CMakeLists.txt index 3abfd86a5e..276862a433 100644 --- a/src/axom/quest/CMakeLists.txt +++ b/src/axom/quest/CMakeLists.txt @@ -136,19 +136,28 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_MPI) conduit::conduit_mpi) endif() -blt_list_append( - TO quest_headers - ELEMENTS MarchingCubes.hpp detail/MarchingCubesSingleDomain.hpp detail/MarchingCubesImpl.hpp - IF CONDUIT_FOUND - ) - -blt_list_append( - TO quest_sources - ELEMENTS MarchingCubes.cpp detail/MarchingCubesSingleDomain.cpp - IF CONDUIT_FOUND - ) - -blt_list_append( TO quest_depends_on ELEMENTS conduit::conduit IF CONDUIT_FOUND ) +if(CONDUIT_FOUND) + blt_list_append( + TO quest_headers + ELEMENTS MarchingCubes.hpp + detail/MarchingCubesSingleDomain.hpp + detail/MarchingCubesImpl.hpp) + + blt_list_append( + TO quest_sources + ELEMENTS MarchingCubes.cpp + detail/MarchingCubesSingleDomain.cpp) + + if(AXOM_ENABLE_BUMP) + blt_list_append( + TO quest_headers + ELEMENTS detail/MarchingCubesBumpAdaptor.hpp + detail/MarchingCubesBumpImpl.hpp) + endif() + + blt_list_append( TO quest_depends_on ELEMENTS conduit::conduit) +endif() + if(AXOM_ENABLE_KLEE AND AXOM_ENABLE_SIDRE) if(MFEM_FOUND OR CONDUIT_FOUND) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index 4a0d4f855e..1fd65342aa 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -18,9 +18,7 @@ #include "axom/quest/detail/MarchingCubesImpl.hpp" #include "axom/fmt.hpp" -namespace axom -{ -namespace quest +namespace axom::quest { const axom::StackArray twoZeros {0, 0}; @@ -42,6 +40,7 @@ MarchingCubes::MarchingCubes(RuntimePolicy runtimePolicy, , m_crossingFlags(0, 0, m_allocatorID) , m_scannedFlags(0, 0, m_allocatorID) , m_facetIncrs(0, 0, m_allocatorID) + , m_nodeCount(0) , m_facetNodeIds(twoZeros, m_allocatorID) , m_facetNodeCoords(twoZeros, m_allocatorID) , m_facetParentIds(0, 0, m_allocatorID) @@ -53,8 +52,27 @@ void MarchingCubes::setMesh(const conduit::Node& bpMesh, const std::string& topologyName, const std::string& maskField) { - SLIC_ASSERT_MSG(conduit::blueprint::mesh::is_multi_domain(bpMesh), - "MarchingCubes class input mesh must be in multidomain format."); + const conduit::Node* mdMesh = &bpMesh; + if(bpMesh.has_path("topologies/" + topologyName)) + { + m_singleDomainMesh.reset(); + m_singleDomainMesh.append().set_external(bpMesh); + mdMesh = &m_singleDomainMesh; + } + else if(conduit::blueprint::mesh::is_multi_domain(bpMesh)) + { + m_singleDomainMesh.reset(); + } + else + { + // Neither a single domain carrying the requested topology nor a valid multi-domain mesh. + // Error out here since wrapping it would defers the failure into an opaque fetch_existing() below. + SLIC_ERROR( + axom::fmt::format("MarchingCubes::setMesh: the input mesh is neither a multi-domain " + "Blueprint mesh nor a single domain containing topology '{}'.", + topologyName)); + return; + } m_topologyName = topologyName; m_maskFieldName = maskField; @@ -66,7 +84,7 @@ void MarchingCubes::setMesh(const conduit::Node& bpMesh, domains is m_domainCount, not m_singles.size(). To *really* deallocate memory, deallocate the MarchingCubes object. */ - auto newDomainCount = conduit::blueprint::mesh::number_of_domains(bpMesh); + auto newDomainCount = conduit::blueprint::mesh::number_of_domains(*mdMesh); if(m_singles.size() < newDomainCount) { @@ -80,7 +98,7 @@ void MarchingCubes::setMesh(const conduit::Node& bpMesh, for(int d = 0; d < newDomainCount; ++d) { - const auto& dom = bpMesh.child(d); + const auto& dom = mdMesh->child(d); m_singles[d]->setDomain(dom, m_topologyName, maskField); } for(int d = newDomainCount; d < m_singles.size(); ++d) @@ -101,22 +119,43 @@ void MarchingCubes::setFunctionField(const std::string& fcnField) } } +void MarchingCubes::setUseBumpBackend(bool useBump) +{ +#if !defined(AXOM_USE_BUMP) + SLIC_ERROR_IF(useBump, + "MarchingCubes bump backend requires Axom to be configured " + "with the bump component."); +#endif + m_useBumpBackend = useBump; +} + void MarchingCubes::computeIsocontour(double contourVal) { AXOM_ANNOTATE_SCOPE("MarchingCubes::computeIsoContour"); + /* + NOTE: the accumulators are deliberately not reset here. + Successive computeIsocontour() calls accumulate into one facet buffer. + It calls clearOutput() once, then loops over function fields and mask values + calling computeIsocontour() for each, recording a running prefix sum of facet counts per strategy. + */ + // Mark and scan domains while adding up their // facet counts to get the total facet counts. m_facetIndexOffsets.resize(m_singles.size()); + m_nodeIndexOffsets.resize(m_singles.size()); for(axom::IndexType d = 0; d < m_domainCount; ++d) { auto& single = *m_singles[d]; single.setContourValue(contourVal); single.setMaskValue(m_maskVal); + single.setRobustnessPolicy(m_robustnessPolicy); single.markCrossings(); single.scanCrossings(); m_facetIndexOffsets[d] = m_facetCount; + m_nodeIndexOffsets[d] = m_nodeCount; m_facetCount += single.getContourCellCount(); + m_nodeCount += single.getContourNodeCount(); } allocateOutputBuffers(); @@ -130,7 +169,8 @@ void MarchingCubes::computeIsocontour(double contourVal) m_singles[d]->getImpl().setOutputBuffers(facetNodeIdsView, facetNodeCoordsView, facetParentIdsView, - m_facetIndexOffsets[d]); + m_facetIndexOffsets[d], + m_nodeIndexOffsets[d]); } for(axom::IndexType d = 0; d < m_domainCount; ++d) @@ -147,16 +187,16 @@ void MarchingCubes::computeIsocontour(double contourVal) } } -axom::IndexType MarchingCubes::getContourNodeCount() const -{ - axom::IndexType contourNodeCount = - (m_domainCount > 0) ? m_facetCount * m_singles[0]->spatialDimension() : 0; - return contourNodeCount; -} +axom::IndexType MarchingCubes::getContourNodeCount() const { return m_nodeCount; } void MarchingCubes::clearOutput() { + for(axom::IndexType d = 0; d < m_domainCount; ++d) + { + m_singles[d]->getImpl().clearDomain(); + } m_facetCount = 0; + m_nodeCount = 0; m_facetNodeIds.clear(); m_facetNodeCoords.clear(); m_facetParentIds.clear(); @@ -234,19 +274,71 @@ void MarchingCubes::populateContourMesh(axom::mint::UnstructuredMesh(d)); + } + } +} + +void MarchingCubes::relinquishContourDataBlueprint(conduit::Node& bpMesh) +{ + AXOM_ANNOTATE_SCOPE("MarchingCubes::relinquishContourDataBlueprint"); + bpMesh.reset(); + + SLIC_ERROR_IF(!m_useBumpBackend, + "MarchingCubes Blueprint contour output is available only when " + "setUseBumpBackend(true) was used."); + + for(axom::IndexType d = 0; d < m_domainCount; ++d) + { + auto& single = *m_singles[d]; + auto& impl = single.getImpl(); + SLIC_ERROR_IF(!impl.hasContourMeshBlueprint(), + "MarchingCubes has no Blueprint contour output. " + "Call computeIsocontour() before requesting it."); + + conduit::Node& outDom = bpMesh.append(); + impl.relinquishContourMeshBlueprint(outDom); + if(!outDom.has_path("state/domain_id")) + { + outDom["state/domain_id"] = single.getDomainId(static_cast(d)); + } + } + + clearOutput(); +} + void MarchingCubes::allocateOutputBuffers() { AXOM_ANNOTATE_SCOPE("MarchingCubes::allocateOutputBuffers"); if(!m_singles.empty()) { int ndim = m_singles[0]->spatialDimension(); - const auto nodeCount = m_facetCount * ndim; m_facetNodeIds.resize(axom::StackArray {m_facetCount, ndim}, 0); - m_facetNodeCoords.resize(axom::StackArray {nodeCount, ndim}, 0.0); + m_facetNodeCoords.resize(axom::StackArray {m_nodeCount, ndim}, 0.0); m_facetParentIds.resize(axom::StackArray {m_facetCount}, 0); m_facetDomainIds.resize(axom::StackArray {m_facetCount}, 0); } } -} // end namespace quest -} // end namespace axom +} // end namespace axom::quest diff --git a/src/axom/quest/MarchingCubes.hpp b/src/axom/quest/MarchingCubes.hpp index e3c6bd6191..ff63c6c23c 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -28,26 +28,23 @@ // C++ includes #include -namespace axom +namespace axom::quest { -namespace quest -{ -namespace detail -{ -namespace marching_cubes +namespace detail::marching_cubes { class MarchingCubesSingleDomain; -} // namespace marching_cubes -} // namespace detail +} // namespace detail::marching_cubes /*! - * @brief Enum for implementation. + * @brief Enum for the legacy marching cubes data-parallel implementation. + * + * Partial parallel implementation uses a non-parallizable loop and processes less data. + * It has been shown to work well on CPUs. Full parallel implementation processes more data, + * but parallelizes fully and has been shown to work well on GPUs. + * byPolicy chooses based on runtime policy. * - * Partial parallel implementation uses a non-parallizable loop and - * processes less data. It has been shown to work well on CPUs. - * Full parallel implementation processes more data, but parallelizes - * fully and has been shown to work well on GPUs. byPolicy chooses - * based on runtime policy. + * @note This setting controls only the legacy structured-mesh backend. When MarchingCubes is configured + * to use the bump backend, bump manages its own internal parallelism. */ enum class MarchingCubesDataParallelism { @@ -56,6 +53,32 @@ enum class MarchingCubesDataParallelism fullParallel = 2 }; +/*! + * @brief Enum selecting the isosurface case-table / intersector robustness used by the bump backend + * + * The bump backend determines per-cell topology with an intersector policy plus VisIt-derived cut tables. + * The default intersector (\c axom::bump::extraction::FieldIntersector) classifies cell corners + * with a strict two-label test (corner value > isovalue), evaluates edge crossings in single precision + * and uses a single fixed triangulation per case. Like the classic 1987 marching-cubes tables, + * this resolves ambiguous (saddle) configurations consistently but not necessarily in a way + * that matches the trilinear interpolant. It does not implement the +/-/0 (three-label) / asymptotic-decider + * topology of Wenger's Isosurfaces or MC33. + * + * @note This enum is in anticipation of the more robust case that will be added soon + * and only applies to the new bump-based backend: + * - \c standard (default): use bump's default intersector + tables. + * This is the only policy currently implemented. + * - \c robust: request a topologically-robust intersector/table set (double precision, +/-/0 aware). + * When bump provides such a policy it will be selected here with no further change to quest; + * until then, selecting \c robust behaves identically to \c standard (and may emit a one-time + * informational note), so callers can opt in now and benefit automatically once the robust policy lands. + */ +enum class MarchingCubesRobustnessPolicy +{ + standard = 0, + robust = 1 +}; + /*! * @brief Class implementing marching cubes to compute a contour * mesh from a scalar function on an input mesh. @@ -67,8 +90,8 @@ enum class MarchingCubesDataParallelism * * Implementation is for 2D (marching squares) and 3D (marching cubes). * - * The input mesh is a Conduit::Node following the Mesh Blueprint - * convention. The mesh must be in multi-domain format. + * The input mesh is a Conduit::Node following the Mesh Blueprint convention. + * The mesh must be in multi-domain format. * * Usage example: * @verbatim @@ -89,20 +112,19 @@ enum class MarchingCubesDataParallelism * } * @endverbatim * - * To avoid confusion between the two meshes, we refer to the input - * mesh with the scalar function as "parent" and the generated mesh - * as the "contour". + * To avoid confusion between the two meshes, we refer to the input mesh with the scalar function + * as "parent" and the generated mesh as the "contour". + * + * The output contour mesh format can be a mint::UnstructuredMesh or Array data. + * IDs of parent cell and domain that generated the individual contour facets are provided. + * Blueprint allows users to specify ids for the domains. * - * The output contour mesh format can be a mint::UnstructuredMesh or - * Array data. IDs of parent cell and domain that generated the - * individual contour facets are provided. Blueprint allows users to - * specify ids for the domains. If "state/domain_id" exists in the - * domains, it is used as the domain id. Otherwise, the domain's - * iteration index within the multidomain mesh is used. + * If "state/domain_id" exists in the domains, it is used as the domain id. + * Otherwise, the domain's iteration index within the multidomain mesh is used. * * Output arrays use the allocator id specified in the constructor. - * However, the output mint mesh currently uses host data. The data - * output interfaces are interim and subject to change) + * However, the output mint mesh currently uses host data. + * The data output interfaces are interim and subject to change) */ class MarchingCubes { @@ -110,15 +132,16 @@ class MarchingCubes using RuntimePolicy = axom::runtime_policy::Policy; using DomainIdType = axom::IndexType; /*! - * @brief Constructor sets up runtime preferences for the marching - * cubes implementation. + * @brief Constructor sets up runtime preferences for the marching cubes implementation. * * @param [in] runtimePolicy A value from RuntimePolicy. * The simplest policy is RuntimePolicy::seq, which specifies * running sequentially on the CPU. * @param [in] allocatorID Data allocator ID. Choose something compatible * with \c runtimePolicy. See \c execution_space. - * @param [in] dataParallelism Data parallel implementation choice. + * @param [in] dataParallelism Data parallel implementation choice for the legacy backend. + * The bump backend accepts but ignores this setting because + * bump manages its own internal parallelism. */ MarchingCubes(RuntimePolicy runtimePolicy, int allocatorId, @@ -126,7 +149,7 @@ class MarchingCubes /*! * @brief Set the input mesh. - * @param [in] bpMesh Blueprint multi-domain mesh containing scalar field. + * @param [in] bpMesh Blueprint single-domain or multi-domain mesh containing scalar field. * @param [in] topologyName Name of Blueprint topology to use in \a bpMesh. * @param [in] maskField Cell-based std::int32_t mask field. If provided, * cells where this field evaluates to false are skipped. @@ -135,8 +158,8 @@ class MarchingCubes * environment specified in the constructor. It's an error if not, * e.g., using CPU memory with a GPU policy. * - * Some metadata from \a bpMesh may be cached. Any change to it - * after setMesh() leads to undefined behavior. + * Some metadata from \a bpMesh may be cached. + * Any change to it after setMesh() leads to undefined behavior. */ void setMesh(const conduit::Node& bpMesh, const std::string& topologyName, @@ -150,14 +173,46 @@ class MarchingCubes /*! * @brief Set the mask value. - * @param [in] maskVal mask value. If a mask field is given in - * setMesh(), compute only for cells whose mask matches this value. + * @param [in] maskVal mask value. If a mask field is given in setMesh(), + * compute only for cells whose mask matches this value. * * The default vask value is 1 unless explicitly set by this method. * The mask value has no effect if a mask field is not specified. */ void setMaskValue(int maskVal) { m_maskVal = maskVal; } + /*! + * @brief Select the bump::extraction::CutField backend + * (vs. the legacy structured-only marching cubes kernel). + * @param [in] useBump If true, isocontour extraction is delegated to bump, + * which additionally supports unstructured single-shape quad (2D) and hex + * (3D) meshes. If false (default), the legacy kernel is used. + * + * Only available when Axom is configured with the bump component (AXOM_USE_BUMP). + * Requesting the bump backend without bump is an error. + * The legacy backend supports only structured input. + * + * @note The MarchingCubesDataParallelism constructor argument is a legacy + * backend scan-strategy selector. The bump backend ignores it and relies on + * bump's internal parallelism for the selected runtime policy. + * + * @note This is transitional: while the bump backend matures it is opt-in so + * existing users are unaffected. A future release is expected to make it the + * default and retire the legacy kernel and its lookup tables. + */ + void setUseBumpBackend(bool useBump); + + /*! + * @brief Select the isosurface robustness policy for the bump backend. + * @param [in] policy A value from MarchingCubesRobustnessPolicy. + * + * See MarchingCubesRobustnessPolicy for the meaning of each value. The + * default is MarchingCubesRobustnessPolicy::standard. Has no effect on the + * legacy backend, and selecting \c robust currently behaves as \c standard + * until a robust bump intersector is available. + */ + void setRobustnessPolicy(MarchingCubesRobustnessPolicy policy) { m_robustnessPolicy = policy; } + /*! * @brief Computes the isocontour. * @param [in] contourVal isocontour value @@ -179,13 +234,12 @@ class MarchingCubes /*! * @brief Put generated contour in a mint::UnstructuredMesh. * @param mesh Output contour mesh - * @param cellIdField Name of field to store the array of - * parent cells ids, numbered in the row- or column-major - * ordering of the nodal scalar function. - * If empty, the data is not provided. - * @param domainIdField Name of field to store the - * parent domain ids. The type of this data is \c DomainIdType. - * If omitted, the data is not provided. + * @param cellIdField Name of field to store the array of parent cells ids, + numbered in the row- or column-major ordering of the nodal scalar function. + * If empty, the data is not provided. + * @param domainIdField Name of field to store the parent domain ids. + * The type of this data is \c DomainIdType. + * If omitted, the data is not provided. * * If the fields aren't in the mesh, they will be created. * @@ -193,11 +247,31 @@ class MarchingCubes * regardless of the allocator ID, this method always deep-copies * data to host memory. To access the data without deep-copying, see * the other output methods in this name group. + * + * When the bump backend is enabled, its native 3D CutField output may contain polygonal surface elements. + * The adaptor triangulates those polygons (reusing bump's welded vertex coordinates). */ void populateContourMesh(axom::mint::UnstructuredMesh& mesh, const std::string& cellIdField = {}, const std::string& domainIdField = {}) const; + /*! + * @brief Copy the richer bump-backed contour mesh into a Blueprint multi-domain mesh. + * @param [out] bpMesh Output Blueprint multi-domain mesh. + * @param triangulate If true, convert 3D polygonal surface elements into triangles + * while preserving bump's welded coordset. + * + * This accessor is available only for contours computed with the bump backend. + * It preserves bump's native welded representation: line segments in 2D + * and polygonal surface elements in 3D with Blueprint elements/{connectivity,sizes,offsets}. + * When \a triangulate is true, 3D polygonal faces are triangulated in the returned Blueprint mesh. + * + * Array data in \a bpMesh is copied into the same memory space used by the MarchingCubes object. + * If the contour was computed with a device policy, callers that need host-readable Blueprint data + * should copy it to host. + */ + void populateContourMeshBlueprint(conduit::Node& bpMesh, bool triangulate = false) const; + /*! * @brief Return view of facet corner node indices (connectivity) Array. * @@ -274,6 +348,17 @@ class MarchingCubes facetParentIds.swap(m_facetParentIds); facetDomainIds.swap(m_facetDomainIds); } + + /*! + * @brief Give caller possession of the richer bump-backed Blueprint contour. + * @param [out] bpMesh Output Blueprint multi-domain mesh. + * + * This moves the cached bump output nodes without deep-copying them. + * It is available only for contours computed with the bump backend + * and leaves this MarchingCubes object with no accessible contour output, + * as though clearOutput() had been called. + */ + void relinquishContourDataBlueprint(conduit::Node& bpMesh); ///@} //! @brief Clear the computed contour mesh. @@ -291,15 +376,19 @@ class MarchingCubes */ using CrossingFlagType = std::uint32_t; +private: + //! @brief Allocate output buffers corresponding to runtime policy. + void allocateOutputBuffers(); + private: RuntimePolicy m_runtimePolicy; - int m_allocatorID = axom::INVALID_ALLOCATOR_ID; + int m_allocatorID {axom::INVALID_ALLOCATOR_ID}; - //! @brief Choice of full or partial data-parallelism, or byPolicy. - MarchingCubesDataParallelism m_dataParallelism = MarchingCubesDataParallelism::byPolicy; + //! @brief Legacy backend data-parallel scan strategy, or byPolicy. + MarchingCubesDataParallelism m_dataParallelism {MarchingCubesDataParallelism::byPolicy}; //! @brief Number of domains. - axom::IndexType m_domainCount; + axom::IndexType m_domainCount {0}; /*! * @brief Single-domain implementations. @@ -307,13 +396,28 @@ class MarchingCubes * May be longer than m_domainCount (the real count). */ axom::Array> m_singles; + + /*! + * @brief Wrapper used when callers pass a single-domain Blueprint mesh. + * + * MarchingCubesSingleDomain caches references into the per-domain node, so + * the synthetic multi-domain parent must outlive setMesh(). + */ + conduit::Node m_singleDomainMesh; + std::string m_topologyName; std::string m_fcnFieldName; std::string m_fcnPath; std::string m_maskFieldName; std::string m_maskPath; - int m_maskVal = 1; + int m_maskVal {1}; + + //! @brief Whether to use the bump CutField backend (opt-in; default legacy). + bool m_useBumpBackend {false}; + + //! @brief Isosurface robustness policy for the bump backend (Phase 6 seam). + MarchingCubesRobustnessPolicy m_robustnessPolicy {MarchingCubesRobustnessPolicy::standard}; //! @brief First facet index from each parent domain. axom::Array m_facetIndexOffsets; @@ -333,6 +437,9 @@ class MarchingCubes ///@{ //!@name Generated contour mesh, shared with singles. + + axom::IndexType m_nodeCount {0}; + /*! * @brief Corners (index into m_facetNodeCoords) of generated facets. * @see allocateOutputBuffers(). @@ -345,6 +452,8 @@ class MarchingCubes */ axom::Array m_facetNodeCoords; + axom::Array m_nodeIndexOffsets; + /*! * @brief Flat index of parent cell of facets. * @see allocateOutputBuffers(). @@ -354,12 +463,8 @@ class MarchingCubes /// @brief Domain ids of facets axom::Array m_facetDomainIds; ///@} - - //! @brief Allocate output buffers corresponding to runtime policy. - void allocateOutputBuffers(); }; -} // namespace quest -} // namespace axom +} // namespace axom::quest #endif // AXOM_USE_CONDUIT diff --git a/src/axom/quest/MeshViewUtil.hpp b/src/axom/quest/MeshViewUtil.hpp index f6a2d2214f..849e0a22cf 100644 --- a/src/axom/quest/MeshViewUtil.hpp +++ b/src/axom/quest/MeshViewUtil.hpp @@ -768,12 +768,12 @@ class MeshViewUtil if(child.dtype().is_int32()) { const auto* ptr = node.fetch_existing(path).as_int32_ptr(); - return internal::makeStackArray(ptr); + return conduitIndexPointerToStackArray(ptr); } else if(child.dtype().is_int64()) { const auto* ptr = node.fetch_existing(path).as_int64_ptr(); - return internal::makeStackArray(ptr); + return conduitIndexPointerToStackArray(ptr); } else { @@ -783,6 +783,14 @@ class MeshViewUtil return internal::makeStackArray(defaultVal); } + template + MdIndices conduitIndexPointerToStackArray(const T* ptr) const + { + T hostVals[DIM]; + axom::copy(hostVals, ptr, sizeof(T) * DIM); + return internal::makeStackArray(hostVals); + } + void computeCoordsDataLayout() { const conduit::Node& topologyDims = m_ctopology->fetch_existing("elements/dims"); diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp new file mode 100644 index 0000000000..1840c50c36 --- /dev/null +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -0,0 +1,547 @@ +// Copyright (c) Lawrence Livermore National Security, LLC and other +// Axom Project Contributors. See top-level LICENSE and COPYRIGHT +// files for dates and other details. +// +// SPDX-License-Identifier: (BSD-3-Clause) + +/*! + * @file MarchingCubesBumpAdaptor.hpp + * + * @brief Adapts a bump::extraction::CutField Blueprint output mesh into + * the legacy quest::MarchingCubes fixed-stride output buffers. + * + * bump's CutField output is a welded, mixed-shape unstructured Blueprint topology: + * + * ├── topologies + * │ └── + * │ ├─• type == "unstructured" + * │ └── elements + * │ ├─• connectivity (flat, ConnectivityType) + * │ ├─• sizes (per-zone corner count) + * │ ├─• offsets (per-zone start into connectivity) + * │ └─• shapes (per-zone Blueprint ShapeID) + * ├── coordsets + * │ └── + * │ └── values (explicit, blended/welded points) + * │ ├─• x + * │ ├─• y + * │ └─• [z] + * └── fields + * └── originalElements + * └─• values (element-assoc, input zone per fragment) + * + * The legacy MarchingCubes output is composed of triangles (3D) or segments (2D): + * m_facetNodeCoords : (nodeCount, DIM) vertices of the mesh + * m_facetNodeIds : (facetCount, DIM) indices of each facet, where + * the ids index into m_facetNodeCoords and are offset by + * the domain's nodeIndexOffset (the parent concatenates domains) + * m_facetParentIds : (facetCount) parent-cell id per facet + * + * Conversion, all in ExecSpace memory: + * 1. For DIM==2: each welded segment (Line_ShapeID, size 2) is one facet. + * For DIM==3: each welded polygon of p corners fan-triangulates into (p-2) triangles (corners {0,k,k+1}). + * 2. Reuse bump's welded vertex coordinates and write only triangle/segment connectivity. + * 3. Parent id per facet := originalElements[srcZone]. + */ + +#pragma once + +#include "axom/config.hpp" + +#ifndef AXOM_USE_CONDUIT + #error "MarchingCubesBumpAdaptor.hpp requires conduit" +#endif +#ifndef AXOM_USE_BUMP + #error "MarchingCubesBumpAdaptor.hpp requires bump" +#endif + +#include "axom/core/execution/execution_space.hpp" +#include "axom/core/execution/for_all.hpp" +#include "axom/core/execution/reductions.hpp" +#include "axom/core/memory_management.hpp" +#include "axom/core/Array.hpp" +#include "axom/core/ArrayView.hpp" +#include "axom/core/numerics/floating_point_limits.hpp" +#include "axom/slic/interface/slic_macros.hpp" + +#include "axom/bump/utilities/blueprint_utilities.hpp" +#include "axom/bump/utilities/conduit_memory.hpp" +#include "axom/bump/views/NodeArrayView.hpp" +#include "axom/bump/views/Shapes.hpp" + +#include "conduit_node.hpp" + +#include +#include +#include + +namespace axom::quest::detail::marching_cubes +{ +/*! + * @brief Private name for the parent-zone field requested from bump. + * + * Not bump's default: TableBasedExtractor::makeOriginalElements branches on + * whether the input mesh already carries a field of the configured name + * and, if so, maps those values forward instead of writing zone indices. + * Any mesh produced by a prior bump operation contains that field, + * and the empty "fields" option does not suppress the branch, + * so a plausible input silently redefines what a parent cell id means. + */ +constexpr const char* kOriginalElementsField = "__axom_mc_originalElements"; + +/*! + * @brief Name the parent-zone field carries on the PUBLIC Blueprint output. + * + * populateContourMeshBlueprint() hands the mesh to the caller and + * quest_marching_cubes_bump.cpp asserts on this name, so it is an API contract. + * bump's output is renamed from the private request name to this immediately + * after extraction, keeping the private name confined to the request. + */ +constexpr const char* kPublicOriginalElementsField = "originalElements"; + +/*! + * @brief Number of legacy facets a bump zone of \a nCorners contributes. + * + * 2D: segment -> 1. + * 3D: p-gon fans into (p-2) triangles (0 if degenerate). + */ +template +AXOM_HOST_DEVICE inline axom::IndexType facetsPerZone(axom::IndexType nCorners) +{ + if constexpr(DIM == 3) + { + return nCorners >= 3 ? (nCorners - 2) : 0; + } + // DIM == 2: a line segment. + return nCorners >= 2 ? 1 : 0; +} + +template +void duplicateElementValuesForTriangulationViews(InValuesView inValues, + OutValuesView outValues, + CountsView zoneFacetCounts, + OffsetsView zoneFacetOffsets) +{ + const axom::IndexType inputZoneCount = static_cast(inValues.size()); + axom::for_all( + inputZoneCount, + AXOM_LAMBDA(axom::IndexType z) { + const axom::IndexType outBegin = zoneFacetOffsets[z]; + const axom::IndexType nOut = zoneFacetCounts[z]; + for(axom::IndexType f = 0; f < nOut; ++f) + { + outValues[outBegin + f] = inValues[z]; + } + }); +} + +template +void duplicateElementValuesForTriangulation(conduit::Node& n_values, + axom::IndexType outputZoneCount, + CountsView zoneFacetCounts, + OffsetsView zoneFacetOffsets, + int allocatorID) +{ + namespace bpviews = axom::bump::views; + + if(n_values.number_of_children() > 0) + { + for(conduit::index_t i = 0; i < n_values.number_of_children(); ++i) + { + duplicateElementValuesForTriangulation(n_values[i], + outputZoneCount, + zoneFacetCounts, + zoneFacetOffsets, + allocatorID); + } + return; + } + + conduit::Node newValues; + newValues.set_allocator(axom::sidre::ConduitMemory::axomAllocIdToConduit(allocatorID)); + newValues.set(conduit::DataType(n_values.dtype().id(), outputZoneCount)); + + bpviews::nodeToArrayViewSame(n_values, newValues, [&](auto inValues, auto outValues) { + duplicateElementValuesForTriangulationViews(inValues, + outValues, + zoneFacetCounts, + zoneFacetOffsets); + }); + + n_values.move(newValues); +} + +template +void triangulateBlueprintMeshViews(conduit::Node& n_output, + conduit::Node& n_conn, + conduit::Node& n_sizes, + conduit::Node& n_offsets, + const std::string& topologyName, + SizesView sizesView, + OffsetsView offsetsView, + ConnView connView, + int allocatorID) +{ + namespace bputils = axom::bump::utilities; + namespace bpviews = axom::bump::views; + + using ConnectivityType = typename std::decay_t::value_type; + + const axom::IndexType inputZoneCount = static_cast(sizesView.size()); + if(inputZoneCount == 0) + { + return; + } + + axom::Array zoneFacetCounts(inputZoneCount, inputZoneCount, allocatorID); + auto zoneFacetCountsView = zoneFacetCounts.view(); + + axom::ReduceSum totalFacetsReduce(0); + axom::ReduceSum nonTriReduce(0); + axom::for_all( + inputZoneCount, + AXOM_LAMBDA(axom::IndexType z) { + const auto nCorners = static_cast(sizesView[z]); + const auto nFacets = facetsPerZone<3>(nCorners); + zoneFacetCountsView[z] = nFacets; + totalFacetsReduce += nFacets; + nonTriReduce += (nCorners == 3) ? 0 : 1; + }); + + const axom::IndexType outputZoneCount = totalFacetsReduce.get(); + if(nonTriReduce.get() == 0) + { + return; + } + + axom::Array zoneFacetOffsets(inputZoneCount, inputZoneCount, allocatorID); + auto zoneFacetOffsetsView = zoneFacetOffsets.view(); + axom::exclusive_scan(zoneFacetCountsView, zoneFacetOffsetsView); + + const auto conduitAllocatorID = axom::sidre::ConduitMemory::axomAllocIdToConduit(allocatorID); + conduit::Node newConn; + conduit::Node newSizes; + conduit::Node newOffsets; + conduit::Node newShapes; + newConn.set_allocator(conduitAllocatorID); + newSizes.set_allocator(conduitAllocatorID); + newOffsets.set_allocator(conduitAllocatorID); + newShapes.set_allocator(conduitAllocatorID); + newConn.set(conduit::DataType(n_conn.dtype().id(), outputZoneCount * 3)); + newSizes.set(conduit::DataType(n_sizes.dtype().id(), outputZoneCount)); + newOffsets.set(conduit::DataType(n_offsets.dtype().id(), outputZoneCount)); + newShapes.set(conduit::DataType(n_sizes.dtype().id(), outputZoneCount)); + + auto newConnView = bputils::make_array_view(newConn); + auto newSizesView = bputils::make_array_view(newSizes); + auto newOffsetsView = bputils::make_array_view(newOffsets); + auto newShapesView = bputils::make_array_view(newShapes); + + axom::for_all( + inputZoneCount, + AXOM_LAMBDA(axom::IndexType z) { + const axom::IndexType nFacets = zoneFacetCountsView[z]; + const axom::IndexType connStart = static_cast(offsetsView[z]); + const axom::IndexType triStart = zoneFacetOffsetsView[z]; + + for(axom::IndexType f = 0; f < nFacets; ++f) + { + const axom::IndexType tri = triStart + f; + const axom::IndexType outConn = tri * 3; + newConnView[outConn + 0] = connView[connStart + 0]; + newConnView[outConn + 1] = connView[connStart + f + 1]; + newConnView[outConn + 2] = connView[connStart + f + 2]; + newSizesView[tri] = static_cast(3); + newOffsetsView[tri] = static_cast(outConn); + newShapesView[tri] = static_cast(bpviews::Tri_ShapeID); + } + }); + + if(n_output.has_child("fields")) + { + conduit::Node& n_fields = n_output["fields"]; + for(conduit::index_t i = 0; i < n_fields.number_of_children(); ++i) + { + conduit::Node& n_field = n_fields[i]; + if(n_field.has_path("association") && n_field["association"].as_string() == "element" && + n_field.has_path("topology") && n_field["topology"].as_string() == topologyName && + n_field.has_child("values")) + { + duplicateElementValuesForTriangulation(n_field["values"], + outputZoneCount, + zoneFacetCountsView, + zoneFacetOffsetsView, + allocatorID); + } + } + } + + n_conn.move(newConn); + n_sizes.move(newSizes); + n_offsets.move(newOffsets); + conduit::Node& n_topo = n_output["topologies"][topologyName]; + conduit::Node& n_elems = n_topo.fetch_existing("elements"); + n_elems["shapes"].move(newShapes); + n_elems["shape_map"].reset(); + n_elems["shape_map"][bpviews::TriTraits::name()] = bpviews::Tri_ShapeID; +} + +/*! + * @brief Convert a bump CutField Blueprint domain from polygonal surface + * elements to a welded triangle mesh in place. + * + * This rewrites only topology connectivity and element-associated fields. The + * coordset is left untouched, and generated triangles reference the existing + * welded vertex ids. + */ +template +void triangulateBlueprintMesh(conduit::Node& n_output, int allocatorID) +{ + if constexpr(DIM != 3) + { + return; + } + + namespace bputils = axom::bump::utilities; + namespace bpviews = axom::bump::views; + + if(!n_output.has_child("topologies")) + { + return; // empty contour (isovalue outside the data range): nothing to triangulate + } + const conduit::Node& n_topos = n_output.fetch_existing("topologies"); + SLIC_ASSERT(n_topos.number_of_children() == 1); + const std::string topologyName = n_topos.child(0).name(); + conduit::Node& n_topo = n_output["topologies"][topologyName]; + conduit::Node& n_elems = n_topo.fetch_existing("elements"); + + conduit::Node& n_conn = n_elems.fetch_existing("connectivity"); + conduit::Node& n_sizes = n_elems.fetch_existing("sizes"); + conduit::Node& n_offsets = n_elems.fetch_existing("offsets"); + + SLIC_ERROR_IF( + n_offsets.dtype().id() != n_sizes.dtype().id() || n_conn.dtype().id() != n_sizes.dtype().id(), + "MarchingCubes bump Blueprint triangulation expects connectivity, " + "sizes, and offsets to use the same integer type."); + + auto triangulateViews = [&](auto sizesView, auto offsetsView, auto connView) { + triangulateBlueprintMeshViews(n_output, + n_conn, + n_sizes, + n_offsets, + topologyName, + sizesView, + offsetsView, + connView, + allocatorID); + }; + +#if defined(_WIN32) + triangulateViews(bputils::make_array_view(n_sizes), + bputils::make_array_view(n_offsets), + bputils::make_array_view(n_conn)); +#else + bpviews::indexNodeToArrayViewSame(n_sizes, n_offsets, n_conn, std::move(triangulateViews)); +#endif +} + +template +void adaptCutFieldOutputViews(const conduit::Node& n_coords, + SizesView sizesView, + OffsetsView offsetsView, + ConnView connView, + OrigView origView, + axom::ArrayView facetNodeIds, + axom::ArrayView facetNodeCoords, + axom::ArrayView facetParentIds, + axom::IndexType facetIndexOffset, + axom::IndexType nodeIndexOffset, + int objectAllocatorID) +{ + namespace bputils = axom::bump::utilities; + + const conduit::Node& n_x = n_coords.fetch_existing("values/x"); + const conduit::Node& n_y = n_coords.fetch_existing("values/y"); + auto xView = bputils::make_array_view(n_x); + auto yView = bputils::make_array_view(n_y); + // z only in 3D. + axom::ArrayView zView; + if constexpr(DIM == 3) + { + const conduit::Node& n_z = n_coords.fetch_existing("values/z"); + zView = bputils::make_array_view(n_z); + } + + const axom::IndexType numZones = static_cast(sizesView.size()); + const axom::IndexType numNodes = static_cast(xView.size()); + + axom::for_all( + numNodes, + AXOM_LAMBDA(axom::IndexType n) { + facetNodeCoords(nodeIndexOffset + n, 0) = xView[n]; + facetNodeCoords(nodeIndexOffset + n, 1) = yView[n]; + // Avoid first-capture in constexpr-if context error + (void)zView; + if constexpr(DIM == 3) + { + facetNodeCoords(nodeIndexOffset + n, 2) = zView[n]; + } + }); + + // --- Per-zone facet offset (exclusive scan of facetsPerZone) ----------- + // We need, for each bump zone, the index of its first facet within this + // domain so kernels can write without atomics. + // Use the object's allocator, not the execution space default. + const int allocatorID = objectAllocatorID; + axom::Array zoneFacetCounts(numZones, numZones, allocatorID); + auto zoneFacetCountsView = zoneFacetCounts.view(); + axom::for_all( + numZones, + AXOM_LAMBDA(axom::IndexType z) { + zoneFacetCountsView[z] = facetsPerZone(static_cast(sizesView[z])); + }); + + axom::Array zoneFacetOffsets(numZones, numZones, allocatorID); + auto zoneFacetOffsetsView = zoneFacetOffsets.view(); + axom::exclusive_scan(zoneFacetCountsView, zoneFacetOffsetsView); + + // --- The fan-triangulation kernel ------------------------------------- + // One thread per bump zone. Each zone writes facetsPerZone facets; + // each facet reuses bump's welded coordset vertex ids. + axom::for_all( + numZones, + AXOM_LAMBDA(axom::IndexType z) { + const axom::IndexType nCorners = static_cast(sizesView[z]); + const axom::IndexType nFacets = facetsPerZone(nCorners); + if(nFacets == 0) + { + return; + } + const axom::IndexType connStart = static_cast(offsetsView[z]); + + // Parent-cell id for every facet of this zone. + const axom::IndexType parentId = static_cast(origView[z]); + + // This zone's first facet within the whole concatenated output. + const axom::IndexType facetBase = facetIndexOffset + zoneFacetOffsetsView[z]; + + for(axom::IndexType f = 0; f < nFacets; ++f) + { + const axom::IndexType facetIdx = facetBase + f; + + // Local corner indices of this facet within the zone. + // DIM==2: the segment endpoints {0,1} + // DIM==3: fan triangle {0, f+1, f+2} + axom::IndexType local[DIM]; + if constexpr(DIM == 3) + { + local[0] = 0; + local[1] = f + 1; + local[2] = f + 2; + } + else + { + local[0] = 0; + local[1] = 1; + } + + for(int c = 0; c < DIM; ++c) + { + const axom::IndexType weldedNode = + static_cast(connView[connStart + local[c]]); + facetNodeIds(facetIdx, c) = nodeIndexOffset + weldedNode; + } + + facetParentIds[facetIdx] = parentId; + } + }); +} + +/*! + * @brief Convert one bump CutField output (single domain) into the + * fixed-corners-per-facet output buffers supplied by the parent MarchingCubes. + * + * @tparam DIM Spatial dimension (2 or 3). + * @tparam ExecSpace Axom execution space. + * + * @param n_output The bump CutField output Blueprint mesh (in ExecSpace memory). + * @param facetNodeIds [out] view, shape (totalFacetCount, DIM). + * @param facetNodeCoords [out] view, shape (totalNodeCount, DIM). + * @param facetParentIds [out] view, shape (totalFacetCount). + * @param facetIndexOffset This domain's first facet index in the concatenated + * output (the parent's m_facetIndexOffsets[d]). + * @param nodeIndexOffset This domain's first node index in the concatenated output. + * @param thisDomainFacetCount Number of facets this domain produces (already + * computed by the caller; equals sum of facetsPerZone over the bump zones). + * @param objectAllocatorID Allocator used for temporary arrays. + * + * @pre All output views and \a n_output live in ExecSpace's memory space. + */ +template +void adaptCutFieldOutput(const conduit::Node& n_output, + axom::ArrayView facetNodeIds, + axom::ArrayView facetNodeCoords, + axom::ArrayView facetParentIds, + axom::IndexType facetIndexOffset, + axom::IndexType nodeIndexOffset, + axom::IndexType thisDomainFacetCount, + int objectAllocatorID) +{ + namespace bputils = axom::bump::utilities; + namespace bpviews = axom::bump::views; + + if(thisDomainFacetCount == 0) + { + return; + } + + // --- Locate the single output topology + coordset ------------------------ + const conduit::Node& n_topos = n_output.fetch_existing("topologies"); + SLIC_ASSERT(n_topos.number_of_children() == 1); + const conduit::Node& n_topo = n_topos.child(0); + + // bump always emits explicit sizes/offsets/connectivity for cut output. + const conduit::Node& n_elems = n_topo.fetch_existing("elements"); + const conduit::Node& n_conn = n_elems.fetch_existing("connectivity"); + const conduit::Node& n_sizes = n_elems.fetch_existing("sizes"); + const conduit::Node& n_offsets = n_elems.fetch_existing("offsets"); + + const std::string coordsetName = n_topo.fetch_existing("coordset").as_string(); + const conduit::Node& n_coords = + n_output.fetch_existing(axom::fmt::format("coordsets/{}", coordsetName)); + + // originalElements: element-associated, one entry per output zone (fragment). + const conduit::Node& n_orig = + n_output.fetch_existing(axom::fmt::format("fields/{}/values", kPublicOriginalElementsField)); + + SLIC_ERROR_IF(n_offsets.dtype().id() != n_sizes.dtype().id() || + n_conn.dtype().id() != n_sizes.dtype().id() || + n_orig.dtype().id() != n_sizes.dtype().id(), + "MarchingCubes bump adaptor expects connectivity, sizes, " + "offsets, and originalElements to use the same integer type."); + + auto adaptViews = [&](auto sizesView, auto offsetsView, auto connView, auto origView) { + adaptCutFieldOutputViews(n_coords, + sizesView, + offsetsView, + connView, + origView, + facetNodeIds, + facetNodeCoords, + facetParentIds, + facetIndexOffset, + nodeIndexOffset, + objectAllocatorID); + }; + +#if defined(_WIN32) + adaptViews(bputils::make_array_view(n_sizes), + bputils::make_array_view(n_offsets), + bputils::make_array_view(n_conn), + bputils::make_array_view(n_orig)); +#else + bpviews::indexNodeToArrayViewSame(n_sizes, n_offsets, n_conn, n_orig, std::move(adaptViews)); +#endif +} + +} // namespace axom::quest::detail::marching_cubes diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp new file mode 100644 index 0000000000..3dfdae0ddd --- /dev/null +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -0,0 +1,1249 @@ +// Copyright (c) Lawrence Livermore National Security, LLC and other +// Axom Project Contributors. See top-level LICENSE and COPYRIGHT +// files for dates and other details. +// +// SPDX-License-Identifier: (BSD-3-Clause) + +/*! + * @file MarchingCubesBumpImpl.hpp + * + * @brief A MarchingCubesSingleDomain::ImplBase implementation + * that delegates isocontour extraction to axom::bump::extraction::CutField. + * + * Unlike the legacy MarchingCubesImpl (which contains a hand-written marching cubes kernel over structured data), + * this implementation wraps the bump CutField extractor. + * CutField is templated on , so it transparently supports: + * - structured (uniform / rectilinear / explicit-structured) topologies, and + * - unstructured *single-shape* quad (2D) and hex (3D) topologies, + * on all execution spaces (seq, omp, cuda, hip), via the bump view dispatch. + * + * Design notes: + * - CutField is a Blueprint-in / Blueprint-out operation. + * We run it once per domain in computeFacets(); + * markCrossings()/scanCrossings() do the cheap bookkeeping the parent MarchingCubes orchestration expects. + * - The phased ImplBase interface (mark/scan/compute) was designed around the legacy kernel. + * bump does everything in one execute() call, so we run the extractor lazily and cache its result, + * then satisfy the count queries from the cached result. + * - bump produces a welded, topologically-connected surface (blend-group uniquification). + * The 3D output may be polygonal (tri/quad/poly5..8), and the 2D output is line segments. + * The adaptor can optionally triangulate the polygon. + */ + +#pragma once + +#include "axom/config.hpp" + +#ifndef AXOM_USE_CONDUIT + #error "MarchingCubesBumpImpl.hpp requires conduit" +#endif +#ifndef AXOM_USE_BUMP + #error "MarchingCubesBumpImpl.hpp requires bump" +#endif + +#include "axom/core/execution/execution_space.hpp" +#include "axom/core/execution/for_all.hpp" +#include "axom/core/execution/reductions.hpp" +#include "axom/core/MDMapping.hpp" +#include "axom/slic/interface/slic_macros.hpp" +#include "axom/quest/MeshViewUtil.hpp" +#include "axom/quest/detail/MarchingCubesSingleDomain.hpp" +#include "axom/quest/detail/MarchingCubesBumpAdaptor.hpp" + +// bump extraction + views +#include "axom/bump/extraction/CutField.hpp" +#include "axom/bump/extraction/FieldIntersector.hpp" +#include "axom/bump/SelectedZones.hpp" +#include "axom/bump/views/NodeArrayView.hpp" +#include "axom/bump/views/dispatch_coordset.hpp" +#include "axom/bump/views/dispatch_topology.hpp" +#include "axom/bump/views/Shapes.hpp" +#include "axom/bump/utilities/blueprint_utilities.hpp" +#include "axom/bump/utilities/conduit_traits.hpp" +#include "axom/bump/utilities/conduit_memory.hpp" + +#include "conduit_node.hpp" +#include "conduit_blueprint.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace axom::quest::detail::marching_cubes +{ +template +axom::IndexType computeTriangulatedFacetCountView(SizesView sizes) +{ + const axom::IndexType n = static_cast(sizes.size()); + axom::ReduceSum facetCount(0); + axom::for_all( + n, + AXOM_LAMBDA(axom::IndexType i) { + const auto p = static_cast(sizes[i]); + facetCount += (DIM == 3) ? (p >= 3 ? p - 2 : 0) : (p >= 2 ? 1 : 0); + }); + return facetCount.get(); +} + +/*! + * @brief Bump-backed single-domain marching cubes implementation. + * + * @tparam DIM Spatial dimension (2 or 3). + * @tparam ExecSpace Axom execution space (SEQ_EXEC, OMP_EXEC, CUDA_EXEC<>, HIP_EXEC<>). + * + * This object holds a reference to a single Blueprint domain and, on + * computeFacets(), invokes bump's CutField extractor to produce the isocontour. + * It then adapts the bump Blueprint output into the legacy output buffers + * supplied by the parent MarchingCubes via setOutputBuffers(). + */ +template +class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase +{ +public: + static constexpr auto MemorySpace = execution_space::memory_space; + static constexpr int SelectedDimensions = axom::bump::views::select_dimensions(DIM); + static constexpr int ShapeTypes = + (DIM == 3) ? (1 << axom::bump::views::Hex_ShapeID) : (1 << axom::bump::views::Quad_ShapeID); + + MarchingCubesBumpImpl(int allocatorID) : m_allocatorID(allocatorID) { } + + /*! + * @brief Cache the domain and topology/mask names. + * + * We do not build views here because CutField needs the field name and + * contour value too; we defer all heavy work to computeFacets(). + */ + void setDomain(const conduit::Node& dom, + const std::string& topologyName, + const std::string& maskFieldName) override + { + m_dom = &dom; + m_topologyName = topologyName; + m_maskFieldName = maskFieldName; + + if(!m_maskFieldName.empty()) + { + const conduit::Node& n_mask = + dom.fetch_existing(axom::fmt::format("fields/{}", m_maskFieldName)); + SLIC_ERROR_IF(n_mask.fetch_existing("association").as_string() != "element", + "MarchingCubes mask fields must be element-associated."); + SLIC_ERROR_IF(!n_mask.has_path("values"), + "MarchingCubes mask field is missing a values node."); + SLIC_ERROR_IF(!n_mask.fetch_existing("values").dtype().is_int32(), + "MarchingCubes mask field values must be int32."); + } + + // Validate that this is a topology bump+MarchingCubes supports: a DIM-dimensional structured topology, + // or an unstructured single-shape quad (DIM==2) / hex (DIM==3) topology. Mixed/polyhedral are rejected here. + const conduit::Node& n_topo = + dom.fetch_existing(axom::fmt::format("topologies/{}", topologyName)); + const std::string topoType = n_topo.fetch_existing("type").as_string(); + + // MeshViewUtil::isValid() requires both a "structured" topology and an explicit coordset + // Gating those paths on m_isStructured meant a uniform/rectilinear mesh passed setDomain()'s validation, + // and then hard-errored deep inside the crossing pre-filter. + // bump handles uniform and rectilinear, so use bump's views rather than MeshViewUtil. + + const std::string coordsetTypeForPath = + dom + .fetch_existing( + axom::fmt::format("coordsets/{}", n_topo.fetch_existing("coordset").as_string())) + .fetch_existing("type") + .as_string(); + m_useMeshViewUtilPath = (topoType == "structured") && (coordsetTypeForPath == "explicit"); + + // Strided-structured (ghost-padded) input needs no special handling here. + // adaptCutFieldOutputViews() reads bump's blended output coordset with make_array_view, + // which errors late and opaquely on a float32 coordset. + // Catch it here instead, where the path can be named. + const std::string csPath = + axom::fmt::format("coordsets/{}/values", n_topo.fetch_existing("coordset").as_string()); + for(const char* comp : {"x", "y", "z"}) + { + validateFieldIsFloat64(axom::fmt::format("{}/{}", csPath, comp), "coordset component"); + } + if(!m_fcnFieldName.empty()) + { + validateFieldIsFloat64(axom::fmt::format("fields/{}/values", m_fcnFieldName), + "function field"); + validateFieldStrideOrder(axom::fmt::format("fields/{}", m_fcnFieldName)); + } + if(topoType == "unstructured") + { + const std::string shape = n_topo.fetch_existing("elements/shape").as_string(); + const char* expected = (DIM == 3) ? "hex" : "quad"; + SLIC_ERROR_IF(shape != expected, + axom::fmt::format("MarchingCubes (bump backend) supports unstructured " + "single-shape '{}' in {}D, but got shape '{}'.", + expected, + DIM, + shape)); + } + else + { + SLIC_ERROR_IF( + topoType != "uniform" && topoType != "rectilinear" && topoType != "structured", + axom::fmt::format("MarchingCubes (bump backend) does not support topology type '{}'.", + topoType)); + } + } + + /*! + * @brief Set the nodal function field, validating its type. + * + * The structured pre-filter reads the field through MeshViewUtil::getConstFieldView(), + * which assumes that the values are `double`. Check the type here, and provide an error + * message with the actual type when necessary. + */ + void setFunctionField(const std::string& fcnFieldName) override + { + m_fcnFieldName = fcnFieldName; + if(m_dom != nullptr && !m_fcnFieldName.empty()) + { + validateFieldIsFloat64(axom::fmt::format("fields/{}/values", m_fcnFieldName), + "function field"); + validateFieldStrideOrder(axom::fmt::format("fields/{}", m_fcnFieldName)); + } + } + + /*! + * @brief Validate the function field layout used by bump's flat field view. + * + * bump's FieldIntersector reads the field with a flat make_array_view, + * ignoring the field's Blueprint offsets/strides. That is only correct for a compact i-fastest layout, + * or a ghost-padded i-fastest layout whose offsets and strides match the structured topology. + * Reject independently strided fields and topology arrays cases. + * + * i-fastest is characterised by strides[0] == 1 and non-decreasing strides. + * + * @note Uniform, rectilinear, and unstructured topologies use compact node + * numbering, so field offsets/strides are not supported on those paths. + */ + void validateFieldStrideOrder(const std::string& fieldPath) const + { + if(m_dom == nullptr || !m_dom->has_path(fieldPath)) + { + return; + } + const conduit::Node& n_field = m_dom->fetch_existing(fieldPath); + const bool hasFieldOffsets = n_field.has_child("offsets"); + const bool hasFieldStrides = n_field.has_child("strides"); + + const conduit::Node& n_topo = + m_dom->fetch_existing(axom::fmt::format("topologies/{}", m_topologyName)); + const bool hasTopoOffsets = n_topo.has_path("elements/dims/offsets"); + const bool hasTopoStrides = n_topo.has_path("elements/dims/strides"); + + if(!hasFieldOffsets && !hasFieldStrides && !hasTopoOffsets && !hasTopoStrides) + { + return; + } + if(!m_useMeshViewUtilPath) + { + SLIC_ERROR(axom::fmt::format( + "MarchingCubes (bump backend) does not support function-field offsets/strides " + "on topology '{}'; bump indexes this field with compact topology node ids.", + m_topologyName)); + return; + } + + axom::quest::MeshViewUtil mvu(*m_dom, m_topologyName); + const auto nodeShape = mvu.getNodeShape(); + axom::StackArray fieldOffsets {}; + axom::StackArray topoOffsets {}; + axom::StackArray fieldStrides {}; + axom::StackArray topoStrides {}; + + axom::IndexType compactStride = 1; + for(int d = 0; d < DIM; ++d) + { + fieldStrides[d] = compactStride; + topoStrides[d] = compactStride; + compactStride *= nodeShape[d]; + } + + auto readMetadata = [](const conduit::Node& node, const std::string& path, auto& values) { + if(!node.has_path(path)) + { + return true; + } + const conduit::Node& metadata = node.fetch_existing(path); + if(metadata.dtype().number_of_elements() != DIM) + { + SLIC_ERROR(axom::fmt::format("MarchingCubes metadata '{}' has {} values; expected {}.", + path, + metadata.dtype().number_of_elements(), + DIM)); + return false; + } + // The input mesh can reside in device memory. Copy its small metadata + // array to the host before inspecting it. + axom::bump::utilities::fillFromNode(node, path, values, true); + return true; + }; + + if(!readMetadata(n_field, "offsets", fieldOffsets) || + !readMetadata(n_field, "strides", fieldStrides) || + !readMetadata(n_topo, "elements/dims/offsets", topoOffsets) || + !readMetadata(n_topo, "elements/dims/strides", topoStrides)) + { + return; + } + + bool iFastest = fieldStrides[0] == 1; + for(int d = 1; d < DIM; ++d) + { + iFastest = iFastest && (fieldStrides[d] >= fieldStrides[d - 1]); + } + if(!iFastest) + { + SLIC_ERROR( + axom::fmt::format("MarchingCubes (bump backend) requires an i-fastest function field: bump " + "reads field values as a flat array and does not honor Blueprint field " + "strides, so a permuted layout is silently transposed. Field '{}' has " + "strides that are not i-fastest.", + fieldPath)); + return; + } + + SLIC_ERROR_IF( + fieldOffsets != topoOffsets || fieldStrides != topoStrides, + axom::fmt::format("MarchingCubes (bump backend) requires function field '{}' to use the " + "same offsets and strides as its structured topology. bump indexes " + "field values directly with topology node ids.", + fieldPath)); + } + + //! @brief Require a float64 Blueprint array, naming the offending type if not. + void validateFieldIsFloat64(const std::string& path, const std::string& what) const + { + if(m_dom == nullptr || !m_dom->has_path(path)) + { + return; // absence is reported elsewhere, with a better message + } + const conduit::Node& n = m_dom->fetch_existing(path); + SLIC_ERROR_IF(!n.dtype().is_float64(), + axom::fmt::format("MarchingCubes (bump backend) requires a float64 {} at '{}', " + "but found '{}'.", + what, + path, + n.dtype().name())); + } + + void setContourValue(double contourVal) override { m_contourVal = contourVal; } + + void setMaskValue(int maskVal) override { m_maskVal = maskVal; } + + /*! + * @brief Record the requested robustness policy (Phase 6 seam). + * + * Currently advisory: `standard` and `robust` both run bump's default intersector + tables. + * When a robust (double-precision, +/-/0-aware) intersector is available, + * runExtraction() will select it for `robust` with no change to the calling code. + */ + void setRobustnessPolicy(MarchingCubesRobustnessPolicy policy) override + { + m_robustnessPolicy = policy; + } + + // The data-parallelism knob is a legacy-kernel concept; bump manages its own + // parallelism. We accept and ignore it (kept for API compatibility). + void setDataParallelism(MarchingCubesDataParallelism dataPar) override + { + m_dataParallelism = dataPar; + } + + // ---- Phased interface (mark/scan/compute) ------------------------------- + // bump does extraction in a single execute() call. + // We run it lazily in runExtraction() and have the phase methods drive/observe that. + + //! @brief No-op for the bump backend (extraction is deferred). + void markCrossings() override { /* no-op: deferred to computeFacets */ } + + /*! + * @brief Run the bump extraction so the facet count is known. + * + * The parent MarchingCubes allocates the shared output buffers after the scan phase + * (it needs per-domain counts to size them) and before the compute phase. + * + * bump cannot give us a count without doing the full extraction, so we perform extraction here and cache the result. + * The count then becomes available to the parent, and computeFacets() copies cached data into the buffers the parent allocated. + */ + void scanCrossings() override + { + m_extractionRan = true; + runExtraction(); + } + + //! @brief Copy cached bump output into the parent-allocated output buffers. + void computeFacets() override { fillLegacyOutputBuffers(); } + + axom::IndexType getContourCellCount() const override { return m_facetCount; } + + axom::IndexType getContourNodeCount() const override + { + if(m_facetCount == 0) + { + return 0; + } + + SLIC_ASSERT(m_output != nullptr); + const conduit::Node& n_topos = m_output->fetch_existing("topologies"); + SLIC_ASSERT(n_topos.number_of_children() == 1); + const conduit::Node& n_topo = n_topos.child(0); + const std::string coordsetName = n_topo.fetch_existing("coordset").as_string(); + const conduit::Node& n_coords = + m_output->fetch_existing(axom::fmt::format("coordsets/{}", coordsetName)); + return static_cast( + n_coords.fetch_existing("values/x").dtype().number_of_elements()); + } + + /*! + * @brief Whether a Blueprint contour can be produced. + * + * True once computeIsocontour() has run, even if the contour is empty. + */ + bool hasContourMeshBlueprint() const override { return m_extractionRan; } + + void copyContourMeshBlueprint(conduit::Node& bpMesh, bool triangulate) const override + { + SLIC_ERROR_IF(!m_extractionRan, + "MarchingCubes bump backend has no Blueprint contour output. " + "Call computeIsocontour() before requesting it."); + if(m_output == nullptr) + { + bpMesh.reset(); + return; + } + axom::bump::utilities::copy(bpMesh, *m_output, m_allocatorID); + if(triangulate) + { + triangulateBlueprintMesh(bpMesh, m_allocatorID); + } + } + + void relinquishContourMeshBlueprint(conduit::Node& bpMesh) override + { + SLIC_ERROR_IF(!m_extractionRan, + "MarchingCubes bump backend has no Blueprint contour output. " + "Call computeIsocontour() before requesting it."); + bpMesh.reset(); + if(m_output != nullptr) + { + bpMesh.swap(*m_output); + m_output.reset(); + } + m_facetCount = 0; + m_extractionRan = false; + } + + void clearDomain() override + { + m_output.reset(); + m_facetCount = 0; + m_extractionRan = false; + } + +#if !defined(__CUDACC__) +private: +#endif + /*! + * @brief Convert the requested isovalue to bump's field type while preserving + * the legacy backend's greater-than-or-equal corner classification. + */ + template + static FieldType isoValueForBump(double contourVal) + { + const auto value = static_cast(contourVal); + return std::nextafter(value, -std::numeric_limits::infinity()); + } + + /*! @brief Dispatch a coordset view restricted to this implementation's DIM. */ + template + static void dispatchCoordset(const conduit::Node& n_coords, FuncType&& func) + { + namespace bumpviews = axom::bump::views; + + const std::string cstype = n_coords.fetch_existing("type").as_string(); + if(cstype == "uniform") + { + auto coordsetView = bumpviews::make_uniform_coordset::view(n_coords); + func(coordsetView); + } + else if(cstype == "rectilinear") + { + const conduit::Node& values = n_coords.fetch_existing("values"); + if constexpr(DIM == 2) + { + SLIC_ERROR_IF(values.number_of_children() != 2, + "2D rectilinear coordsets require 2 component arrays."); + bumpviews::floatNodeToArrayViewSame(values[0], values[1], [&](auto xView, auto yView) { + bumpviews::RectilinearCoordsetView2 coordsetView( + xView, + yView); + func(coordsetView); + }); + } + else + { + SLIC_ERROR_IF(values.number_of_children() != 3, + "3D rectilinear coordsets require 3 component arrays."); + bumpviews::floatNodeToArrayViewSame( + values[0], + values[1], + values[2], + [&](auto xView, auto yView, auto zView) { + bumpviews::RectilinearCoordsetView3 coordsetView( + xView, + yView, + zView); + func(coordsetView); + }); + } + } + else if(cstype == "explicit") + { + const conduit::Node& values = n_coords.fetch_existing("values"); + if constexpr(DIM == 2) + { + SLIC_ERROR_IF(values.number_of_children() != 2, + "2D explicit coordsets require 2 component arrays."); + bumpviews::floatNodeToArrayViewSame(values[0], values[1], [&](auto xView, auto yView) { + bumpviews::ExplicitCoordsetView coordsetView( + xView, + yView); + func(coordsetView); + }); + } + else + { + SLIC_ERROR_IF(values.number_of_children() != 3, + "3D explicit coordsets require 3 component arrays."); + bumpviews::floatNodeToArrayViewSame( + values[0], + values[1], + values[2], + [&](auto xView, auto yView, auto zView) { + bumpviews::ExplicitCoordsetView coordsetView( + xView, + yView, + zView); + func(coordsetView); + }); + } + } + else + { + SLIC_ERROR(axom::fmt::format("Unsupported coordset type '{}'.", cstype)); + } + } + + /*! @brief Dispatch a topology view restricted to MarchingCubes-supported shapes. */ + template + static void dispatchTopology(const conduit::Node& n_topo, FuncType&& func) + { + namespace bumpviews = axom::bump::views; + +#if defined(_WIN32) + // Windows shared-library builds auto-export template instantiations from + // axom_quest.dll. Keep this opt-in bump path narrow enough to link there, + // while preserving the generic bump dispatcher on other platforms. + const std::string topoType = n_topo.fetch_existing("type").as_string(); + if(topoType == "unstructured") + { + const std::string shape = n_topo.fetch_existing("elements/shape").as_string(); + if constexpr(DIM == 3) + { + SLIC_ERROR_IF(shape != "hex", + axom::fmt::format("MarchingCubes bump backend expected " + "unstructured hex topology, but got '{}'.", + shape)); + using ShapeType = bumpviews::HexShape; + auto topologyView = + bumpviews::make_unstructured_single_shape_topology::view(n_topo); + func(shape, topologyView); + } + else + { + SLIC_ERROR_IF(shape != "quad", + axom::fmt::format("MarchingCubes bump backend expected " + "unstructured quad topology, but got '{}'.", + shape)); + using ShapeType = bumpviews::QuadShape; + auto topologyView = + bumpviews::make_unstructured_single_shape_topology::view(n_topo); + func(shape, topologyView); + } + } + else if(topoType == "uniform" || topoType == "rectilinear" || topoType == "structured") + { + SLIC_ERROR_IF( + n_topo.has_path("elements/dims/offsets") || n_topo.has_path("elements/dims/strides"), + "MarchingCubes bump backend does not support strided structured topology " + "on Windows shared-library builds."); + + const std::string shape = (DIM == 3) ? "hex" : "quad"; + bumpviews::StructuredTopologyView> topologyView; + if(topoType == "uniform") + { + topologyView = bumpviews::make_uniform_topology::view(n_topo); + } + else if(topoType == "rectilinear") + { + topologyView = bumpviews::make_rectilinear_topology::view(n_topo); + } + else + { + topologyView = bumpviews::make_structured_topology::view(n_topo); + } + func(shape, topologyView); + } + else + { + SLIC_ERROR(axom::fmt::format("Unsupported topology type '{}'.", topoType)); + } +#else + bumpviews::dispatch_topology(n_topo, + std::forward(func)); +#endif + } + + void attachSelectedZonesOption(conduit::Node& n_options, + axom::Array& selectedZones) const + { + conduit::Node& n_selectedZones = n_options["selectedZones"]; + if(selectedZones.empty()) + { + n_selectedZones.set( + conduit::DataType(axom::bump::utilities::cpp2conduit::id, 0)); + } + else + { + n_selectedZones.set_external(selectedZones.data(), selectedZones.size()); + } + } + + template + void buildSelectedZonesFromMask(axom::IndexType nZones, + MaskPredicate isSelected, + conduit::Node& n_options, + axom::Array& selectedZones) const + { + axom::Array maskFlags(nZones, nZones, m_allocatorID); + auto maskFlagsView = maskFlags.view(); + + axom::ReduceSum selectedCountReduce(0); + axom::for_all( + nZones, + AXOM_LAMBDA(axom::IndexType zoneIndex) { + const axom::IndexType selected = isSelected(zoneIndex) ? 1 : 0; + maskFlagsView[zoneIndex] = selected; + selectedCountReduce += selected; + }); + + const axom::IndexType selectedCount = selectedCountReduce.get(); + selectedZones = axom::Array(selectedCount, selectedCount, m_allocatorID); + + axom::Array selectedOffsets(nZones, nZones, m_allocatorID); + auto selectedOffsetsView = selectedOffsets.view(); + axom::exclusive_scan(maskFlagsView, selectedOffsetsView); + + auto selectedZonesView = selectedZones.view(); + axom::for_all( + nZones, + AXOM_LAMBDA(axom::IndexType zoneIndex) { + if(maskFlagsView[zoneIndex] != 0) + { + selectedZonesView[selectedOffsetsView[zoneIndex]] = zoneIndex; + } + }); + + attachSelectedZonesOption(n_options, selectedZones); + } + + template + void addMaskSelectedZonesOption(const TopologyView& topologyView, + conduit::Node& n_options, + axom::Array& selectedZones) const + { + namespace bputils = axom::bump::utilities; + + if(m_maskFieldName.empty()) + { + return; + } + + const axom::IndexType nZones = topologyView.numberOfZones(); + const conduit::Node& n_mask = + m_dom->fetch_existing(axom::fmt::format("fields/{}", m_maskFieldName)); + const conduit::Node& n_maskValues = n_mask.fetch_existing("values"); + + // Copy mask value to a local so the device predicates below capture it by value. + // AXOM_LAMBDA is [=]; capturing the m_maskVal *member* would instead capture `this`, + // and dereferencing a host `this` pointer inside a CUDA/HIP kernel is undefined behavior. + // (Compiles and passes on seq/omp regardless, which is why this must be a local, not the member.) + const int maskVal = m_maskVal; + + if(m_useMeshViewUtilPath) + { + // Structured + explicit: read the mask through MeshViewUtil so any + // ghost offsets/strides on the field are honored. + axom::quest::MeshViewUtil mvu(*m_dom, m_topologyName); + const auto maskView = mvu.template getConstFieldView(m_maskFieldName, false); + const axom::MDMapping topoMap(mvu.getCellShape(), axom::ArrayStrideOrder::COLUMN); + + buildSelectedZonesFromMask( + nZones, + [topoMap, maskView, maskVal] AXOM_HOST_DEVICE(axom::IndexType zoneIndex) { + const auto zoneIdx = topoMap.toMultiIndex(zoneIndex); + if constexpr(DIM == 2) + { + return maskView(zoneIdx[0], zoneIdx[1]) == maskVal; + } + else + { + return maskView(zoneIdx[0], zoneIdx[1], zoneIdx[2]) == maskVal; + } + }, + n_options, + selectedZones); + } + else + { + // Everything else (unstructured, uniform, rectilinear): the mask values + // are a flat array in the topology's zone order, which matches bump's + // zone numbering. That is only true without per-field offsets/strides, + // so reject those explicitly rather than silently misindexing. + SLIC_ERROR_IF(n_mask.has_child("offsets") || n_mask.has_child("strides"), + "MarchingCubes (bump backend) does not support a mask field with " + "Blueprint offsets/strides on a non-structured-explicit topology."); + auto maskView = bputils::make_array_view(n_maskValues); + SLIC_ERROR_IF(maskView.size() < nZones, + "MarchingCubes mask field has fewer values than topology zones."); + buildSelectedZonesFromMask( + nZones, + [maskView, maskVal] AXOM_HOST_DEVICE(axom::IndexType zoneIndex) { + return maskView[zoneIndex] == maskVal; + }, + n_options, + selectedZones); + } + } + + template + bool attachCrossingSelectedZonesOption(const TopologyView& topologyView, + const CoordsetView& coordsetView, + const conduit::Node& n_topo, + const conduit::Node& n_coords, + const conduit::Node& n_fields, + conduit::Node& n_options, + axom::Array& crossingZones) const + { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::attachCrossingSelectedZonesOption"); + namespace bumpx = axom::bump::extraction; + + axom::bump::SelectedZones selectedZones(topologyView.numberOfZones(), + n_options, + "selectedZones", + m_allocatorID); + const auto selectedZonesView = selectedZones.view(); + if(selectedZonesView.empty()) + { + return false; + } + + bumpx::FieldIntersector intersector; + intersector.setAllocatorID(m_allocatorID); + intersector.initialize(topologyView, coordsetView, n_options, n_topo, n_coords, n_fields); + const auto intersectorView = intersector.view(); + + axom::ReduceSum crossingCount(0); + axom::Array crossingFlags(selectedZonesView.size(), + selectedZonesView.size(), + m_allocatorID); + auto crossingFlagsView = crossingFlags.view(); + const TopologyView deviceTopologyView(topologyView); + axom::for_all( + selectedZonesView.size(), + AXOM_LAMBDA(axom::IndexType selectedIndex) { + const auto zoneIndex = selectedZonesView[selectedIndex]; + const auto zone = deviceTopologyView.zone(zoneIndex); + const auto ids = zone.getIds(); + const auto caseNumber = intersectorView.determineTableCase(zoneIndex, ids); + const auto allPositive = (axom::IndexType {1} << ids.size()) - axom::IndexType {1}; + const axom::IndexType crosses = (caseNumber != 0 && caseNumber != allPositive) ? 1 : 0; + crossingFlagsView[selectedIndex] = crosses; + crossingCount += crosses; + }); + + const axom::IndexType crossingCountValue = crossingCount.get(); + crossingZones = + axom::Array(crossingCountValue, crossingCountValue, m_allocatorID); + + axom::Array crossingOffsets(selectedZonesView.size(), + selectedZonesView.size(), + m_allocatorID); + auto crossingOffsetsView = crossingOffsets.view(); + axom::exclusive_scan(crossingFlagsView, crossingOffsetsView); + + auto crossingZonesView = crossingZones.view(); + axom::for_all( + selectedZonesView.size(), + AXOM_LAMBDA(axom::IndexType selectedIndex) { + if(crossingFlagsView[selectedIndex] != 0) + { + crossingZonesView[crossingOffsetsView[selectedIndex]] = selectedZonesView[selectedIndex]; + } + }); + + attachSelectedZonesOption(n_options, crossingZones); + return crossingCountValue > 0; + } + + /*! + * @brief Fast structured crossing pre-filter. + * + * @param isoForBump Threshold in the intersector's field type; see isoValueForBump(). + * The corner test below MUST be the same expression bump's FieldIntersector uses, + * or this pre-filter can exclude a zone that bump would have cut (silently dropping facets). + */ + template + bool attachStructuredCrossingSelectedZonesOption(IsoFieldType isoForBump, + conduit::Node& n_options, + axom::Array& crossingZones) const + { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::attachStructuredCrossingSelectedZonesOption"); + + axom::quest::MeshViewUtil mvu(*m_dom, m_topologyName); + const auto fcnView = mvu.template getConstFieldView(m_fcnFieldName, false); + axom::ArrayView maskView; + if(!m_maskFieldName.empty()) + { + maskView = mvu.template getConstFieldView(m_maskFieldName, false); + } + + const auto cellShape = mvu.getCellShape(); + const axom::MDMapping topoMap(cellShape, axom::ArrayStrideOrder::COLUMN); + const axom::IndexType nZones = mvu.getCellCount(); + + if constexpr(std::is_same_v) + { + /* + Iterate the logical index space directly rather than deriving it from a flat zone index. + + topoMap.toMultiIndex(zoneIndex) costs DIM integer divisions per zone, + and this loop runs over EVERY zone, not just crossing ones. + Nested loops make the flat index incremental and the divisions disappear. + + Node signs are also hoisted: adjacent zones share four (2D) or eight (3D) corners, + so classifying each NODE once into a byte array and combining bytes per zone + replaces 2^DIM strided double reads per zone with 2^DIM byte reads. + */ + crossingZones = axom::Array(0, 0, m_allocatorID); + crossingZones.reserve(nZones); + { + // Node-sign plane cache: signs for logical k and k+1 (3D), or the single plane (2D). + // Indexed [j * pi + i] over NODE counts. + const axom::IndexType pi = cellShape[0] + 1; + const axom::IndexType pj = cellShape[1] + 1; + const axom::IndexType planeSize = pi * pj; + axom::Array signPlanes(2 * planeSize, 2 * planeSize); + auto signs = signPlanes.view(); + + auto fillPlane = [&](axom::IndexType which, axom::IndexType k) { + std::uint8_t* dst = signs.data() + which * planeSize; + for(axom::IndexType j = 0; j < pj; ++j) + { + for(axom::IndexType i = 0; i < pi; ++i) + { + if constexpr(DIM == 2) + { + AXOM_UNUSED_VAR(k); + dst[j * pi + i] = static_cast(fcnView(i, j)) > isoForBump ? 1 : 0; + } + else + { + dst[j * pi + i] = static_cast(fcnView(i, j, k)) > isoForBump ? 1 : 0; + } + } + } + }; + + const axom::IndexType nk = (DIM == 3) ? cellShape[DIM - 1] : 1; + fillPlane(0, 0); + + for(axom::IndexType k = 0; k < nk; ++k) + { + if constexpr(DIM == 3) + { + // Plane k is already in slot (k % 2); fill k+1 into the other slot. + fillPlane((k + 1) % 2, k + 1); + } + const std::uint8_t* lo = signs.data() + (DIM == 3 ? (k % 2) : 0) * planeSize; + const std::uint8_t* hi = signs.data() + (DIM == 3 ? ((k + 1) % 2) : 0) * planeSize; + + for(axom::IndexType j = 0; j < cellShape[1]; ++j) + { + const axom::IndexType row = j * pi; + const axom::IndexType rowUp = (j + 1) * pi; + for(axom::IndexType i = 0; i < cellShape[0]; ++i) + { + bool useZone = maskView.empty(); + if(!useZone) + { + if constexpr(DIM == 2) + { + useZone = (maskView(i, j) == m_maskVal); + } + else + { + useZone = (maskView(i, j, k) == m_maskVal); + } + } + if(!useZone) + { + continue; + } + + int nPos = lo[row + i] + lo[row + i + 1] + lo[rowUp + i] + lo[rowUp + i + 1]; + int nCorners = 4; + if constexpr(DIM == 3) + { + nPos += hi[row + i] + hi[row + i + 1] + hi[rowUp + i] + hi[rowUp + i + 1]; + nCorners = 8; + } + + if(nPos != 0 && nPos != nCorners) + { + if constexpr(DIM == 2) + { + crossingZones.push_back(i + j * cellShape[0]); + } + else + { + crossingZones.push_back(i + cellShape[0] * (j + cellShape[1] * k)); + } + } + } + } + } + } + + attachSelectedZonesOption(n_options, crossingZones); + return !crossingZones.empty(); + } + + axom::Array crossingFlags(nZones, nZones, m_allocatorID); + auto crossingFlagsView = crossingFlags.view(); + + // Local copies for device capture: AXOM_LAMBDA is [=], so capturing members + // would capture `this` and dereference a host pointer in a device kernel. + const IsoFieldType isoVal = isoForBump; + const int maskVal = m_maskVal; + axom::ReduceSum crossingCount(0); + axom::for_all( + nZones, + [topoMap, maskView, fcnView, isoVal, maskVal, crossingFlagsView, crossingCount] AXOM_HOST_DEVICE( + axom::IndexType zoneIndex) { + const auto idx = topoMap.toMultiIndex(zoneIndex); + bool useZone = maskView.empty(); + if(!useZone) + { + if constexpr(DIM == 2) + { + useZone = (maskView(idx[0], idx[1]) == maskVal); + } + else + { + useZone = (maskView(idx[0], idx[1], idx[2]) == maskVal); + } + } + + bool hasPositive = false; + bool hasNonPositive = false; + if(useZone) + { + if constexpr(DIM == 2) + { + const bool p0 = static_cast(fcnView(idx[0], idx[1])) > isoVal; + const bool p1 = static_cast(fcnView(idx[0] + 1, idx[1])) > isoVal; + const bool p2 = static_cast(fcnView(idx[0] + 1, idx[1] + 1)) > isoVal; + const bool p3 = static_cast(fcnView(idx[0], idx[1] + 1)) > isoVal; + hasPositive = p0 || p1 || p2 || p3; + hasNonPositive = !p0 || !p1 || !p2 || !p3; + } + else + { + const bool p0 = static_cast(fcnView(idx[0], idx[1], idx[2])) > isoVal; + const bool p1 = static_cast(fcnView(idx[0] + 1, idx[1], idx[2])) > isoVal; + const bool p2 = static_cast(fcnView(idx[0], idx[1] + 1, idx[2])) > isoVal; + const bool p3 = + static_cast(fcnView(idx[0] + 1, idx[1] + 1, idx[2])) > isoVal; + const bool p4 = static_cast(fcnView(idx[0], idx[1], idx[2] + 1)) > isoVal; + const bool p5 = + static_cast(fcnView(idx[0] + 1, idx[1], idx[2] + 1)) > isoVal; + const bool p6 = + static_cast(fcnView(idx[0], idx[1] + 1, idx[2] + 1)) > isoVal; + const bool p7 = + static_cast(fcnView(idx[0] + 1, idx[1] + 1, idx[2] + 1)) > isoVal; + hasPositive = p0 || p1 || p2 || p3 || p4 || p5 || p6 || p7; + hasNonPositive = !p0 || !p1 || !p2 || !p3 || !p4 || !p5 || !p6 || !p7; + } + } + + const axom::IndexType crosses = (hasPositive && hasNonPositive) ? 1 : 0; + crossingFlagsView[zoneIndex] = crosses; + crossingCount += crosses; + }); + + const axom::IndexType crossingCountValue = crossingCount.get(); + crossingZones = + axom::Array(crossingCountValue, crossingCountValue, m_allocatorID); + + axom::Array crossingOffsets(nZones, nZones, m_allocatorID); + auto crossingOffsetsView = crossingOffsets.view(); + axom::exclusive_scan(crossingFlagsView, crossingOffsetsView); + + auto crossingZonesView = crossingZones.view(); + axom::for_all( + nZones, + AXOM_LAMBDA(axom::IndexType zoneIndex) { + if(crossingFlagsView[zoneIndex] != 0) + { + crossingZonesView[crossingOffsetsView[zoneIndex]] = zoneIndex; + } + }); + + attachSelectedZonesOption(n_options, crossingZones); + return crossingCountValue > 0; + } + + /*! + * @brief Instantiate CutField for (DIM, ExecSpace, this domain's view types) + * and run it, storing the Blueprint output. + * + * Uses bump's dispatch_topology / dispatch_coordset to turn the runtime + * Blueprint topology+coordset into compile-time view types, then instantiates + * CutField and calls execute(). + * + * NOTE: The input domain arrays must already be in a memory space compatible + * with ExecSpace (the same precondition the legacy backend has). + */ + void runExtraction() + { + SLIC_ASSERT(m_dom != nullptr); + SLIC_ASSERT(!m_fcnFieldName.empty()); + + namespace bumpviews = axom::bump::views; + namespace bumpx = axom::bump::extraction; + + const conduit::Node& n_topo = + m_dom->fetch_existing(axom::fmt::format("topologies/{}", m_topologyName)); + const std::string coordsetName = n_topo.fetch_existing("coordset").as_string(); + const conduit::Node& n_coords = + m_dom->fetch_existing(axom::fmt::format("coordsets/{}", coordsetName)); + + // Options shared by all dispatch branches. + conduit::Node n_options; + n_options["field"] = m_fcnFieldName; + n_options["value"] = m_contourVal; + // Ask bump to record each output facet's originating input zone, which we + // map onto the legacy "parent cell id" output. + n_options["originalElementsField"] = kOriginalElementsField; + // MarchingCubes only consumes the generated originalElements field from CutField. + // An explicit empty fields map avoids blending/slicing all input fields by default. + n_options["fields"].set(conduit::DataType::object()); + + m_output = std::make_unique(); + conduit::Node& n_out = *m_output; + + // Restrict the unstructured shape set to {quad, hex} as requested, to bound template instantiation. + // Structured dimensions restricted to DIM. Dispatch coordset, then topology, building the matching views + // and running CutField. The double dispatch yields the concrete (CoordView, TopoView) pair at compile time. + bool extracted = false; + dispatchCoordset(n_coords, [&](auto coordsetView) { + using CoordsetView = decltype(coordsetView); + dispatchTopology(n_topo, [&](const std::string& AXOM_UNUSED_PARAM(shape), auto topologyView) { + using TopologyView = decltype(topologyView); + + // --- Phase 6 robustness seam -------------------------------------- + // The intersector policy is the single point that determines per-cell topology + crossing precision. + // bump's default FieldIntersector is float-precision and two-label (no +/-/0). + // When a robust intersector (double precision, +/-/0 / asymptotic-decider aware) is added to bump, + // alias `Cut` to CutField<..., RobustIntersector> in the `robust` branch below; + // no other quest code changes. Until then both branches use the default, and selecting `robust` emits a one-time note. + using StandardCut = bumpx::CutField; + // using RobustCut = bumpx::CutField>; + using Cut = StandardCut; + + if(m_robustnessPolicy == MarchingCubesRobustnessPolicy::robust) + { + static bool warnedOnce = false; + if(!warnedOnce) + { + warnedOnce = true; + SLIC_INFO( + "MarchingCubes: robust isosurface policy requested, but a robust " + "bump intersector is not yet available; using the standard " + "(single-precision, two-label) intersector."); + } + } + + Cut iso(topologyView, coordsetView); + iso.setAllocatorID(m_allocatorID); + + // --- Corner-classification convention ------------------------------ + // Reconcile bump's strict corner test with the legacy kernel's `>=`. + // Both the value handed to bump AND the structured pre-filter's own + // test must use this, or the pre-filter and the extractor disagree. + using IsoFieldType = + typename bumpx::FieldIntersector::FieldType; + const IsoFieldType isoForBump = isoValueForBump(m_contourVal); + n_options["value"] = static_cast(isoForBump); + + axom::Array selectedZones; + const bool hasCrossingZones = m_useMeshViewUtilPath + ? attachStructuredCrossingSelectedZonesOption(isoForBump, + n_options, + selectedZones) + : [&]() { + addMaskSelectedZonesOption(topologyView, n_options, selectedZones); + return attachCrossingSelectedZonesOption(topologyView, + coordsetView, + n_topo, + n_coords, + m_dom->fetch_existing("fields"), + n_options, + selectedZones); + }(); + if(!hasCrossingZones) + { + m_facetCount = 0; + return; + } + + conduit::Node execOptions; + axom::bump::utilities::copy(execOptions, n_options, m_allocatorID); + { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::CutField::execute"); + iso.execute(*m_dom, execOptions, n_out); + + // Restore the public field name before the adaptor or any caller sees the output. + // The private request name prevents bump from forwarding a same-named field from the input mesh. + const std::string privateField = axom::fmt::format("fields/{}", kOriginalElementsField); + if(n_out.has_path(privateField)) + { + n_out["fields"].rename_child(kOriginalElementsField, kPublicOriginalElementsField); + } + } + extracted = true; + }); + }); + + if(!extracted) + { + // An out-of-range isovalue or an empty mask is valid and produces an available, empty contour + // rather than an empty Blueprint node. + m_output.reset(); + m_facetCount = 0; + return; + } + + // Determine the facet count from the bump output. After fan-triangulation (see fillLegacyOutputBuffers) + // the legacy facet count is the number of triangles/segments, not the number of bump polygons; + // compute it from the output element sizes. + { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::computeTriangulatedFacetCount"); + m_facetCount = computeTriangulatedFacetCount(n_out); + } + } + + /*! + * @brief Number of (DIM-cornered) facets after fan-triangulating bump output. + * + * For DIM==2 the bump output elements are 2-node segments -> 1 facet each. + * For DIM==3 a p-gon fans into (p-2) triangles. + */ + axom::IndexType computeTriangulatedFacetCount(const conduit::Node& n_out) const + { + const std::string newTopoName = onlyTopologyName(n_out); + const conduit::Node& n_elems = + n_out.fetch_existing(axom::fmt::format("topologies/{}/elements", newTopoName)); + + // Polygonal/segment output carries an explicit "sizes" array. + if(n_elems.has_child("sizes")) + { + const conduit::Node& n_sizes = n_elems.fetch_existing("sizes"); + axom::IndexType facets = 0; + axom::bump::views::nodeToArrayView(n_sizes, [&](auto sizes) { + facets = computeTriangulatedFacetCountView(sizes); + }); + return facets; + } + + SLIC_ERROR(axom::fmt::format( + "MarchingCubes bump backend: cut output topology '{}' has no 'sizes' array. " + "The adaptor requires explicit sizes on bump's cut output.", + newTopoName)); + return 0; + } + + /*! + * @brief Fill the parent-allocated legacy output buffers from cached bump output, + * triangulating the polygons while reusing bump's welded vertex coordinates. + */ + void fillLegacyOutputBuffers() + { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::fillLegacyOutputBuffers"); + if(m_facetCount == 0) + { + return; + } + SLIC_ASSERT(m_output != nullptr); + + adaptCutFieldOutput(*m_output, + m_facetNodeIds, + m_facetNodeCoords, + m_facetParentIds, + m_facetIndexOffset, + m_nodeIndexOffset, + m_facetCount, + m_allocatorID); + } + + //! @brief Return the (single) topology name present in a bump output node. + static std::string onlyTopologyName(const conduit::Node& n_out) + { + const conduit::Node& n_topos = n_out.fetch_existing("topologies"); + SLIC_ASSERT(n_topos.number_of_children() == 1); + return n_topos.child(0).name(); + } + +private: + int m_allocatorID = axom::INVALID_ALLOCATOR_ID; + + const conduit::Node* m_dom {nullptr}; + std::string m_topologyName; + std::string m_fcnFieldName; + std::string m_maskFieldName; + + MarchingCubesRobustnessPolicy m_robustnessPolicy {MarchingCubesRobustnessPolicy::standard}; + + //! @brief Whether the MeshViewUtil fast paths apply (structured + explicit only). + bool m_useMeshViewUtilPath {false}; + + //! @brief Cached bump CutField output (Blueprint mesh). + std::unique_ptr m_output; + + //! @brief Legacy facet count (post fan-triangulation). + axom::IndexType m_facetCount {}; + + //! @brief Whether extraction ran, distinguishing unavailable from empty output. + bool m_extractionRan {false}; +}; + +} // namespace axom::quest::detail::marching_cubes diff --git a/src/axom/quest/detail/MarchingCubesImpl.hpp b/src/axom/quest/detail/MarchingCubesImpl.hpp index 48d0b98955..c7c4bc361e 100644 --- a/src/axom/quest/detail/MarchingCubesImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesImpl.hpp @@ -33,15 +33,13 @@ namespace detail namespace marching_cubes { /*! - @brief Computations for MarchingCubesSingleDomain - - Spatial dimension and execution space are here as template - parameters, to keep out of higher level classes MarchingCubes and - MarchingCubesSingleDomain. - - ExecSpace is the general execution space, like axom::SEQ_EXEC and - axom::CUDA_EXEC<256>. -*/ + * @brief Computations for MarchingCubesSingleDomain + * + * Spatial dimension and execution space are here as template parameters, + * to keep out of higher level classes MarchingCubes and MarchingCubesSingleDomain. + * + * ExecSpace is the general execution space, like axom::SEQ_EXEC and axom::CUDA_EXEC<256>. + */ template class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase { @@ -77,17 +75,14 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase } /*! - @brief Initialize data to a blueprint domain. - @param dom Blueprint structured mesh domain - @param topologyName Name of mesh topology (see blueprint - mesh documentation) - @param maskFieldName Name of integer cell mask function is in dom - - Set up views to domain data and allocate other data to work on the - given domain. - - The above data from the domain MUST be in a memory space - compatible with ExecSpace. + * @brief Initialize data to a blueprint domain. + * @param dom Blueprint structured mesh domain + * @param topologyName Name of mesh topology (see blueprint mesh documentation) + * @param maskFieldName Name of integer cell mask function is in dom + * + * Set up views to domain data and allocate other data to work on the given domain. + * + * The above data from the domain MUST be in a memory space compatible with ExecSpace. */ AXOM_HOST void setDomain(const conduit::Node& dom, const std::string& topologyName, @@ -215,10 +210,9 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase } /*! - @brief Implementation used by MarchingCubesImpl::markCrossings_dim() - containing just the objects needed for that part, to be made available - on devices. - */ + * @brief Implementation used by MarchingCubesImpl::markCrossings_dim() + * containing just the objects needed for that part, to be made available on devices. + */ struct MarkCrossings_Util { axom::ArrayView caseIdsView; @@ -468,6 +462,8 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase // m_firstFacetIds.resize(m_crossingCount); } + axom::IndexType getContourNodeCount() const override { return DIM * getContourCellCount(); } + void computeFacets() override { AXOM_ANNOTATE_SCOPE("MarchingCubesImpl::computeFacets"); @@ -480,6 +476,7 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase axom::ArrayView facetNodeCoordsView = m_facetNodeCoords; axom::ArrayView facetParentIdsView = m_facetParentIds; const axom::IndexType facetIndexOffset = m_facetIndexOffset; + const axom::IndexType nodeIndexOffset = m_nodeIndexOffset; ComputeFacets_Util cfu(m_contourVal, m_caseIdsMDMapper, m_fcnView, m_coordsViews); @@ -496,8 +493,9 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase for(axom::IndexType fId = 0; fId < additionalFacets; ++fId) { + const axom::IndexType localFacetId = firstFacetIdsView[crossingId] + fId; axom::IndexType newFacetId = firstFacetId + fId; - axom::IndexType firstCornerId = newFacetId * DIM; + axom::IndexType firstCornerId = nodeIndexOffset + localFacetId * DIM; facetParentIdsView[newFacetId] = parentCellId; @@ -516,10 +514,9 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase } /*! - @brief Implementation used by MarchingCubesImpl::computeFacets(). - containing just the objects needed for that part, to be made available - on devices. - */ + * @brief Implementation used by MarchingCubesImpl::computeFacets(). + * containing just the objects needed for that part, to be made available on devices. + */ struct ComputeFacets_Util { double contourVal; @@ -764,10 +761,8 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase return index; } - /*! - @brief Constructor. - */ - MarchingCubesImpl() { } + //! @brief Constructor + MarchingCubesImpl() = default; /*! @brief Clear computed data (without deallocating memory). diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.cpp b/src/axom/quest/detail/MarchingCubesSingleDomain.cpp index b550a84f73..8e2b3b31c8 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.cpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.cpp @@ -8,22 +8,22 @@ // Implementation requires Conduit. #ifndef AXOM_USE_CONDUIT - #error "MarchingCubes.cpp requires conduit" + #error "MarchingCubesSingleDomain.cpp requires conduit" #endif #include "conduit_blueprint.hpp" #include "axom/core/execution/execution_space.hpp" #include "axom/quest/detail/MarchingCubesSingleDomain.hpp" #include "axom/quest/detail/MarchingCubesImpl.hpp" + +#if defined(AXOM_USE_BUMP) + #include "axom/quest/detail/MarchingCubesBumpImpl.hpp" +#endif #include "axom/fmt.hpp" -namespace axom -{ -namespace quest -{ -namespace detail -{ -namespace marching_cubes +#include + +namespace axom::quest::detail::marching_cubes { MarchingCubesSingleDomain::MarchingCubesSingleDomain(MarchingCubes& mc) : m_mc(mc) @@ -50,16 +50,24 @@ void MarchingCubesSingleDomain::setDomain(const conduit::Node& dom, SLIC_ASSERT_MSG(!conduit::blueprint::mesh::is_multi_domain(dom), "Internal error. Attempt to set a multi-domain mesh in " "MarchingCubesSingleDomain."); - SLIC_ASSERT(dom.fetch_existing("topologies/" + m_topologyName + "/type").as_string() == - "structured"); + // The legacy backend supports only structured topologies. + // The bump backend additionally supports unstructured single-shape quad/hex; + // it validates the topology type itself in its own setDomain(), + // so we only enforce the structured requirement here when using the legacy backend. + if(!m_mc.m_useBumpBackend) + { + SLIC_ASSERT(dom.fetch_existing("topologies/" + m_topologyName + "/type").as_string() == + "structured"); + } const std::string coordsetPath = "coordsets/" + dom.fetch_existing("topologies/" + m_topologyName + "/coordset").as_string(); SLIC_ASSERT(dom.has_path(coordsetPath)); - if(!m_maskPath.empty()) + m_maskFieldName = maskField; + if(!m_maskFieldName.empty()) { - m_maskPath = maskField.empty() ? std::string() : "fields/" + maskField; + m_maskPath = "fields/" + m_maskFieldName; SLIC_ASSERT(dom.has_path(m_maskPath + "/values")); } else @@ -73,9 +81,16 @@ void MarchingCubesSingleDomain::setDomain(const conduit::Node& dom, dom.fetch_existing(axom::fmt::format("topologies/{}", m_topologyName))); SLIC_ASSERT(m_ndim >= 2 && m_ndim <= 3); - SLIC_ASSERT_MSG( - !conduit::blueprint::mcarray::is_interleaved(dom.fetch_existing(coordsetPath + "/values")), - "MarchingCubes currently requires contiguous coordinates layout."); + // The legacy backend reads coordinates through strided component views and + // requires a contiguous (non-interleaved) layout. The bump backend wraps the + // coordset via bump's coordset views; if a given layout is unsupported there, + // bump's dispatch reports it. So enforce contiguity only for the legacy path. + if(!m_mc.m_useBumpBackend) + { + SLIC_ASSERT_MSG( + !conduit::blueprint::mcarray::is_interleaved(dom.fetch_existing(coordsetPath + "/values")), + "MarchingCubes currently requires contiguous coordinates layout."); + } m_impl = newMarchingCubesImpl(); @@ -87,78 +102,139 @@ void MarchingCubesSingleDomain::setDomain(const conduit::Node& dom, @brief Allocate a MarchingCubesImpl object, template-specialized for caller-specified runtime policy and physical dimension. */ +namespace +{ +/*! + * @brief Construct the single-domain impl leaf for a concrete (DIM, ExecSpace), + * choosing the bump-backed implementation when requested/available, else the + * legacy hand-written marching cubes kernel. + * + * Centralizing the bump-vs-legacy choice here keeps the (policy x dim) matrix + * in newMarchingCubesImpl() from having to repeat the branch in every leaf. + * + * @tparam DIM Spatial dimension. + * @tparam ExecSpace Compute execution space. + * @tparam SeqExec The sequential exec space the legacy kernel uses for its + * (intentionally serial) scan phase; unused by the bump backend. + */ +template +std::unique_ptr make_impl_leaf( + bool useBumpBackend, + int allocatorID, + axom::Array& caseIdsFlat, + axom::Array& crossingFlags, + axom::Array& scannedFlags, + axom::Array& facetIncrs) +{ +#if defined(AXOM_USE_CONDUIT) && defined(AXOM_USE_BUMP) + if(useBumpBackend) + { + #if defined(_WIN32) + constexpr bool supportsBumpExec = std::is_same::value; + #else + constexpr bool supportsBumpExec = true; + #endif + if constexpr(supportsBumpExec) + { + return std::unique_ptr( + new axom::quest::detail::marching_cubes::MarchingCubesBumpImpl(allocatorID)); + } + else + { + SLIC_ERROR( + "MarchingCubes bump backend is not enabled for this runtime policy " + "on Windows shared-library builds."); + // With a non-aborting error handler for SLIC_ERROR, we could + // fall through to the common return below and silently hand back the + // structured-only legacy kernel for what may be an unstructured mesh. + return nullptr; + } + } +#else + SLIC_ERROR_IF(useBumpBackend, + "MarchingCubes bump backend requires Axom to be configured " + "with the bump component."); +#endif + return std::unique_ptr( + new MarchingCubesImpl(allocatorID, + caseIdsFlat, + crossingFlags, + scannedFlags, + facetIncrs)); +} +} // anonymous namespace + std::unique_ptr MarchingCubesSingleDomain::newMarchingCubesImpl() { SLIC_ASSERT(m_ndim >= 2 && m_ndim <= 3); std::unique_ptr impl; + const bool useBump = m_mc.m_useBumpBackend; if(m_runtimePolicy == MarchingCubes::RuntimePolicy::seq) { - impl = m_ndim == 2 - ? std::unique_ptr( - new MarchingCubesImpl<2, axom::SEQ_EXEC, axom::SEQ_EXEC>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)) - : std::unique_ptr( - new MarchingCubesImpl<3, axom::SEQ_EXEC, axom::SEQ_EXEC>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)); + impl = m_ndim == 2 ? make_impl_leaf<2, axom::SEQ_EXEC, axom::SEQ_EXEC>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs) + : make_impl_leaf<3, axom::SEQ_EXEC, axom::SEQ_EXEC>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs); } #if defined(AXOM_RUNTIME_POLICY_USE_OPENMP) else if(m_runtimePolicy == MarchingCubes::RuntimePolicy::omp) { - impl = m_ndim == 2 - ? std::unique_ptr( - new MarchingCubesImpl<2, axom::OMP_EXEC, axom::SEQ_EXEC>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)) - : std::unique_ptr( - new MarchingCubesImpl<3, axom::OMP_EXEC, axom::SEQ_EXEC>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)); + impl = m_ndim == 2 ? make_impl_leaf<2, axom::OMP_EXEC, axom::SEQ_EXEC>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs) + : make_impl_leaf<3, axom::OMP_EXEC, axom::SEQ_EXEC>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs); } #endif #if defined(AXOM_RUNTIME_POLICY_USE_CUDA) else if(m_runtimePolicy == MarchingCubes::RuntimePolicy::cuda) { impl = m_ndim == 2 - ? std::unique_ptr( - new MarchingCubesImpl<2, axom::CUDA_EXEC<256>, axom::CUDA_EXEC<1>>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)) - : std::unique_ptr( - new MarchingCubesImpl<3, axom::CUDA_EXEC<256>, axom::CUDA_EXEC<1>>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)); + ? make_impl_leaf<2, axom::CUDA_EXEC<256>, axom::CUDA_EXEC<1>>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs) + : make_impl_leaf<3, axom::CUDA_EXEC<256>, axom::CUDA_EXEC<1>>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs); } #endif #if defined(AXOM_RUNTIME_POLICY_USE_HIP) else if(m_runtimePolicy == MarchingCubes::RuntimePolicy::hip) { impl = m_ndim == 2 - ? std::unique_ptr( - new MarchingCubesImpl<2, axom::HIP_EXEC<256>, axom::HIP_EXEC<1>>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)) - : std::unique_ptr( - new MarchingCubesImpl<3, axom::HIP_EXEC<256>, axom::HIP_EXEC<1>>(m_mc.m_allocatorID, - m_mc.m_caseIdsFlat, - m_mc.m_crossingFlags, - m_mc.m_scannedFlags, - m_mc.m_facetIncrs)); + ? make_impl_leaf<2, axom::HIP_EXEC<256>, axom::HIP_EXEC<1>>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs) + : make_impl_leaf<3, axom::HIP_EXEC<256>, axom::HIP_EXEC<1>>(useBump, + m_mc.m_allocatorID, + m_mc.m_caseIdsFlat, + m_mc.m_crossingFlags, + m_mc.m_scannedFlags, + m_mc.m_facetIncrs); } #endif else @@ -180,7 +256,4 @@ int32_t MarchingCubesSingleDomain::getDomainId(int32_t defaultId) const return rval; } -} // namespace marching_cubes -} // namespace detail -} // end namespace quest -} // end namespace axom +} // end namespace axom::quest::detail::marching_cubes diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp index 9e822a20f0..1511299ad5 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp @@ -16,70 +16,61 @@ #include "axom/config.hpp" // Implementation requires Conduit. -#ifdef AXOM_USE_CONDUIT +#ifndef AXOM_USE_CONDUIT + #error "MarchingCubesSingleDomain.cpp requires conduit" +#endif - // Axom includes - #include "axom/core/execution/runtime_policy.hpp" - #include "axom/mint/mesh/UnstructuredMesh.hpp" - #include "axom/quest/MarchingCubes.hpp" +// Axom includes +#include "axom/core/execution/runtime_policy.hpp" +#include "axom/mint/mesh/UnstructuredMesh.hpp" +#include "axom/quest/MarchingCubes.hpp" - // Conduit includes - #include "conduit_node.hpp" +// Conduit includes +#include "conduit_node.hpp" - // C++ includes - #include +// C++ includes +#include -namespace axom -{ -namespace quest -{ -namespace detail -{ -namespace marching_cubes +namespace axom::quest::detail::marching_cubes { template class MarchingCubesImpl; /*! - \@brief Class implementing marching cubes algorithm for a single - domain. - - This class is an internal detail for multi-domain implementation - MarchinCubes class, and should not be used outside it. - - \sa MarchingCubes -*/ + * \@brief Class implementing marching cubes algorithm for a single domain. + * + * This class is an internal detail for multi-domain implementation + * MarchinCubes class, and should not be used outside it. + * + * \sa MarchingCubes + */ class MarchingCubesSingleDomain { public: using RuntimePolicy = axom::runtime_policy::Policy; - /*! - \brief Constructor for applying algorithm in a single domain. - */ + //! \brief Constructor for applying algorithm in a single domain. MarchingCubesSingleDomain(MarchingCubes& mc); - ~MarchingCubesSingleDomain() { } + ~MarchingCubesSingleDomain() = default; /*! - @brief Intitialize object to a domain. - \param [in] dom Blueprint single-domain mesh containing scalar field. - \param [in] topologyName Name of Blueprint topology to use in \a dom - \param [in] maskField Cell-based std::int32_t mask field. If provided, - cells where this field evaluates to false are skipped. - - Array data in \a dom must be accessible in the the \a - runtimePolicy environment in the constructor. It's an error if - not, e.g., using CPU memory with a GPU policy. - - Some data from \a dom may be cached by the constructor. Any - change to it without re-initialization leads to undefined - behavior. - - The mesh coordinates should be stored contiguously. See - conduit::blueprint::is_contiguous(). In the future, this - requirement may be relaxed, possibly at the cost of a - transformation and storage of the temporary contiguous layout. - */ + * @brief Intitialize object to a domain. + * \param [in] dom Blueprint single-domain mesh containing scalar field. + * \param [in] topologyName Name of Blueprint topology to use in \a dom + * \param [in] maskField Cell-based std::int32_t mask field. If provided, + * cells where this field evaluates to false are skipped. + * + * Array data in \a dom must be accessible in the the \a runtimePolicy environment + * in the constructor. It's an error if not, e.g., using CPU memory with a GPU policy. + * + * Some data from \a dom may be cached by the constructor. + * Any change to it without re-initialization leads to undefined behavior. + * + * The mesh coordinates should be stored contiguously. See + * conduit::blueprint::is_contiguous(). In the future, this + * requirement may be relaxed, possibly at the cost of a + * transformation and storage of the temporary contiguous layout. + */ void setDomain(const conduit::Node& dom, const std::string& topologyName, const std::string& maskfield); @@ -87,9 +78,8 @@ class MarchingCubesSingleDomain int spatialDimension() const { return m_ndim; } /*! - @brief Specify the field containing the nodal scalar function - in the input mesh. - \param [in] fcnField Name of node-based scalar function values. + * @brief Specify the field containing the nodal scalar function in the input mesh. + * @param [in] fcnField Name of node-based scalar function values. */ void setFunctionField(const std::string& fcnField) { @@ -119,33 +109,41 @@ class MarchingCubesSingleDomain } } + void setRobustnessPolicy(MarchingCubesRobustnessPolicy policy) + { + m_robustnessPolicy = policy; + if(m_impl) + { + m_impl->setRobustnessPolicy(m_robustnessPolicy); + } + } + // Methods trivially delegated to implementation. void markCrossings() { m_impl->markCrossings(); } void scanCrossings() { m_impl->scanCrossings(); } void computeFacets() { m_impl->computeFacets(); } /*! - @brief Get the Blueprint domain id specified in \a state/domain_id - if it is provided, or use the given default if not provided. - */ + * @brief Get the Blueprint domain id specified in \a state/domain_id + * if it is provided, or use the given default if not provided. + */ int32_t getDomainId(int32_t defaultId) const; //!@brief Get number of cells in the generated contour mesh. axom::IndexType getContourCellCount() const { return m_impl->getContourCellCount(); } //!@brief Get number of nodes in the generated contour mesh. - axom::IndexType getContourNodeCount() const { return m_ndim * getContourCellCount(); } + axom::IndexType getContourNodeCount() const { return m_impl->getContourNodeCount(); } /*! - @brief Base class for implementations templated on dimension DIM - and execution space ExecSpace. - - Implementation details templated on DIM and ExecSpace cannot - be in MarchingCubesSingleDomain so should live in this class. - - This class allows m_impl to refer to any implementation used - at runtime. - */ + * @brief Base class for implementations templated on dimension DIM + * and execution space ExecSpace. + * + * Implementation details templated on DIM and ExecSpace cannot + * be in MarchingCubesSingleDomain so should live in this class. + * + * This class allows m_impl to refer to any implementation used at runtime. + */ struct ImplBase { /*! @@ -162,6 +160,14 @@ class MarchingCubesSingleDomain virtual void setContourValue(double contourVal) = 0; virtual void setMaskValue(int maskVal) = 0; + /*! + * @brief Set the isosurface robustness policy (bump backend only). + * + * No-op default so the legacy backend (which has no intersector concept) is unaffected. + * The bump backend overrides this. + */ + virtual void setRobustnessPolicy(MarchingCubesRobustnessPolicy) { } + virtual void setDataParallelism(MarchingCubesDataParallelism dataPar) = 0; ///@{ @@ -185,20 +191,48 @@ class MarchingCubesSingleDomain //! @brief Return number of contour mesh facets generated. virtual axom::IndexType getContourCellCount() const = 0; + + //! @brief Return number of contour mesh nodes generated. + virtual axom::IndexType getContourNodeCount() const = 0; + + /*! @brief Whether this implementation has a richer Blueprint contour. */ + virtual bool hasContourMeshBlueprint() const { return false; } + + /*! + * @brief Copy the implementation's richer Blueprint contour, if any. + * + * The legacy backend does not provide this representation; callers should + * check hasContourMeshBlueprint() before invoking this method. + */ + virtual void copyContourMeshBlueprint(conduit::Node& bpMesh, bool triangulate) const + { + AXOM_UNUSED_VAR(triangulate); + bpMesh.reset(); + } + + /*! + * @brief Move the implementation's richer Blueprint contour, if any. + * + * The legacy backend does not provide this representation; callers should + * check hasContourMeshBlueprint() before invoking this method. + */ + virtual void relinquishContourMeshBlueprint(conduit::Node& bpMesh) { bpMesh.reset(); } ///@} void setOutputBuffers(axom::ArrayView& facetNodeIds, axom::ArrayView& facetNodeCoords, axom::ArrayView& facetParentIds, - axom::IndexType facetIndexOffset) + axom::IndexType facetIndexOffset, + axom::IndexType nodeIndexOffset) { m_facetNodeIds = facetNodeIds; m_facetNodeCoords = facetNodeCoords; m_facetParentIds = facetParentIds; m_facetIndexOffset = facetIndexOffset; + m_nodeIndexOffset = nodeIndexOffset; } - virtual ~ImplBase() { } + virtual ~ImplBase() = default; virtual void clearDomain() = 0; @@ -210,19 +244,32 @@ class MarchingCubesSingleDomain axom::ArrayView m_facetNodeCoords; axom::ArrayView m_facetParentIds; axom::IndexType m_facetIndexOffset = -1; + axom::IndexType m_nodeIndexOffset = -1; }; ImplBase& getImpl() { return *m_impl; } + const ImplBase& getImpl() const { return *m_impl; } + +private: + /*! + * \brief Set the blueprint single-domain mesh. + * + * Some data from \a dom may be cached. + */ + void setDomain(const conduit::Node& dom); + + /// @brief Allocate MarchingCubesImpl object + std::unique_ptr newMarchingCubesImpl(); private: //! @brief Multi-domain implementation this object is under. MarchingCubes& m_mc; RuntimePolicy m_runtimePolicy; - int m_allocatorID = axom::INVALID_ALLOCATOR_ID; + int m_allocatorID {axom::INVALID_ALLOCATOR_ID}; //! @brief Choice of full or partial data-parallelism, or byPolicy. - MarchingCubesDataParallelism m_dataParallelism = MarchingCubesDataParallelism::byPolicy; + MarchingCubesDataParallelism m_dataParallelism {MarchingCubesDataParallelism::byPolicy}; //! \brief Computational mesh as a conduit::Node. const conduit::Node* m_dom; @@ -239,26 +286,11 @@ class MarchingCubesSingleDomain //! @brief Path to mask in m_dom. std::string m_maskPath; - double m_contourVal = 0.0; - int m_maskVal = 1; + double m_contourVal {0.0}; + int m_maskVal {1}; + MarchingCubesRobustnessPolicy m_robustnessPolicy {MarchingCubesRobustnessPolicy::standard}; std::unique_ptr m_impl; +}; - /*! - * \brief Set the blueprint single-domain mesh. - * - * Some data from \a dom may be cached. - */ - void setDomain(const conduit::Node& dom); - - /// @brief Allocate MarchingCubesImpl object - std::unique_ptr newMarchingCubesImpl(); - -}; // class MarchingCubesSingleDomain - -} // end namespace marching_cubes -} // end namespace detail -} // namespace quest -} // namespace axom - -#endif // AXOM_USE_CONDUIT +} // namespace axom::quest::detail::marching_cubes diff --git a/src/axom/quest/docs/sphinx/isosurface_detection.rst b/src/axom/quest/docs/sphinx/isosurface_detection.rst index 00a6218fde..53deba7eb9 100644 --- a/src/axom/quest/docs/sphinx/isosurface_detection.rst +++ b/src/axom/quest/docs/sphinx/isosurface_detection.rst @@ -11,20 +11,19 @@ Isosurface Detection ******************** Quest can generate isosurface meshes for node-centered scalar fields. -This feature takes a structured mesh with some scalar nodal field and -generates an ``UnstructuredMesh`` at a user-specified isovalue. The -isosurface mesh contains information on which elements of the field -mesh it crosses. The output may be useful for material surface -reconstruction and visualization, among other things. +This feature takes a Conduit Blueprint mesh with a scalar nodal field and +generates an ``UnstructuredMesh`` at a user-specified isovalue. +The isosurface mesh contains information on which elements of the field mesh it crosses. +The output may be useful for material interface reconstruction and visualization, among other things. -We support 2D and 3D configurations. The isosurface mesh is a -composed of line segments in 2D and triangles in 3D. +We support 2D and 3D configurations. +The isosurface mesh is composed of line segments in 2D and triangles in 3D. .. Note:: The current implementation is for the original algorithm: - Lorensen, William E.; Cline, Harvey E. (1 August 1987). + William E. Lorensen, and Harvey E. Cline (1 August 1987). "Marching cubes: A high resolution 3D surface construction algorithm". *ACM SIGGRAPH Computer Graphics*. 21 (**4**): 163-169 @@ -57,8 +56,8 @@ The inputs are: #. The contour value. The following example shows usage of the ``MarchingCubes`` class. -(A complete example is provided in -``src/axom/quest/examples/quest_marching_cubes_example.cpp``.) +A complete example is in +``src/axom/quest/examples/quest_marching_cubes_example.cpp``. Relevant header files: @@ -70,12 +69,14 @@ Relevant header files: Set up the user's blueprint mesh and the ``MarchingCubes`` object: -The blueprint mesh must be a structured mesh in multi-domain format. -A domain is a part of a global mesh that has been subdivided for -reasons including parallel partitioning, geometric constraints and -size constraints. Any number of domains is allowed, including zero. -(For single-domain format, see the similar -``MarchingCubesSingleDomain`` class in the ``axom::quest`` namespace.) +``MarchingCubes`` accepts a Blueprint mesh in multi-domain format. A domain +is a part of a global mesh that has been subdivided for reasons including +parallel partitioning, geometric constraints, and size constraints. Any +number of domains is allowed, including zero. + +If you already have a single-domain mesh, you can pass it directly and +``MarchingCubes`` will wrap it internally. The ``MarchingCubesSingleDomain`` +class provides a similar interface with a single-domain focus. Blueprint convention allows for named coordinate sets and scalar fields. Here, we tell the ``MarchingCubes`` constructor that the @@ -87,6 +88,74 @@ tells ``mc`` to run sequentially on the host. ``MarchingCubes`` currently also supports OpenMP and GPU device executions using CUDA and HIP. +The ``MarchingCubesDataParallelism`` constructor argument selects the scan +strategy used by the legacy structured-mesh backend. When the optional bump +``CutField`` backend is enabled with ``setUseBumpBackend(true)``, bump manages +its own internal parallelism for the selected runtime policy. The +data-parallelism setting is accepted for API compatibility only. + +The two backends accept different input. + +Legacy backend + This is the default backend. It supports only a ``structured`` topology + with an ``explicit`` coordset. It does not support ``uniform`` or + ``rectilinear`` topologies. It accepts ghost-padded structured input. + +Bump backend + Enable this backend with ``setUseBumpBackend(true)``. It supports the + same structured input. It also supports + ``uniform`` and ``rectilinear`` topologies, plus unstructured single-shape + meshes. In 2D this means quadrilaterals. In 3D this means hexahedra. + It accepts ghost-padded structured input. + + The bump backend requires ``float64`` coordinates and function field, and + validates that at ``setMesh`` and ``setFunctionField`` time. + +The bump backend produces a welded, topologically connected contour, which +``populateContourMeshBlueprint`` and ``relinquishContourDataBlueprint`` +expose directly. In 3D, bump's native ``CutField`` output may contain +triangles, quadrilaterals, or polygons with more than four vertices. +The legacy ``MarchingCubes`` output API still returns a triangle mesh, +so the bump adaptor fan-triangulates each polygonal output face when it +fills the fixed-stride contour arrays used by ``populateContourMesh``. + +Note two behavioral differences from the legacy backend, both inherited +from bump's current default intersector and cut tables: + +#. *Precision.* The bump default intersector evaluates the scalar field + and edge-crossing positions in single precision (``float``), whereas + the legacy backend uses ``double``. Input fields of other types are + converted to ``float`` for the intersection computation. +#. *Ambiguity.* Like the legacy 1987 tables, bump's VisIt-derived cut + tables resolve ambiguous saddle cell configurations with a single + fixed triangulation per case. It is consistent, but not necessarily + the same as the trilinear interpolant. Neither backend currently + implements a topologically robust resolution such as an asymptotic + decider or plus-minus-zero. +#. *Fan triangulation.* Because the legacy output API returns a triangle + mesh, each polygonal bump face is fan-triangulated from its first + corner. For a planar polygon every fan gives the same geometry, but + for a *non-planar* one the resulting facet areas depend on which + corner the fan starts from. We measured up to 3.8% per polygon on a + high-curvature field. ``populateContourMeshBlueprint`` returns the + un-triangulated polygons and is unaffected. + +``MarchingCubes`` normalizes one bump behavior rather than exposing it. +bump's intersector classifies a corner as inside with a strict ``>``, +while the legacy kernel uses ``>=``. A node lying exactly on the +isovalue would therefore be classified oppositely by the two backends, +attributing the same surface to parent cells one cell layer apart +whenever the isovalue coincides with nodal values. ``MarchingCubes`` +passes bump the next representable value below the requested isovalue so +that the two agree. A direct ``axom::bump::extraction::CutField`` call +at the same nominal isovalue keeps bump's own convention. + +``MarchingCubes::setRobustnessPolicy`` reserves +``MarchingCubesRobustnessPolicy::robust`` for a future topologically +robust bump intersector. Until that intersector is available, +selecting ``robust`` behaves identically to ``standard``; callers may +opt in now to benefit automatically once it lands. + .. sourcecode:: C++ conduit::Node blueprintMesh = blueprint_mesh_from_user(); @@ -106,7 +175,11 @@ Place the isocontour in an output ``mint::UnstructuredMesh`` object: ``MarchingCubes`` generates the isocontour mesh in an internal format. Use ``populateContourMesh`` to put it in a ``mint::UnstructuredMesh`` -object. In the future, we will support outputs in blueprint format. +object. In 3D this method always produces triangles, including when the +bump backend first produced polygonal ``CutField`` faces internally. +When the bump backend is enabled, +``populateContourMeshBlueprint`` and ``relinquishContourDataBlueprint`` +provide the richer welded Blueprint output directly. ``populateContourMesh`` provides two scalar fields for the generated mesh: diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index e168c25f03..76892a7788 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -511,13 +511,8 @@ if(AXOM_ENABLE_MPI AND AXOM_ENABLE_SIDRE AND HDF5_FOUND) endif() # Marching cubes example ------------------------------------------- -if(CONDUIT_FOUND AND AXOM_ENABLE_MPI) - list(APPEND quest_depends_on conduit::conduit - conduit::conduit_mpi) -endif() - -if(CONDUIT_FOUND) - set(quest_marching_cubes_depends ${quest_example_depends} conduit::conduit) +if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) + set(quest_marching_cubes_depends ${quest_example_depends} conduit::conduit bump) blt_list_append(TO quest_marching_cubes_depends IF AXOM_ENABLE_MPI ELEMENTS conduit::conduit_mpi) axom_add_executable( @@ -539,10 +534,10 @@ if(CONDUIT_FOUND) # Non-zero empty-rank probability tests domain underloading case set(_meshes "mdmesh.2x1" "mdmesh.2x3" "mdmesh.2x2x1" "mdmeshg.2x2x1") # The amc.* files were generated by these commands: - # src/tools/gen-multidom-structured-mesh.py -ml=0,0 -mu=2,2 -ms=100,100 -dc=2,1 -o mdmesh.2x1 - # src/tools/gen-multidom-structured-mesh.py -ml=0,0 -mu=2,2 -ms=100,100 -dc=2,3 -o mdmesh.2x3 - # src/tools/gen-multidom-structured-mesh.py -ml=0,0,0 -mu=2,2,2 -ms=20,20,15 -dc=2,2,1 -o mdmesh.2x2x1 - # src/tools/gen-multidom-structured-mesh.py -ml=0,0,0 -mu=2,2,2 -ms=20,20,15 -dc=2,2,1 -o mdmeshg.2x2x1 --strided + # src/tools/gen-multidom-mesh.py -ml=0,0 -mu=2,2 -ms=100,100 -dc=2,1 -o mdmesh.2x1 + # src/tools/gen-multidom-mesh.py -ml=0,0 -mu=2,2 -ms=100,100 -dc=2,3 -o mdmesh.2x3 + # src/tools/gen-multidom-mesh.py -ml=0,0,0 -mu=2,2,2 -ms=20,20,15 -dc=2,2,1 -o mdmesh.2x2x1 + # src/tools/gen-multidom-mesh.py -ml=0,0,0 -mu=2,2,2 -ms=20,20,15 -dc=2,2,1 -o mdmeshg.2x2x1 --strided foreach(_pol ${AXOM_EXECUTION_POLICIES}) # sets the number of threads for the omp policy; leave empty for non-omp policies set(_num_threads) @@ -585,10 +580,86 @@ if(CONDUIT_FOUND) endforeach() endforeach() + # Exercise the example's bump-backend CLI path across the same structured + # multidomain meshes as the legacy path; the dedicated + # quest_marching_cubes_bump test covers unstructured input. + set(_bump_meshes "mdmesh.2x1" "mdmesh.2x3" "mdmesh.2x2x1" "mdmeshg.2x2x1") + foreach(_pol ${AXOM_EXECUTION_POLICIES}) + set(_num_threads) + if(_pol STREQUAL "omp") + set(_num_threads ${AXOM_TEST_NUM_OMP_THREADS}) + endif() + + foreach(_mesh ${_bump_meshes}) + string(REGEX MATCH "\\.[0-9]+(x[0-9]+)+$" _sizes "${_mesh}") + string(REGEX MATCHALL "[0-9]+" _sizes ${_sizes}) + list(LENGTH _sizes _ndim) + + if(_ndim EQUAL 2) + set(_dir 1.0 0.4) + set(_center 1.0 0.4) + set(_scale 3 3) + elseif(_ndim EQUAL 3) + set(_dir 1.0 0.4 1.2) + set(_center 1.0 0.4 1.2) + set(_scale 3 3 1.5) + endif() + + set(_test "quest_marching_cubes_bump_run_${_ndim}D_${_pol}_${_mesh}") + axom_add_test( + NAME ${_test} + COMMAND quest_marching_cubes_ex + --policy ${_pol} + --mesh-file ${quest_data_dir}/${_mesh}.root + --fields-file ${_test}.field + --dir ${_dir} + --center ${_center} + --scale ${_scale} + --contourVal 1.25 + --useBumpBackend + --check-results + NUM_MPI_TASKS ${_nranks} + NUM_OMP_THREADS ${_num_threads}) + set_tests_properties(${_test} PROPERTIES + PASS_REGULAR_EXPRESSION "Contour mesh has .* cells") + endforeach() + endforeach() + unset(_nranks) unset(_test) endif() + #-------------------------------------------------------------------------- + # Unstructured coverage for the bump backend. + # generated via: + # src/tools/gen-multidom-mesh.py \ + # -ml 0,0,0 -mu 1,1,1 -ms 12,12,12 -dc 1,1,1 --topology unstructured \ + # --field sphere --center 0.5,0.5,0.5 --radius 0.25 --protocol hdf5 + #-------------------------------------------------------------------------- + if(AXOM_ENABLE_TESTS AND AXOM_DATA_DIR AND NOT WIN32 AND HDF5_FOUND) + set(_mc_unstructured_mesh "${quest_data_dir}/mc_uhex.root") + foreach(_pol ${AXOM_EXECUTION_POLICIES}) + set(_test "quest_marching_cubes_bump_run_3D_${_pol}_unstructured_hex") + axom_add_test( + NAME ${_test} + COMMAND quest_marching_cubes_ex + --policy ${_pol} + --useBumpBackend + --mesh-file ${_mc_unstructured_mesh} + --center 0.5 0.5 0.5 + --contourVal 0.25 + --blueprint-contour-file ${_test}.contour + NUM_MPI_TASKS 1 + ) + set_tests_properties(${_test} + PROPERTIES PROCESSORS 1 + REQUIRED_FILES "${_mc_unstructured_mesh}" + PASS_REGULAR_EXPRESSION "Contour mesh has") + endforeach() + unset(_test) + unset(_mc_unstructured_mesh) + endif() + endif() # Point in cell example ------------------------------------------------------- diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 3c19f9734f..9ac1cbdeac 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -5,37 +5,39 @@ // SPDX-License-Identifier: (BSD-3-Clause) /*! - \file marching_cubes_example.cpp - \brief Driver and test for a marching cubes isocontour generation - - The test can generate planar and round contours. Planar contours - can be checked to machine-zero accuracy, but it doesn't test a great - variety of contour-mesh intersection types. Round contours can - check more intersection types but requires a tolerance to allow - for the function not varying linearly along mesh lines. -*/ + * \file marching_cubes_example.cpp + * \brief Driver and test for a marching cubes isocontour generation + * + * The test can generate planar and round contours. + * Planar contours can be checked to machine-zero accuracy, + * but it doesn't test a great variety of contour-mesh intersection types. + * Round contours can check more intersection types but requires a tolerance + * since the function is nonlinear + */ #include "axom/config.hpp" -// Implementation requires Conduit. +// This example requires Conduit and bump #ifndef AXOM_USE_CONDUIT #error "MarchingCubesFullParallel.hpp requires conduit" #endif +#ifndef AXOM_USE_BUMP + #error "quest_marching_cubes_example.cpp requires bump" +#endif // Axom includes #include "axom/core.hpp" -#include "axom/core/NumericLimits.hpp" #include "axom/slic.hpp" #include "axom/primal.hpp" +#include "axom/bump/utilities/conduit_memory.hpp" +#include "axom/bump/views/Shapes.hpp" #include "axom/mint/mesh/UnstructuredMesh.hpp" -#include "axom/core/MDMapping.hpp" #include "axom/quest/MarchingCubes.hpp" #include "axom/quest/MeshViewUtil.hpp" + #if defined(AXOM_USE_SIDRE) #include "axom/sidre.hpp" #endif -#include "axom/core/Types.hpp" -#include "axom/core/numerics/floating_point_limits.hpp" #include "conduit_blueprint.hpp" #include "conduit_relay_io_blueprint.hpp" @@ -56,6 +58,10 @@ #include #include #include +#include +#include +#include +#include namespace quest = axom::quest; namespace slic = axom::slic; @@ -63,23 +69,28 @@ namespace slic = axom::slic; namespace sidre = axom::sidre; #endif namespace primal = axom::primal; +namespace bumpviews = axom::bump::views; namespace mint = axom::mint; namespace numerics = axom::numerics; using RuntimePolicy = axom::runtime_policy::Policy; -/////////////////////////////////////////////////////////////// +//----------------------------------------------------------------------------- // converts the input string into an 80 character string // padded on both sides with '=' symbols +//----------------------------------------------------------------------------- std::string banner(const std::string& str) { return axom::fmt::format("{:=^80}", str); } -/////////////////////////////////////////////////////////////// -/// Struct to parse and store the input parameters +//----------------------------------------------------------------------------- +// Struct to parse and store the input parameters +//----------------------------------------------------------------------------- struct Input { public: std::string meshFile; std::string fieldsFile {"fields"}; + //! @brief Also emit bump's welded polygonal contour as a Blueprint mesh. + std::string blueprintContourFile {}; // Center of round contour function std::vector fcnCenter; @@ -101,26 +112,33 @@ struct Input quest::MarchingCubesDataParallelism dataParallelism = quest::MarchingCubesDataParallelism::byPolicy; + // Use the bump CutField backend (supports unstructured quad/hex) vs legacy. + bool useBumpBackend {false}; + + // Bump-backend isosurface robustness policy (Phase 6 seam). + quest::MarchingCubesRobustnessPolicy robustnessPolicy = + quest::MarchingCubesRobustnessPolicy::standard; + // Distinct MarchingCubes objects count. - int objectRepCount = 1; + int objectRepCount {1}; // Contour generation count for each MarchingCubes objects. - int contourGenCount = 1; + int contourGenCount {1}; // Number of masking cycles. - int maskCount = 1; + int maskCount {1}; std::string annotationMode {"none"}; private: bool _verboseOutput {false}; - // clang-format off - const std::map s_validImplChoices - { - {"byPolicy", quest::MarchingCubesDataParallelism::byPolicy} - , {"hybridParallel", quest::MarchingCubesDataParallelism::hybridParallel} - , {"fullParallel", quest::MarchingCubesDataParallelism::fullParallel} - }; - // clang-format on + const std::map s_validImplChoices { + {"byPolicy", quest::MarchingCubesDataParallelism::byPolicy}, + {"hybridParallel", quest::MarchingCubesDataParallelism::hybridParallel}, + {"fullParallel", quest::MarchingCubesDataParallelism::fullParallel}}; + + const std::map s_validRobustnessPolicies { + {"standard", quest::MarchingCubesRobustnessPolicy::standard}, + {"robust", quest::MarchingCubesRobustnessPolicy::robust}}; public: bool isVerbose() const { return _verboseOutput; } @@ -133,16 +151,36 @@ struct Input ->transform(axom::CLI::CheckedTransformer(axom::runtime_policy::s_nameToPolicy)); app.add_option("--dataParallelism", dataParallelism) - ->description("Set full or partial data-parallelism, or by-policy") + ->description( + "Set full or partial data-parallelism, or by-policy, for the legacy backend " + "(ignored by --useBumpBackend)") ->capture_default_str() ->transform(axom::CLI::CheckedTransformer(s_validImplChoices)); + app.add_flag("--useBumpBackend", useBumpBackend) + ->description( + "Use the bump CutField backend (adds unstructured quad/hex support) " + "instead of the legacy structured-only marching cubes kernel") + ->capture_default_str(); + + app.add_option("--robustnessPolicy", robustnessPolicy) + ->description( + "Bump-backend isosurface robustness: 'standard' (default) or 'robust' " + "(reserved; currently behaves as standard)") + ->capture_default_str() + ->transform(axom::CLI::CheckedTransformer(s_validRobustnessPolicies)); + app.add_option("-m,--mesh-file", meshFile) ->description( - "Path to multidomain computational mesh following conduit blueprint " - "convention.") + "Path to multidomain computational mesh following conduit blueprint convention.") ->check(axom::CLI::ExistingFile); + app.add_option("--blueprint-contour-file", blueprintContourFile) + ->description( + "Write the bump backend's welded polygonal contour to this Blueprint file " + "as Blueprint output. Requires --useBumpBackend.") + ->capture_default_str(); + app.add_option("-s,--fields-file", fieldsFile) ->description("Name of output mesh file with all its fields.") ->capture_default_str(); @@ -229,8 +267,7 @@ struct Input (inPlane.empty() || inPlane.size() == ndim) && (perpDir.empty() || perpDir.size() == ndim) && (gyroidScale.empty() || gyroidScale.size() == ndim), - "fcnCenter, inPlane and perpDir must have consistent sizes " - "if specified."); + "fcnCenter, inPlane and perpDir must have consistent sizes if specified."); // inPlane defaults to origin if omitted. if(usingPlanar() && inPlane.empty()) @@ -275,26 +312,6 @@ struct Input //!@brief Our allocator id, based on execution policy. static int s_allocatorId = axom::INVALID_ALLOCATOR_ID; // Set in main. -//!@brief Put a conduit::Node array data into the specified memory space. -template -void moveConduitDataToNewMemorySpace(conduit::Node& node, const std::string& path, int allocId) -{ - conduit::Node& dataNode = node.fetch_existing(path); - SLIC_ASSERT(!dataNode.dtype().is_empty() && !dataNode.dtype().is_object() && - !dataNode.dtype().is_list()); - - std::size_t count = dataNode.dtype().number_of_elements(); - T* oldPtr = static_cast(dataNode.data_ptr()); - bool deleteOld = dataNode.is_data_external(); - T* newPtr = axom::allocate(count, allocId); - axom::copy(newPtr, oldPtr, count * sizeof(T)); - dataNode.set_external(newPtr, count); - if(deleteOld) - { - axom::deallocate(oldPtr); - } -} - void getIntMinMax(int inVal, int& minVal, int& maxVal, int& sumVal) { #ifdef AXOM_USE_MPI @@ -308,26 +325,53 @@ void getIntMinMax(int inVal, int& minVal, int& maxVal, int& sumVal) #endif } -Input params; +void loadBlueprintMesh(const std::string& meshFilename, conduit::Node& mesh) +{ +#ifdef AXOM_USE_MPI + conduit::relay::mpi::io::blueprint::load_mesh(meshFilename, mesh, MPI_COMM_WORLD); +#else + conduit::relay::io::blueprint::load_mesh(meshFilename, mesh); +#endif +} + +bool verifyBlueprintMesh(const conduit::Node& mesh, conduit::Node& info) +{ +#ifdef AXOM_USE_MPI + return conduit::blueprint::mpi::verify("mesh", mesh, info, MPI_COMM_WORLD); +#else + return conduit::blueprint::verify("mesh", mesh, info); +#endif +} int myRank = -1, numRanks = -1; // MPI stuff, set in main(). -/** - \brief Generic computational mesh, to hold cell and node data. -*/ +/// \brief Generic computational mesh, to hold cell and node data. struct BlueprintStructuredMesh { public: - explicit BlueprintStructuredMesh(const std::string& meshFile, const std::string& topologyName) + explicit BlueprintStructuredMesh(const std::string& meshFile, + const std::string& topologyName, + bool verboseOutput = false) : _topologyName(topologyName) , _topologyPath("topologies/" + topologyName) { readBlueprintMesh(meshFile); - for(int d = 0; d < _mdMesh.number_of_children(); ++d) + + if(verboseOutput) { - auto dl = domainLengths(d); - SLIC_INFO_IF(params.isVerbose(), axom::fmt::format("dom[{}] size={}", d, dl)); + for(int d = 0; d < _mdMesh.number_of_children(); ++d) + { + if(isStructured(d)) + { + SLIC_INFO(axom::fmt::format("dom[{}] size={}", d, domainLengths(d))); + } + else + { + SLIC_INFO(axom::fmt::format("dom[{}] cells={}, nodes={}", d, cellCount(d), nodeCount(d))); + } + } } + _maxSpacing = maxSpacing(); } @@ -366,14 +410,15 @@ struct BlueprintStructuredMesh } /*! - @brief Get the number of cells in each direction of a blueprint single domain. - - @param domId Index of domain - @lengths Space for dimension() numbers. - */ + * @brief Get the number of cells in each direction of a blueprint single domain. + * + * @param domId Index of domain + * @param lengths Space for dimension() numbers. + */ void domainLengths(axom::IndexType domId, axom::IndexType* lengths) const { const conduit::Node& dom = domain(domId); + SLIC_ASSERT_MSG(isStructured(domId), "domainLengths() is only defined for structured domains."); SLIC_ASSERT_MSG(dom.fetch_existing(_coordsetPath + "/type").as_string() == "explicit", axom::fmt::format("Currently only supporting explicit coordinate types." " '{}/type' is '{}'", @@ -382,7 +427,7 @@ struct BlueprintStructuredMesh const conduit::Node& dimsNode = dom.fetch_existing(_topologyPath + "/elements/dims"); for(int i = 0; i < _ndims; ++i) { - lengths[i] = dimsNode[i].as_int(); + lengths[i] = static_cast(dimsNode[i].to_int64()); } } @@ -396,13 +441,18 @@ struct BlueprintStructuredMesh /// Returns the number of cells in a domain int cellCount(axom::IndexType domId) const { - auto shape = domainLengths(domId); - int rval = 1; - for(const auto& l : shape) + if(isStructured(domId)) { - rval *= l; + const auto shape = domainLengths(domId); + int rval = 1; + for(const auto& l : shape) + { + rval *= l; + } + return rval; } - return rval; + return static_cast( + conduit::blueprint::mesh::topology::length(domain(domId).fetch_existing(_topologyPath))); } /// Returns the number of cells in all mesh domains @@ -419,13 +469,18 @@ struct BlueprintStructuredMesh /// Returns the number of nodes in a domain int nodeCount(axom::IndexType domId) const { - auto shape = domainLengths(domId); - int rval = 1; - for(const auto& l : shape) + if(isStructured(domId)) { - rval *= 1 + l; + auto shape = domainLengths(domId); + int rval = 1; + for(const auto& l : shape) + { + rval *= 1 + l; + } + return rval; } - return rval; + return static_cast( + conduit::blueprint::mesh::coordset::length(domain(domId).fetch_existing(_coordsetPath))); } /// Returns the number of nodes in all mesh domains @@ -441,14 +496,52 @@ struct BlueprintStructuredMesh int dimension() const { return _ndims; } - const std::string& coordsetPath() const { return _coordsetPath; } + std::string topologyType(axom::IndexType domId) const + { + return domain(domId).fetch_existing(_topologyPath + "/type").as_string(); + } + + bool isStructured(axom::IndexType domId) const { return topologyType(domId) == "structured"; } + + bool isUnstructured(axom::IndexType domId) const { return topologyType(domId) == "unstructured"; } + + bool isStridedStructured(axom::IndexType domId) const + { + return isStructured(domId) && + domain(domId).fetch_existing(_topologyPath + "/elements/dims").has_child("strides"); + } /*! - @return largest mesh spacing. + * @brief Whether this domain's field arrays can be indexed as a flat, compact array of node values. + * + * This is false only for strided structured topologies whose fields live in a ghost padded window + * and must be indexed through the field offsets and strides. + */ + bool useFlatFields(axom::IndexType domId) const { return !isStridedStructured(domId); } + + const std::string& coordsetPath() const { return _coordsetPath; } - Compute only once, because after that, coordinates data may be - moved to devices. - */ + //! @brief Corner node ids of a zone, for an unstructured single-shape topology. + void unstructuredCellNodeIds(axom::IndexType domId, + axom::IndexType cellId, + axom::Array& nodeIds) const + { + const conduit::Node& elems = domain(domId).fetch_existing(_topologyPath + "/elements"); + const std::string shape = elems.fetch_existing("shape").as_string(); + const axom::IndexType cornersPerCell = shape == "hex" ? 8 : 4; + const auto conn = elems.fetch_existing("connectivity").as_index_t_accessor(); + nodeIds.resize(cornersPerCell); + for(axom::IndexType c = 0; c < cornersPerCell; ++c) + { + nodeIds[c] = static_cast(conn[cellId * cornersPerCell + c]); + } + } + + /*! + * @return largest mesh spacing. + * + * Compute only once, because after that, coordinates data may be moved to devices. + */ double maxSpacing() const { if(_maxSpacing >= 0) @@ -472,19 +565,27 @@ struct BlueprintStructuredMesh } /*! - @return largest mesh spacing in a domain. - - This method takes shortcuts by assuming - the mesh is structured and cartesian, with explicit coordinates. - */ + * @return largest mesh spacing in a domain. + * + * This method takes shortcuts by assuming the mesh is structured and cartesian, with explicit coordinates. + */ double maxSpacing1(axom::IndexType domId) const { const conduit::Node& dom = domain(domId); + if(useFlatFields(domId) && isStructured(domId)) + { + return maxStructuredEdgeLengthFlat(dom); + } + if(isUnstructured(domId)) + { + return maxUnstructuredEdgeLength(dom); + } + const conduit::Node& dimsNode = dom.fetch_existing("topologies/mesh/elements/dims"); axom::Array ls(_ndims); for(int d = 0; d < _ndims; ++d) { - ls[d] = 1 + dimsNode[d].as_int(); + ls[d] = 1 + static_cast(dimsNode[d].to_int64()); } double rval = 0.0; @@ -509,15 +610,128 @@ struct BlueprintStructuredMesh return rval; } + double maxStructuredEdgeLengthFlat(const conduit::Node& dom) const + { + const conduit::Node& dimsNode = dom.fetch_existing("topologies/mesh/elements/dims"); + axom::StackArray nodeShape {{1, 1, 1}}; + nodeShape[0] = dimsNode.fetch_existing("i").to_int64() + 1; + nodeShape[1] = dimsNode.fetch_existing("j").to_int64() + 1; + if(_ndims == 3) + { + nodeShape[2] = dimsNode.fetch_existing("k").to_int64() + 1; + } + + const conduit::Node& coords = dom.fetch_existing(_coordsetPath + "/values"); + const auto xs = coords.fetch_existing("x").as_double_accessor(); + const auto ys = coords.fetch_existing("y").as_double_accessor(); + const bool hasZ = _ndims == 3; + const auto zs = hasZ ? coords.fetch_existing("z").as_double_accessor() + : coords.fetch_existing("x").as_double_accessor(); + + auto nodeIndex = [&](axom::IndexType i, axom::IndexType j, axom::IndexType k) { + return i + j * nodeShape[0] + k * nodeShape[0] * nodeShape[1]; + }; + + double maxLen = 0.0; + for(axom::IndexType k = 0; k < nodeShape[2]; ++k) + { + for(axom::IndexType j = 0; j < nodeShape[1]; ++j) + { + for(axom::IndexType i = 0; i < nodeShape[0]; ++i) + { + const axom::IndexType a = nodeIndex(i, j, k); + const axom::IndexType maxAxis = hasZ ? 3 : 2; + for(axom::IndexType axis = 0; axis < maxAxis; ++axis) + { + axom::IndexType ni = i, nj = j, nk = k; + if(axis == 0) + { + ++ni; + } + else if(axis == 1) + { + ++nj; + } + else + { + ++nk; + } + if(ni >= nodeShape[0] || nj >= nodeShape[1] || nk >= nodeShape[2]) + { + continue; + } + const axom::IndexType b = nodeIndex(ni, nj, nk); + const double dx = xs[a] - xs[b]; + const double dy = ys[a] - ys[b]; + const double dz = hasZ ? zs[a] - zs[b] : 0.0; + maxLen = std::max(maxLen, std::sqrt(dx * dx + dy * dy + dz * dz)); + } + } + } + } + return maxLen; + } + + /*! + * @brief Longest cell edge over an unstructured single-shape topology. + * + * This uses bump's shape traits for edge connectivity. + */ + template + double maxEdgeLengthForTraits(const conduit::Node& topo, const conduit::Node& coords) const + { + const auto xs = coords.fetch_existing("x").as_double_accessor(); + const auto ys = coords.fetch_existing("y").as_double_accessor(); + const bool hasZ = _ndims == 3; + const auto zs = hasZ ? coords.fetch_existing("z").as_double_accessor() + : coords.fetch_existing("x").as_double_accessor(); + const auto conn = topo.fetch_existing("elements/connectivity").as_index_t_accessor(); + + constexpr auto cornersPerCell = ShapeTraits::numberOfNodes(); + constexpr auto edgeCount = ShapeTraits::numberOfEdges(); + const axom::IndexType numCells = + static_cast(conn.number_of_elements()) / cornersPerCell; + + double maxLen = 0.0; + for(axom::IndexType cell = 0; cell < numCells; ++cell) + { + for(int e = 0; e < edgeCount; ++e) + { + const auto edge = ShapeTraits::getEdge(e); + const auto a = static_cast(conn[cell * cornersPerCell + edge[0]]); + const auto b = static_cast(conn[cell * cornersPerCell + edge[1]]); + const double dx = xs[a] - xs[b]; + const double dy = ys[a] - ys[b]; + const double dz = hasZ ? zs[a] - zs[b] : 0.0; + maxLen = std::max(maxLen, std::sqrt(dx * dx + dy * dy + dz * dz)); + } + } + return maxLen; + } + + double maxUnstructuredEdgeLength(const conduit::Node& dom) const + { + const conduit::Node& topo = dom.fetch_existing(_topologyPath); + const conduit::Node& coords = dom.fetch_existing(_coordsetPath + "/values"); + const std::string shape = topo.fetch_existing("elements/shape").as_string(); + + if(shape == "hex") + { + return maxEdgeLengthForTraits(topo, coords); + } + if(shape == "quad") + { + return maxEdgeLengthForTraits(topo, coords); + } + SLIC_ERROR(axom::fmt::format("Unsupported unstructured shape '{}'.", shape)); + return 0.0; + } + /// Checks whether the blueprint is valid and prints diagnostics bool isValid() const { conduit::Node info; -#ifdef AXOM_USE_MPI - if(!conduit::blueprint::mpi::verify("mesh", _mdMesh, info, MPI_COMM_WORLD)) -#else - if(!conduit::blueprint::verify("mesh", _mdMesh, info)) -#endif + if(!verifyBlueprintMesh(_mdMesh, info)) { SLIC_INFO("Invalid blueprint for mesh: \n" << info.to_yaml()); slic::flushStreams(); @@ -528,47 +742,66 @@ struct BlueprintStructuredMesh void printMeshInfo() const { _mdMesh.print(); } - /*! - @param[in] path Path to existing data in the blueprint mesh, - relative to each domain in the mesh. - @param[in] allocId Allocator id for the new memory space. - @tparam Type of data being moved. Should be something Conduit - supports, i.e., not custom user data. - */ - template - void moveMeshDataToNewMemorySpace(const std::string& path, int allocId) + template + void copyMeshToMemorySpace(int allocId = axom::execution_space::allocatorID()) { - AXOM_ANNOTATE_SCOPE("moveMeshDataToNewMemorySpace"); - for(auto& dom : _mdMesh.children()) - { - moveConduitDataToNewMemorySpace(dom, path, allocId); - } + AXOM_ANNOTATE_SCOPE("copyMeshToMemorySpace"); + conduit::Node newMesh; + axom::bump::utilities::copy(newMesh, _mdMesh, allocId); + _mdMesh.swap(newMesh); } private: int _ndims {-1}; conduit::Node _mdMesh; axom::IndexType _domCount; - bool _coordsAreStrided = false; const std::string _topologyName; const std::string _topologyPath; std::string _coordsetPath; double _maxSpacing = -1.0; - /*! - @brief Read a blueprint mesh into conduit::Node _mdMesh. - */ + axom::IndexType dimValue(const conduit::Node& node, int dim, axom::IndexType defaultValue = 0) const + { + static const char* dimNames[] = {"i", "j", "k"}; + if(node.has_child(dimNames[dim])) + { + return static_cast(node.fetch_existing(dimNames[dim]).to_int64()); + } + if(node.dtype().is_int32()) + { + return static_cast(node.as_int32_ptr()[dim]); + } + if(node.dtype().is_int64()) + { + return static_cast(node.as_int64_ptr()[dim]); + } + if(dim < node.number_of_children()) + { + return static_cast(node[dim].to_int64()); + } + return defaultValue; + } + + //! @brief Read a blueprint mesh into conduit::Node _mdMesh. void readBlueprintMesh(const std::string& meshFilename) { SLIC_ASSERT(!meshFilename.empty()); + conduit::Node loadedMesh; + loadBlueprintMesh(meshFilename, loadedMesh); + // Normalize to a multi-domain node. MarchingCubes::setMesh() performs the + // equivalent normalization for its own input; this wrapper still needs its own + // copy because domainLengths(), cellCount(), and the coordset helpers below + // operate on it independently of the query object. _mdMesh.reset(); -#ifdef AXOM_USE_MPI - conduit::relay::mpi::io::blueprint::load_mesh(meshFilename, _mdMesh, MPI_COMM_WORLD); -#else - conduit::relay::io::blueprint::load_mesh(meshFilename, _mdMesh); -#endif - SLIC_ASSERT(conduit::blueprint::mesh::is_multi_domain(_mdMesh)); + if(conduit::blueprint::mesh::is_multi_domain(loadedMesh)) + { + _mdMesh.swap(loadedMesh); + } + else + { + _mdMesh.append().set(loadedMesh); + } _domCount = conduit::blueprint::mesh::number_of_domains(_mdMesh); if(_domCount > 0) @@ -578,8 +811,6 @@ struct BlueprintStructuredMesh _coordsetPath = axom::fmt::format("coordsets/{}", coordsetName); SLIC_ASSERT(_mdMesh[0].has_path(_coordsetPath)); - _coordsAreStrided = - _mdMesh[0].fetch_existing(_topologyPath + "/elements/dims").has_child("strides"); const conduit::Node coordsetNode = _mdMesh[0].fetch_existing(_coordsetPath); _ndims = conduit::blueprint::mesh::coordset::dims(coordsetNode); } @@ -656,11 +887,7 @@ void saveMesh(const sidre::Group& mesh, const std::string& filename) mesh.createNativeLayout(tmpMesh); { conduit::Node info; - #ifdef AXOM_USE_MPI - if(!conduit::blueprint::mpi::verify("mesh", tmpMesh, info, MPI_COMM_WORLD)) - #else - if(!conduit::blueprint::verify("mesh", tmpMesh, info)) - #endif + if(!verifyBlueprintMesh(tmpMesh, info)) { SLIC_INFO("Invalid blueprint for mesh: \n" << info.to_yaml()); slic::flushStreams(); @@ -693,11 +920,10 @@ static void addToStackArray(axom::StackArray& a, U b) } /*! - @brief Strategy pattern for supporting a variety of contour types. - - The strategy encapsulates the scalar functions and things related to - it. -*/ + * @brief Strategy pattern for supporting a variety of contour types. + * + * The strategy encapsulates the scalar functions and things related to it. + */ template struct ContourTestStrategy { @@ -722,10 +948,11 @@ template struct ContourTestBase { static constexpr auto MemorySpace = axom::execution_space::memory_space; + static constexpr double BumpGeometryToleranceScale = 1.e-5; using PointType = axom::primal::Point; - // ContourTestBase(const std::shared_ptr>& testStrategy) - ContourTestBase() - : m_testStrategies() + explicit ContourTestBase(const Input& params) + : m_params(params) + , m_testStrategies() , m_parentCellIdField("parentCellIds") , m_domainIdField("domainIdField") { } @@ -737,6 +964,7 @@ struct ContourTestBase SLIC_INFO(axom::fmt::format("Add test {}.", testStrategy->testName())); } + const Input& m_params; axom::Array>> m_testStrategies; //!@brief Prefix sum of facet counts from test strategies. axom::Array m_strategyFacetPrefixSum; @@ -744,6 +972,13 @@ struct ContourTestBase const std::string m_parentCellIdField; const std::string m_domainIdField; + double geometryTolerance(const BlueprintStructuredMesh& computationalMesh) const + { + // Bump's current isosurface intersector computes interpolation in float. + return m_params.useBumpBackend ? BumpGeometryToleranceScale * computationalMesh.maxSpacing() + : axom::numerics::floating_point_limits::epsilon(); + } + int runTest(BlueprintStructuredMesh& computationalMesh) { AXOM_ANNOTATE_SCOPE("runTest"); @@ -753,22 +988,7 @@ struct ContourTestBase { AXOM_ANNOTATE_SCOPE("move mesh to device memory"); - const std::string axes[3] = {"x", "y", "z"}; - for(int d = 0; d < DIM; ++d) - { - computationalMesh.moveMeshDataToNewMemorySpace( - computationalMesh.coordsetPath() + "/values/" + axes[d], - s_allocatorId); - } - for(const auto& strategy : m_testStrategies) - { - computationalMesh.moveMeshDataToNewMemorySpace( - axom::fmt::format("fields/{}/values", strategy->functionName()), - s_allocatorId); - } - computationalMesh.moveMeshDataToNewMemorySpace( - axom::fmt::format("fields/{}/values", "mask"), - s_allocatorId); + computationalMesh.template copyMeshToMemorySpace(s_allocatorId); } #if defined(AXOM_USE_UMPIRE) @@ -793,14 +1013,14 @@ struct ContourTestBase resourceName = allocator.getName(); } SLIC_INFO(axom::fmt::format("Testing with policy {} and function data on {}", - params.policy, + m_params.policy, resourceName)); - if(params.policy == axom::runtime_policy::Policy::seq) + if(m_params.policy == axom::runtime_policy::Policy::seq) { SLIC_ASSERT(resourceName == "HOST"); } #if defined(AXOM_RUNTIME_POLICY_USE_OPENMP) - else if(params.policy == axom::runtime_policy::Policy::omp) + else if(m_params.policy == axom::runtime_policy::Policy::omp) { SLIC_ASSERT(resourceName == "HOST"); } @@ -822,7 +1042,7 @@ struct ContourTestBase // All contourGenCount loops. axom::utilities::Timer contourGenLoopTimer(false); - // params.objectRepCount setMesh calls + // objectRepCount setMesh calls axom::utilities::Timer setMeshTimer(false); // Time steady-state computeIsocontour calls @@ -832,17 +1052,20 @@ struct ContourTestBase axom::utilities::Timer contourTimerM(false); std::unique_ptr mcPtr; - const auto objectLoopName = axom::fmt::format("objectRepLoop {}", params.objectRepCount); + const auto objectLoopName = axom::fmt::format("objectRepLoop {}", m_params.objectRepCount); AXOM_ANNOTATE_BEGIN(objectLoopName); objectRepLoopTimer.start(); - for(int j = 0; j < params.objectRepCount; ++j) + for(int j = 0; j < m_params.objectRepCount; ++j) { if(!mcPtr) { AXOM_ANNOTATE_SCOPE("MCInit"); initializationTimer.start(); - mcPtr = - std::make_unique(params.policy, s_allocatorId, params.dataParallelism); + mcPtr = std::make_unique(m_params.policy, + s_allocatorId, + m_params.dataParallelism); + mcPtr->setUseBumpBackend(m_params.useBumpBackend); + mcPtr->setRobustnessPolicy(m_params.robustnessPolicy); mcPtr->setMesh(computationalMesh.asConduitNode(), "mesh", "mask"); initializationTimer.stop(); } @@ -858,20 +1081,20 @@ struct ContourTestBase #endif contourGenLoopTimer.start(); - for(int i = 0; i < params.contourGenCount; ++i) + for(int i = 0; i < m_params.contourGenCount; ++i) { SLIC_DEBUG(axom::fmt::format("MarchingCubes object rep {} of {}, contour run {} of {}:", j, - params.objectRepCount, + m_params.objectRepCount, i, - params.contourGenCount)); + m_params.contourGenCount)); mc.clearOutput(); m_strategyFacetPrefixSum.clear(); m_strategyFacetPrefixSum.push_back(0); for(const auto& strategy : m_testStrategies) { mc.setFunctionField(strategy->functionName()); - for(int iMask = 0; iMask < params.maskCount; ++iMask) + for(int iMask = 0; iMask < m_params.maskCount; ++iMask) { mc.setMaskValue(iMask); if(i == 0) @@ -882,7 +1105,7 @@ struct ContourTestBase { contourTimer.start(); } - mc.computeIsocontour(params.contourVal); + mc.computeIsocontour(m_params.contourVal); if(i == 0) { contourTimerM.stop(); @@ -900,8 +1123,8 @@ struct ContourTestBase objectRepLoopTimer.stop(); AXOM_ANNOTATE_END(objectLoopName); SLIC_INFO(axom::fmt::format("Finished {} object reps x {} contour reps", - params.objectRepCount, - params.contourGenCount)); + m_params.objectRepCount, + m_params.contourGenCount)); printTimingStats(initializationTimer, axom::fmt::format("init")); printTimingStats(contourTimerM, axom::fmt::format("setMeshContour")); printTimingStats(setMeshTimer, axom::fmt::format("setMesh")); @@ -918,21 +1141,7 @@ struct ContourTestBase { AXOM_ANNOTATE_SCOPE("copy mesh back to host memory"); - const std::string axes[3] = {"x", "y", "z"}; - for(int d = 0; d < DIM; ++d) - { - computationalMesh.moveMeshDataToNewMemorySpace( - computationalMesh.coordsetPath() + "/values/" + axes[d], - axom::execution_space::allocatorID()); - } - for(const auto& strategy : m_testStrategies) - { - computationalMesh.moveMeshDataToNewMemorySpace( - axom::fmt::format("fields/{}/values", strategy->functionName()), - axom::execution_space::allocatorID()); - } - computationalMesh.moveMeshDataToNewMemorySpace( - axom::fmt::format("fields/{}/values", "mask"), + computationalMesh.template copyMeshToMemorySpace( axom::execution_space::allocatorID()); } @@ -960,6 +1169,27 @@ struct ContourTestBase extractTimer.stop(); printTimingStats(extractTimer, "extract"); + // Demonstrate the bump backend's native output: a welded, connected, polygonal contour mesh. + if(!m_params.blueprintContourFile.empty()) + { + if(!m_params.useBumpBackend) + { + SLIC_WARNING( + "--blueprint-contour-file requires --useBumpBackend; the legacy kernel has no " + "Blueprint contour output. Skipping."); + } + else + { + AXOM_ANNOTATE_SCOPE("write blueprint contour"); + conduit::Node contourBp; + mc.populateContourMeshBlueprint(contourBp); + SLIC_INFO(axom::fmt::format("Blueprint contour has {} domains; writing to '{}'", + contourBp.number_of_children(), + m_params.blueprintContourFile)); + saveMesh(contourBp, m_params.blueprintContourFile); + } + } + { axom::Array facetNodeIds; axom::Array facetNodeCoords; @@ -971,9 +1201,9 @@ struct ContourTestBase AXOM_ANNOTATE_END("convert to mint mesh"); int localErrCount = 0; - if(params.checkResults) + if(m_params.checkResults) { - localErrCount += checkContourSurface(contourMesh, params.contourVal, "diff"); + localErrCount += checkContourSurface(contourMesh, m_params.contourVal, "diff"); localErrCount += checkContourCellLimits(computationalMesh, contourMesh); @@ -1008,7 +1238,7 @@ struct ContourTestBase sum, (double)sum / numRanks)); } - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("Contour mesh has locally {} cells, {} nodes.", mc.getContourCellCount(), mc.getContourNodeCount())); @@ -1021,6 +1251,12 @@ struct ContourTestBase SLIC_ASSERT(bpMesh.dimension() == DIM); for(int domId = 0; domId < bpMesh.domainCount(); ++domId) { + if(bpMesh.useFlatFields(domId)) + { + computeNodalDistanceFlat(bpMesh.domain(domId), strat); + continue; + } + auto domainView = bpMesh.getDomainView(domId); // Create nodal function data with ghosts like node coords. @@ -1033,6 +1269,11 @@ struct ContourTestBase for(int domId = 0; domId < bpMesh.domainCount(); ++domId) { + if(bpMesh.useFlatFields(domId)) + { + continue; + } + auto domainView = bpMesh.getDomainView(domId); const auto coordsViews = domainView.getConstCoordsViews(false); axom::ArrayView fieldView = @@ -1045,6 +1286,36 @@ struct ContourTestBase } } + void computeNodalDistanceFlat(conduit::Node& dom, ContourTestStrategy& strat) + { + conduit::Node& fieldNode = dom["fields/" + strat.functionName()]; + fieldNode["association"] = "vertex"; + fieldNode["topology"] = "mesh"; + + const conduit::Node& values = dom.fetch_existing("coordsets/coords/values"); + const auto xs = values.fetch_existing("x").as_double_accessor(); + const auto ys = values.fetch_existing("y").as_double_accessor(); + const bool hasZ = DIM == 3; + const auto zs = hasZ ? values.fetch_existing("z").as_double_accessor() + : values.fetch_existing("x").as_double_accessor(); + + const conduit::index_t nodeCount = xs.number_of_elements(); + fieldNode["values"].set(conduit::DataType::float64(nodeCount)); + auto* fieldValues = fieldNode["values"].as_double_ptr(); + + for(conduit::index_t nodeId = 0; nodeId < nodeCount; ++nodeId) + { + PointType pt; + pt[0] = xs[nodeId]; + pt[1] = ys[nodeId]; + if(DIM == 3) + { + pt[2] = zs[nodeId]; + } + fieldValues[nodeId] = strat.valueAt(pt); + } + } + template typename std::enable_if::type populateNodalDistance( const axom::StackArray, DIM>& coordsViews, @@ -1114,6 +1385,12 @@ struct ContourTestBase } for(axom::IndexType domId = 0; domId < bpMesh.domainCount(); ++domId) { + if(bpMesh.useFlatFields(domId)) + { + addMaskFieldFlat(bpMesh.domain(domId)); + continue; + } + auto domainView = bpMesh.getDomainView(domId); auto cellCount = domainView.getCellCount(); auto slowestDirs = domainView.getConstCoordsViews()[0].mapping().slowestDirs(); @@ -1129,7 +1406,7 @@ struct ContourTestBase zeros, fastestDirs); auto maskView = domainView.template getFieldView(maskFieldName); - int maskCount = params.maskCount; + int maskCount = m_params.maskCount; axom::for_all( 0, cellCount, @@ -1137,6 +1414,24 @@ struct ContourTestBase } } + void addMaskFieldFlat(conduit::Node& dom) + { + const axom::IndexType cellCount = static_cast( + conduit::blueprint::mesh::topology::length(dom.fetch_existing("topologies/mesh"))); + + conduit::Node& mask = dom["fields/mask"]; + mask["association"] = "element"; + mask["topology"] = "mesh"; + mask["values"].set(conduit::DataType::c_int(cellCount)); + auto* maskValues = mask["values"].as_int_ptr(); + + const int maskCount = m_params.maskCount; + for(axom::IndexType cellId = 0; cellId < cellCount; ++cellId) + { + maskValues[cellId] = static_cast(cellId % maskCount); + } + } + void computeNodalDistance(BlueprintStructuredMesh& bpMesh) { for(auto& strategy : m_testStrategies) @@ -1146,10 +1441,10 @@ struct ContourTestBase } /** - Check for errors in the surface contour mesh. - - analytical scalar value at surface points should be - contourVal, within tolerance zero. - */ + * Check for errors in the surface contour mesh. + * - analytical scalar value at surface points should be + * contourVal, within tolerance zero. + */ int checkContourSurface(axom::mint::UnstructuredMesh& contourMesh, double contourVal, const std::string& diffField = {}) @@ -1164,35 +1459,42 @@ struct ContourTestBase int errCount = 0; for(axom::IndexType iStrat = 0; iStrat < m_testStrategies.size(); ++iStrat) { - auto contourNodeBegin = DIM * m_strategyFacetPrefixSum[iStrat]; - auto contourNodeEnd = DIM * m_strategyFacetPrefixSum[iStrat + 1]; + const auto contourCellBegin = m_strategyFacetPrefixSum[iStrat]; + const auto contourCellEnd = m_strategyFacetPrefixSum[iStrat + 1]; auto& strategy = *m_testStrategies[iStrat]; double tol = strategy.errorTolerance(); PointType pt; - for(axom::IndexType iNode = contourNodeBegin; iNode < contourNodeEnd; ++iNode) + for(axom::IndexType iContourCell = contourCellBegin; iContourCell < contourCellEnd; + ++iContourCell) { - contourMesh.getNode(iNode, pt.data()); - double analyticalVal = strategy.valueAt(pt); - double diff = std::abs(analyticalVal - contourVal); - if(diffPtr) - { - diffPtr[iNode] = diff; - } - if(diff > tol) + const axom::IndexType* cellNodeIds = contourMesh.getCellNodeIDs(iContourCell); + const axom::IndexType cellNodeCount = contourMesh.getNumberOfCellNodes(iContourCell); + for(axom::IndexType iCellNode = 0; iCellNode < cellNodeCount; ++iCellNode) { - ++errCount; - SLIC_INFO_IF( - params.isVerbose(), - axom::fmt::format("checkContourSurface: node {} at {} has dist {}, off by {}", - iNode, - pt, - analyticalVal, - diff)); + const axom::IndexType iNode = cellNodeIds[iCellNode]; + contourMesh.getNode(iNode, pt.data()); + double analyticalVal = strategy.valueAt(pt); + double diff = std::abs(analyticalVal - contourVal); + if(diffPtr) + { + diffPtr[iNode] = diff; + } + if(diff > tol) + { + ++errCount; + SLIC_INFO_IF( + m_params.isVerbose(), + axom::fmt::format("checkContourSurface: node {} at {} has dist {}, off by {}", + iNode, + pt, + analyticalVal, + diff)); + } } } - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("checkContourSurface: found {} errors outside tolerance of {}", errCount, tol)); @@ -1200,7 +1502,7 @@ struct ContourTestBase return errCount; } - //!@brief Get view of output domain id data. + //! @brief Get view of output domain id data. axom::ArrayView getDomainIdView( axom::mint::UnstructuredMesh& contourMesh) const { @@ -1213,7 +1515,7 @@ struct ContourTestBase return view; } - //!@brief Get view of output parent cell idx data. + //! @brief Get view of output parent cell idx data. axom::ArrayView> get_parent_cell_idx_view( axom::mint::UnstructuredMesh& contourMesh) const { @@ -1245,9 +1547,7 @@ struct ContourTestBase return view; } - /** - Check that generated cells fall within their parents. - */ + /// Check that generated cells fall within their parents. int checkContourCellLimits(BlueprintStructuredMesh& computationalMesh, axom::mint::UnstructuredMesh& contourMesh) { @@ -1263,8 +1563,14 @@ struct ContourTestBase domainCount); for(int iDomain = 0; iDomain < domainCount; ++iDomain) { - auto domainView = computationalMesh.getDomainView(iDomain); - allCoordsViews[iDomain] = domainView.getConstCoordsViews(false); + // MeshViewUtil requires a structured topology with an explicit coordset, + // so it can only be used for the structured domains. + // Unstructured domains get their parent-cell bounds from the connectivity below. + if(!computationalMesh.isUnstructured(iDomain)) + { + auto domainView = computationalMesh.getDomainView(iDomain); + allCoordsViews[iDomain] = domainView.getConstCoordsViews(false); + } } std::map @@ -1284,6 +1590,10 @@ struct ContourTestBase axom::Array> mappings(domainCount); for(int d = 0; d < domainCount; ++d) { + if(computationalMesh.isUnstructured(d)) + { + continue; // no logical index space + } axom::StackArray domShape; computationalMesh.domainLengths(d, domShape); mappings[d].initializeShape(domShape, @@ -1313,19 +1623,57 @@ struct ContourTestBase axom::IndexType parentCellId = parentCellIdView[iContourCell]; - axom::StackArray parentCellIdx = - mappings[contiguousIndex].toMultiIndex(parentCellId); - axom::StackArray upperIdx = parentCellIdx; - addToStackArray(upperIdx, 1); + /* + Bounds over all corners of the parent cell, for either topology type. - PointType lower, upper; - for(int d = 0; d < DIM; ++d) + The structured path previously used only the (i,j,k) and (i+1,j+1,k+1) nodes. + That is exact for an axis-aligned grid and wrong for a curvilinear structured mesh + or any warped cell, where the two opposite corners do not bound the cell. + Enumerating every corner is correct in both cases and costs 8 lookups instead of 2. + */ + axom::primal::BoundingBox parentCellBox; + if(computationalMesh.isUnstructured(contiguousIndex)) + { + const conduit::Node& dom = computationalMesh.domain(contiguousIndex); + const conduit::Node& cvals = + dom.fetch_existing(computationalMesh.coordsetPath() + "/values"); + axom::Array nodeIds; + computationalMesh.unstructuredCellNodeIds(contiguousIndex, parentCellId, nodeIds); + for(const auto nodeId : nodeIds) + { + PointType corner; + const char* comps[3] = {"x", "y", "z"}; + for(int d = 0; d < DIM; ++d) + { + corner[d] = cvals.fetch_existing(comps[d]).as_double_accessor()[nodeId]; + } + parentCellBox.addPoint(corner); + } + } + else { - lower[d] = coordsViews[d][parentCellIdx]; - upper[d] = coordsViews[d][upperIdx]; + const axom::StackArray parentCellIdx = + mappings[contiguousIndex].toMultiIndex(parentCellId); + constexpr short int cornerCount = (1 << DIM); + for(short int cornerId = 0; cornerId < cornerCount; ++cornerId) + { + axom::StackArray cornerIdx = parentCellIdx; + for(int d = 0; d < DIM; ++d) + { + if(cornerId & (1 << d)) + { + ++cornerIdx[d]; + } + } + PointType corner; + for(int d = 0; d < DIM; ++d) + { + corner[d] = coordsViews[d][cornerIdx]; + } + parentCellBox.addPoint(corner); + } } - axom::primal::BoundingBox parentCellBox(lower, upper); - auto tol = axom::numerics::floating_point_limits::epsilon(); + auto tol = geometryTolerance(computationalMesh); axom::primal::BoundingBox big(parentCellBox); big.expand(tol); axom::primal::BoundingBox small(parentCellBox); @@ -1347,7 +1695,7 @@ struct ContourTestBase if(!big.contains(nodeCoords)) { ++errCount; - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("checkContourCellLimits: node {} at {} " "too far outside parent cell boundary.", cellNodeIds[nn], @@ -1357,7 +1705,7 @@ struct ContourTestBase if(checkSmall && small.contains(nodeCoords)) { ++errCount; - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("checkContourCellLimits: node {} at {} " "too far inside parent cell boundary.", cellNodeIds[nn], @@ -1367,7 +1715,7 @@ struct ContourTestBase } } - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("checkContourCellLimits: found {} " "nodes not on parent cell boundary.", errCount)); @@ -1375,9 +1723,98 @@ struct ContourTestBase } /*! - Check that computational cells that contain the contour value - have at least one contour mesh cell. - */ + * @brief Half-width of the band around the contour value in which the bump + * backend's classification is indeterminate relative to a double predicate. + * + * bump's FieldIntersector evaluates the corner test in float, so any corner value + * within one float ULP of the contour value can land on either side after rounding. + * This check compares in double, so it needs a small band to treat near-equal values as equal. + * The legacy kernel also compares in double. + * + * This shows up on the radius 0.25 sphere over a 12^3 unit lattice. Some nodes land within one + * float ULP of 0.25 because 1/6 is not exactly representable. Exact equality misses those cases. + * + * Widening the pass-through from exact equality to one float ULP makes the check + * agree with what the backend can actually resolve, rather than holding a float + * classifier to a double predicate. + */ + double contourIndeterminacyBand() const + { + const auto c = static_cast(m_params.contourVal); + const float up = std::nextafterf(c, std::numeric_limits::infinity()); + return static_cast(up - c); + } + + /*! + * @brief Unstructured counterpart of the per-cell contour-membership check. + * + * The structured path flags a cell when its corner values straddle the contour value. + * This does the same thing, but it reads corner values through the topology connectivity. + * It does not need MeshViewUtil or a structured topology. + */ + int checkCellsContainingContourUnstructured(const BlueprintStructuredMesh& computationalMesh, + axom::IndexType domId, + const axom::Array& hasContours) const + { + int errCount = 0; + const conduit::Node& dom = computationalMesh.domain(domId); + const axom::IndexType parentCellCount = computationalMesh.cellCount(domId); + + axom::Array nodeIds; + for(axom::IndexType parentCellId = 0; parentCellId < parentCellCount; ++parentCellId) + { + computationalMesh.unstructuredCellNodeIds(domId, parentCellId, nodeIds); + const axom::IndexType hasContourBits = hasContours[parentCellId]; + + for(axom::IndexType iStrat = 0; iStrat < m_testStrategies.size(); ++iStrat) + { + auto& strategy = *m_testStrategies[iStrat]; + const axom::IndexType iStratBit = (1 << iStrat); + const auto fcn = + dom.fetch_existing("fields/" + strategy.functionName() + "/values").as_double_accessor(); + + double minFcnValue = axom::numeric_limits::max(); + double maxFcnValue = axom::numeric_limits::lowest(); + for(const auto nodeId : nodeIds) + { + const double fcnValue = fcn[nodeId]; + minFcnValue = std::min(minFcnValue, fcnValue); + maxFcnValue = std::max(maxFcnValue, fcnValue); + } + + const bool hasContour = hasContourBits & iStratBit; + bool touchesContour = + (minFcnValue <= m_params.contourVal && maxFcnValue >= m_params.contourVal); + // A cell whose extremum lies within the backend's resolution of the contour + // value may be reported either way; see contourIndeterminacyBand(). + const double band = contourIndeterminacyBand(); + if(std::abs(minFcnValue - m_params.contourVal) <= band || + std::abs(maxFcnValue - m_params.contourVal) <= band) + { + touchesContour = hasContour; + } + + if(touchesContour != hasContour) + { + ++errCount; + SLIC_INFO_IF(m_params.isVerbose(), + axom::fmt::format("checkCellsContainingContourUnstructured: cell {}: " + "hasContour ({}) and touchesContour ({}) don't agree " + "for strategy {}.", + parentCellId, + hasContour, + touchesContour, + strategy.testName())); + } + } + } + return errCount; + } + + /*! + * Check that computational cells that contain the contour value + * have at least one contour mesh cell. + */ int checkCellsContainingContour(BlueprintStructuredMesh& computationalMesh, axom::mint::UnstructuredMesh& contourMesh) { @@ -1415,12 +1852,9 @@ struct ContourTestBase axom::Array> hasContours(domainCount); for(axom::IndexType domId = 0; domId < domainCount; ++domId) { - axom::quest::MeshViewUtil domainView = computationalMesh.getDomainView(domId); - - const axom::IndexType cellCount = domainView.getCellCount(); - + // Do not use MeshViewUtil here since it requires a structured topology with an explicit coordset. axom::Array& hasContour = hasContours[domId]; - hasContour.resize(cellCount, 0); + hasContour.resize(computationalMesh.cellCount(domId), 0); } for(int iStrat = 0; iStrat < m_testStrategies.size(); ++iStrat) @@ -1438,10 +1872,16 @@ struct ContourTestBase } } - // Verify that cells marked by hasContours touches the contour - // and other cells don't. + // Verify that cells marked by hasContours touches the contour and other cells don't. for(axom::IndexType domId = 0; domId < domainCount; ++domId) { + if(computationalMesh.isUnstructured(domId)) + { + errCount += + checkCellsContainingContourUnstructured(computationalMesh, domId, hasContours[domId]); + continue; + } + auto domainView = computationalMesh.getDomainView(domId); axom::StackArray domLengths; @@ -1470,7 +1910,7 @@ struct ContourTestBase // Compute min and max function values in the cell. double minFcnValue = axom::numeric_limits::max(); - double maxFcnValue = axom::numeric_limits::min(); + double maxFcnValue = axom::numeric_limits::lowest(); constexpr short int cornerCount = (1 << DIM); // Number of nodes in a cell. for(short int cornerId = 0; cornerId < cornerCount; ++cornerId) { @@ -1492,10 +1932,12 @@ struct ContourTestBase const bool hasContour = hasContourBits & iStratBit; bool touchesContour = - (minFcnValue <= params.contourVal && maxFcnValue >= params.contourVal); - // If the min or max values in the cell is close to params.contourVal - // touchesContour and hasCont can go either way. So give it a pass. - if(minFcnValue == params.contourVal || maxFcnValue == params.contourVal) + (minFcnValue <= m_params.contourVal && maxFcnValue >= m_params.contourVal); + // If the min or max value in the cell is close to the contour value, + // touchesContour and hasContour can go either way, so give it a pass. + const double band = contourIndeterminacyBand(); + if(std::abs(minFcnValue - m_params.contourVal) <= band || + std::abs(maxFcnValue - m_params.contourVal) <= band) { touchesContour = hasContour; } @@ -1504,7 +1946,7 @@ struct ContourTestBase { ++errCount; SLIC_INFO_IF( - params.isVerbose(), + m_params.isVerbose(), axom::fmt::format("checkCellsContainingContour: cell {}: hasContour " "({}) and touchesContour ({}) don't agree for strategy {}.", parentCellIdx, @@ -1515,7 +1957,7 @@ struct ContourTestBase } } } - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("checkCellsContainingContour: found {} " "misrepresented computational cells.", errCount)); @@ -1535,6 +1977,7 @@ struct PlanarTestStrategy : public ContourTestStrategy virtual std::string testName() const override { return std::string("planar"); } virtual std::string functionName() const override { return std::string("dist_to_plane"); } double errorTolerance() const override { return _errTol; } + void setTolerance(double errTol) { _errTol = errTol; } virtual double valueAt(const PointType& pt) const override { return _plane.signedDistance(pt); } const axom::primal::Plane _plane; double _errTol; @@ -1633,128 +2076,134 @@ int allocatorIdToTest(axom::runtime_policy::Policy policy) return allocatorID; } -/// Utility function to initialize the logger -void initializeLogger() +// ---------------------------------------------------------------------------- +// Utility RAII struct to set up and tear down the example's logger +// ---------------------------------------------------------------------------- +struct ParallelLoggerRAII { - // Initialize Logger - slic::initialize(); - slic::setLoggingMsgLevel(slic::message::Info); + ParallelLoggerRAII() + { + // Initialize Logger + slic::initialize(); + slic::setLoggingMsgLevel(slic::message::Info); - slic::LogStream* logStream; + slic::LogStream* logStream; #ifdef AXOM_USE_MPI - std::string fmt = "[][]: \n"; + std::string fmt = "[][]: \n"; #ifdef AXOM_USE_LUMBERJACK - const int RLIMIT = 8; - logStream = new slic::LumberjackStream(&std::cout, MPI_COMM_WORLD, RLIMIT, fmt); + const int RLIMIT = 8; + logStream = new slic::LumberjackStream(&std::cout, MPI_COMM_WORLD, RLIMIT, fmt); #else - logStream = new slic::SynchronizedStream(&std::cout, MPI_COMM_WORLD, fmt); + logStream = new slic::SynchronizedStream(&std::cout, MPI_COMM_WORLD, fmt); #endif #else - std::string fmt = "[]: \n"; - logStream = new slic::GenericOutputStream(&std::cout, fmt); + std::string fmt = "[]: \n"; + logStream = new slic::GenericOutputStream(&std::cout, fmt); #endif // AXOM_USE_MPI - slic::addStreamToAllMsgLevels(logStream); + slic::addStreamToAllMsgLevels(logStream); - conduit::utils::set_error_handler( - [](auto& msg, auto& file, int line) { slic::logErrorMessage(msg, file, line); }); - conduit::utils::set_warning_handler( - [](auto& msg, auto& file, int line) { slic::logWarningMessage(msg, file, line); }); - conduit::utils::set_info_handler([](auto& msg, auto& file, int line) { - slic::logMessage(slic::message::Info, msg, file, line); - }); -} + conduit::utils::set_error_handler( + [](auto& msg, auto& file, int line) { slic::logErrorMessage(msg, file, line); }); + conduit::utils::set_warning_handler( + [](auto& msg, auto& file, int line) { slic::logWarningMessage(msg, file, line); }); + conduit::utils::set_info_handler([](auto& msg, auto& file, int line) { + slic::logMessage(slic::message::Info, msg, file, line); + }); + } -/// Utility function to finalize the logger -void finalizeLogger() -{ - if(slic::isInitialized()) + void flush() { slic::flushStreams(); } + + /// Utility function to finalize the logger + ~ParallelLoggerRAII() { - slic::flushStreams(); - slic::finalize(); + if(slic::isInitialized()) + { + slic::flushStreams(); + slic::finalize(); + } } -} +}; -/*! - All the test code that depends on DIM to instantiate. -*/ -template -int testNdimInstance(BlueprintStructuredMesh& computationalMesh) +// ---------------------------------------------------------------------------- +// Tag dispatch for choosing the desired execution policy and dimension +// ---------------------------------------------------------------------------- +template +struct TypeTag { - //--------------------------------------------------------------------------- - // params specify which tests to run. - //--------------------------------------------------------------------------- - - std::shared_ptr> planarStrat; - std::shared_ptr> roundStrat; - std::shared_ptr> gyroidStrat; + using type = T; +}; - ContourTestBase contourTest; +template +struct TestInstance +{ + static constexpr int DIM = DIM_; + using ExecSpace = ExecSpace_; +}; - if(params.usingPlanar()) - { - planarStrat = std::make_shared>(params.planeNormal(), - params.inplanePoint()); - contourTest.addTestStrategy(planarStrat); - } +using TestInstanceVariant = std::variant, + TestInstance<3, axom::SEQ_EXEC> +#if defined(AXOM_USE_RAJA) && defined(AXOM_USE_OPENMP) + , + TestInstance<2, axom::OMP_EXEC>, + TestInstance<3, axom::OMP_EXEC> +#endif +#if defined(AXOM_USE_RAJA) && defined(AXOM_USE_CUDA) && defined(AXOM_USE_UMPIRE) + , + TestInstance<2, axom::CUDA_EXEC<256>>, + TestInstance<3, axom::CUDA_EXEC<256>> +#endif +#if defined(AXOM_USE_RAJA) && defined(AXOM_USE_HIP) && defined(AXOM_USE_UMPIRE) + , + TestInstance<2, axom::HIP_EXEC<256>>, + TestInstance<3, axom::HIP_EXEC<256>> +#endif + >; - if(params.usingRound()) +template +TestInstanceVariant selectTestDimension(TypeTag, const Input& params) +{ + if(params.ndim == 2) { - roundStrat = std::make_shared>(params.roundContourCenter()); - roundStrat->setToleranceByLongestEdge(computationalMesh); - contourTest.addTestStrategy(roundStrat); + return TestInstance<2, ExecSpace> {}; } - - if(params.usingGyroid()) + if(params.ndim == 3) { - gyroidStrat = - std::make_shared>(params.gyroidScaleFactor(), params.contourVal); - gyroidStrat->setToleranceByLongestEdge(computationalMesh); - contourTest.addTestStrategy(gyroidStrat); + return TestInstance<3, ExecSpace> {}; } - contourTest.computeNodalDistance(computationalMesh); - - contourTest.addMaskField(computationalMesh); + SLIC_ERROR(axom::fmt::format("Unsupported mesh dimension {}", params.ndim)); + return TestInstance<2, axom::SEQ_EXEC> {}; +} - if(params.isVerbose()) +TestInstanceVariant selectTestInstance(const Input& params) +{ + if(params.policy == RuntimePolicy::seq) { - computationalMesh.printMeshInfo(); + return selectTestDimension(TypeTag {}, params); } - - // Write computational mesh with contour functions. - saveMesh(computationalMesh.asConduitNode(), params.fieldsFile); - - int localErrCount = 0; - localErrCount += contourTest.runTest(computationalMesh); - - // Check results - - int errCount = 0; - if(params.checkResults) +#if defined(AXOM_USE_RAJA) && defined(AXOM_USE_OPENMP) + if(params.policy == RuntimePolicy::omp) { -#ifdef AXOM_USE_MPI - MPI_Allreduce(&localErrCount, &errCount, 1, MPI_INT, MPI_SUM, MPI_COMM_WORLD); -#else - errCount = localErrCount; + return selectTestDimension(TypeTag {}, params); + } #endif - - if(errCount) - { - SLIC_INFO(axom::fmt::format(" Error exit: {} errors found.", errCount)); - } - else - { - SLIC_INFO(banner("Normal exit.")); - } +#if defined(AXOM_USE_RAJA) && defined(AXOM_USE_CUDA) && defined(AXOM_USE_UMPIRE) + if(params.policy == RuntimePolicy::cuda) + { + return selectTestDimension(TypeTag> {}, params); } - else +#endif +#if defined(AXOM_USE_RAJA) && defined(AXOM_USE_HIP) && defined(AXOM_USE_UMPIRE) + if(params.policy == RuntimePolicy::hip) { - SLIC_INFO("Results not checked."); + return selectTestDimension(TypeTag> {}, params); } +#endif - return errCount; + SLIC_ERROR(axom::fmt::format("Unsupported runtime policy {}", params.policy)); + return TestInstance<2, axom::SEQ_EXEC> {}; } //------------------------------------------------------------------------------ @@ -1764,13 +2213,14 @@ int main(int argc, char** argv) myRank = mpi_raii_wrapper.my_rank(); numRanks = mpi_raii_wrapper.num_ranks(); - initializeLogger(); + ParallelLoggerRAII raii_logger; //slic::setAbortOnWarning(true); //--------------------------------------------------------------------------- // Set up and parse command line arguments //--------------------------------------------------------------------------- axom::CLI::App app {"Driver/test code for marching cubes algorithm"}; + Input params; try { @@ -1801,15 +2251,24 @@ int main(int argc, char** argv) //--------------------------------------------------------------------------- // Load computational mesh. //--------------------------------------------------------------------------- + AXOM_ANNOTATE_BEGIN("load mesh"); - BlueprintStructuredMesh computationalMesh(params.meshFile, "mesh"); + BlueprintStructuredMesh computationalMesh(params.meshFile, "mesh", params.isVerbose()); AXOM_ANNOTATE_END("load mesh"); + SLIC_ERROR_IF( + params.ndim != static_cast(computationalMesh.dimension()), + axom::fmt::format( + "Function parameter dimension {} does not match input mesh dimension {} for '{}'.", + params.ndim, + computationalMesh.dimension(), + params.meshFile)); + SLIC_INFO_IF(params.isVerbose(), axom::fmt::format("Computational mesh has {} cells in {} domains locally", computationalMesh.cellCount(), computationalMesh.domainCount())); - slic::flushStreams(); + raii_logger.flush(); // Output some global mesh size stats { @@ -1831,68 +2290,93 @@ int main(int argc, char** argv) (double)sum / numRanks)); } - slic::flushStreams(); + raii_logger.flush(); //--------------------------------------------------------------------------- // Run test in the execution space set by command line. //--------------------------------------------------------------------------- - int errCount = 0; - if(params.policy == axom::runtime_policy::Policy::seq) - { - if(params.ndim == 2) - { - errCount = testNdimInstance<2, axom::SEQ_EXEC>(computationalMesh); - } - else if(params.ndim == 3) - { - errCount = testNdimInstance<3, axom::SEQ_EXEC>(computationalMesh); - } - } -#if defined(AXOM_USE_RAJA) - #ifdef AXOM_USE_OPENMP - else if(params.policy == axom::runtime_policy::Policy::omp) - { - if(params.ndim == 2) - { - errCount = testNdimInstance<2, axom::OMP_EXEC>(computationalMesh); - } - else if(params.ndim == 3) - { - errCount = testNdimInstance<3, axom::OMP_EXEC>(computationalMesh); - } - } - #endif - #if defined(AXOM_USE_CUDA) && defined(AXOM_USE_UMPIRE) - else if(params.policy == axom::runtime_policy::Policy::cuda) - { - if(params.ndim == 2) - { - errCount = testNdimInstance<2, axom::CUDA_EXEC<256>>(computationalMesh); - } - else if(params.ndim == 3) - { - errCount = testNdimInstance<3, axom::CUDA_EXEC<256>>(computationalMesh); - } - } - #endif - #if defined(AXOM_USE_HIP) && defined(AXOM_USE_UMPIRE) - else if(params.policy == axom::runtime_policy::Policy::hip) - { - if(params.ndim == 2) - { - errCount = testNdimInstance<2, axom::HIP_EXEC<256>>(computationalMesh); - } - else if(params.ndim == 3) - { - errCount = testNdimInstance<3, axom::HIP_EXEC<256>>(computationalMesh); - } - } - #endif + auto testInstance = selectTestInstance(params); + int errCount = std::visit( + [&](const auto& instance) { + AXOM_UNUSED_VAR(instance); + using Instance = std::decay_t; + constexpr int DIM = Instance::DIM; + using ExecSpace = typename Instance::ExecSpace; + + std::shared_ptr> planarStrat; + std::shared_ptr> roundStrat; + std::shared_ptr> gyroidStrat; + + ContourTestBase contourTest(params); + + if(params.usingPlanar()) + { + planarStrat = std::make_shared>(params.planeNormal(), + params.inplanePoint()); + if(params.useBumpBackend) + { + planarStrat->setTolerance(contourTest.geometryTolerance(computationalMesh)); + } + contourTest.addTestStrategy(planarStrat); + } + + if(params.usingRound()) + { + roundStrat = std::make_shared>(params.roundContourCenter()); + roundStrat->setToleranceByLongestEdge(computationalMesh); + contourTest.addTestStrategy(roundStrat); + } + + if(params.usingGyroid()) + { + gyroidStrat = std::make_shared>(params.gyroidScaleFactor(), + params.contourVal); + gyroidStrat->setToleranceByLongestEdge(computationalMesh); + contourTest.addTestStrategy(gyroidStrat); + } + + contourTest.computeNodalDistance(computationalMesh); + contourTest.addMaskField(computationalMesh); + + if(params.isVerbose()) + { + computationalMesh.printMeshInfo(); + } + + // Write computational mesh with contour functions. + saveMesh(computationalMesh.asConduitNode(), params.fieldsFile); + + int localErrCount = contourTest.runTest(computationalMesh); + + int globalErrCount = 0; + if(params.checkResults) + { +#ifdef AXOM_USE_MPI + MPI_Allreduce(&localErrCount, &globalErrCount, 1, MPI_INT, MPI_SUM, MPI_COMM_WORLD); +#else + globalErrCount = localErrCount; #endif + if(globalErrCount) + { + SLIC_INFO(axom::fmt::format(" Error exit: {} errors found.", globalErrCount)); + } + else + { + SLIC_INFO(banner("Normal exit.")); + } + } + else + { + SLIC_INFO("Results not checked."); + } + + return globalErrCount; + }, + testInstance); + questMarchingCubesExample.stop(); printTimingStats(questMarchingCubesExample, "questMarchingCubesExample"); - finalizeLogger(); return errCount != 0; } diff --git a/src/axom/quest/tests/CMakeLists.txt b/src/axom/quest/tests/CMakeLists.txt index 2d6eedaa74..802ea9c77f 100644 --- a/src/axom/quest/tests/CMakeLists.txt +++ b/src/axom/quest/tests/CMakeLists.txt @@ -310,6 +310,50 @@ if(AXOM_ENABLE_KLEE AND AXOM_ENABLE_SIDRE AND CONDUIT_FOUND AND RAJA_FOUND AND A endif() +#------------------------------------------------------------------------------ +# Bump-backed MarchingCubes tests (structured + unstructured quad/hex). +# Requires the bump component; the unstructured-conversion path needs Sidre. +#------------------------------------------------------------------------------ +if(CONDUIT_FOUND AND RAJA_FOUND AND AXOM_ENABLE_BUMP AND AXOM_ENABLE_SIDRE) + set(quest_mc_bump_depends ${quest_tests_depends} conduit::conduit RAJA) + blt_list_append(TO quest_mc_bump_depends IF AXOM_ENABLE_MPI ELEMENTS conduit::conduit_mpi) + + axom_add_executable( + NAME quest_marching_cubes_bump_test + SOURCES quest_marching_cubes_bump.cpp + OUTPUT_DIR ${TEST_OUTPUT_DIRECTORY} + DEPENDS_ON ${quest_mc_bump_depends} + FOLDER axom/quest/tests + ) + + # The gtest cases self-select the enabled execution spaces internally, + # so a single registration runs all compiled-in policies. + axom_add_test( + NAME quest_marching_cubes_bump + COMMAND quest_marching_cubes_bump_test + NUM_MPI_TASKS 1 + ) + set_tests_properties(quest_marching_cubes_bump PROPERTIES PROCESSORS 1) + + # Compares native MC vs. bump-based MC backend on the structured/explicit meshes. + # Builds its meshes in memory, so it doesn't depend on HDF5. + axom_add_executable( + NAME quest_marching_cubes_equivalence_test + SOURCES quest_marching_cubes_equivalence.cpp + OUTPUT_DIR ${TEST_OUTPUT_DIRECTORY} + DEPENDS_ON ${quest_mc_bump_depends} + FOLDER axom/quest/tests + ) + + axom_add_test( + NAME quest_marching_cubes_equivalence + COMMAND quest_marching_cubes_equivalence_test + NUM_MPI_TASKS 1 + ) + set_tests_properties(quest_marching_cubes_equivalence PROPERTIES PROCESSORS 1) + +endif() + #------------------------------------------------------------------------------ # Regression tests for quest signed distance and inout queries # diff --git a/src/axom/quest/tests/quest_marching_cubes_bump.cpp b/src/axom/quest/tests/quest_marching_cubes_bump.cpp new file mode 100644 index 0000000000..c10de76a51 --- /dev/null +++ b/src/axom/quest/tests/quest_marching_cubes_bump.cpp @@ -0,0 +1,943 @@ +// Copyright (c) Lawrence Livermore National Security, LLC and other +// Axom Project Contributors. See top-level LICENSE and COPYRIGHT +// files for dates and other details. +// +// SPDX-License-Identifier: (BSD-3-Clause) + +/*! + * @file quest_marching_cubes_bump.cpp + * + * @brief Validation tests for the bump-based backend of quest::MarchingCubes, + * covering structured AND unstructured single-shape (quad/hex) input + * on every available execution space. + * + * This exercises: + * - The bump backend (MarchingCubes::setUseBumpBackend(true)). + * - Unstructured single-shape quad (2D) and hex (3D) topologies. + * - An explicit edge-manifoldness check on the extracted contour. + * This is relevant to the saddle-ambiguity behavior of the original MC tables. + * it should pass on the smooth analytic fields used here regardless of backend. + * + * Verification oracles (all backend-agnostic; applied to bump output): + * O1. On-surface value: every output facet node, evaluated in the analytic field, + * equals the contour value within tolerance (exact for planar). + * O2. Parent containment: each facet's parent cell id is in range, + * and the t centroid lies within the parent cell's axis-aligned bounds. + * O3. Edge manifoldness on bump's welded Blueprint output: + * every contour edge (3D) is shared by exactly 1 or 2 facets and no edge is used 3+ times. + * A closed smooth surface interior to the domain should have all-interior edges shared exactly twice; + * boundary-clipped edges may be shared once. + */ + +#include "axom/config.hpp" + +#ifndef AXOM_USE_CONDUIT + #error "quest_marching_cubes_bump.cpp requires conduit" +#endif +#ifndef AXOM_USE_BUMP + #error "quest_marching_cubes_bump.cpp requires bump" +#endif +#ifndef AXOM_USE_SIDRE + #error "quest_marching_cubes_bump.cpp requires sidre" +#endif + +#include "axom/core.hpp" +#include "axom/slic.hpp" +#include "axom/primal.hpp" +#include "axom/sidre.hpp" +#include "axom/bump/utilities/conduit_memory.hpp" +#include "axom/spin/MortonIndex.hpp" +#include "axom/mint/mesh/UnstructuredMesh.hpp" +#include "axom/quest/MarchingCubes.hpp" +#include "axom/quest/util/mesh_helpers.hpp" + +#include "conduit_blueprint.hpp" + +#include "gtest/gtest.h" + +#include +#include +#include +#include +#include + +namespace +{ +using RuntimePolicy = axom::runtime_policy::Policy; +using Point3D = axom::primal::Point; +using BoundingBox3D = axom::primal::BoundingBox; +using QuantizedPoint3D = axom::primal::Point; + +int hostAllocatorID() { return axom::execution_space::allocatorID(); } + +void copyBlueprintToPolicy(conduit::Node& dst, + const conduit::Node& src, + RuntimePolicy policy, + int allocatorID) +{ + namespace bputils = axom::bump::utilities; + +// The bump backend reads Blueprint arrays in the execution space associated +// with the runtime policy. Keep mesh construction host-side, then copy the +// finished Blueprint tree into policy-compatible memory for MarchingCubes. +#if defined(AXOM_RUNTIME_POLICY_USE_CUDA) + if(policy == RuntimePolicy::cuda) + { + bputils::copy>(dst, src, allocatorID); + return; + } +#endif + +#if defined(AXOM_RUNTIME_POLICY_USE_HIP) + if(policy == RuntimePolicy::hip) + { + bputils::copy>(dst, src, allocatorID); + return; + } +#endif + + AXOM_UNUSED_VAR(policy); + AXOM_UNUSED_VAR(allocatorID); + dst.set(src); +} + +void copyBlueprintToHost(conduit::Node& dst, const conduit::Node& src) +{ + axom::bump::utilities::copy(dst, src, hostAllocatorID()); +} + +//--------------------------------------------------------------------------- +// Analytic fields (mirror the example's PlanarTestStrategy / RoundTestStrategy) +//--------------------------------------------------------------------------- + +//! @brief Signed distance to a plane through @a origin with unit normal @a n. +struct PlanarField +{ + using PointType = axom::primal::Point; + using VectorType = axom::primal::Vector; + using PlaneType = axom::primal::Plane; + + PlanarField(const PointType& origin, const VectorType& normal) : plane(normal, origin) { } + + double operator()(double x, double y, double z) const + { + return plane.signedDistance(PointType {x, y, z}); + } + + PlaneType plane; +}; + +//! @brief Signed distance to a sphere/circle. +struct RoundField +{ + using PointType = axom::primal::Point; + using SphereType = axom::primal::Sphere; + + RoundField(const PointType& center, double radius) : sphere(center, radius) { } + + double operator()(double x, double y, double z) const + { + return sphere.computeSignedDistance(PointType {x, y, z}); + } + + SphereType sphere; +}; + +//--------------------------------------------------------------------------- +// O3 helper: edge-manifoldness over a welded triangle/segment soup. +//--------------------------------------------------------------------------- + +/*! + * @brief Count, for a 3D triangle mesh, how many facets use each undirected edge + * after welding coincident vertices (quantized hash). + * Returns the max multiplicity and the count of edges used 3+ times. + */ +struct EdgeManifoldResult +{ + int maxMultiplicity = 0; + axom::IndexType edgesUsed3PlusTimes = 0; + axom::IndexType boundaryEdges = 0; // used exactly once + axom::IndexType interiorEdges = 0; // used exactly twice +}; + +EdgeManifoldResult checkEdgeManifold3D(const axom::ArrayView& nodeCoords, + const axom::ArrayView& facetCorners, + double weldTol) +{ + const double inv = 1.0 / weldTol; + auto quantize = [inv](double v) { return static_cast(std::llround(v * inv)); }; + + // The legacy MarchingCubes arrays duplicate coordinates per facet. + // Quantize coordinates here to recover welded vertex ids for the + // helper self-test without changing the output contract. + std::unordered_map> vmap; + const axom::IndexType nFacets = facetCorners.shape()[0]; + + auto weldedId = [&](axom::IndexType row) { + QuantizedPoint3D key {quantize(nodeCoords(row, 0)), + quantize(nodeCoords(row, 1)), + quantize(nodeCoords(row, 2))}; + auto it = vmap.find(key); + if(it != vmap.end()) + { + return it->second; + } + const axom::IndexType id = static_cast(vmap.size()); + vmap.emplace(key, id); + return id; + }; + + std::map, int> edgeUse; + for(axom::IndexType f = 0; f < nFacets; ++f) + { + axom::IndexType v[3]; + for(int c = 0; c < 3; ++c) + { + v[c] = weldedId(facetCorners(f, c)); + } + for(int e = 0; e < 3; ++e) + { + axom::IndexType a = v[e], b = v[(e + 1) % 3]; + if(a == b) + { + continue; // degenerate edge; ignore + } + if(a > b) + { + std::swap(a, b); + } + edgeUse[{a, b}]++; + } + } + + EdgeManifoldResult res; + for(const auto& kv : edgeUse) + { + res.maxMultiplicity = std::max(res.maxMultiplicity, kv.second); + if(kv.second == 1) + { + res.boundaryEdges++; + } + else if(kv.second == 2) + { + res.interiorEdges++; + } + else if(kv.second >= 3) + { + res.edgesUsed3PlusTimes++; + } + } + return res; +} + +EdgeManifoldResult checkBlueprintEdgeManifold3D(const conduit::Node& contourDom) +{ + // The Blueprint accessor exposes bump's native welded polygonal contour, + // so this path can count edges directly from connectivity without coordinate + // re-welding or fan-triangulating polygons. + const conduit::Node& n_topo = contourDom.fetch_existing("topologies").child(0); + const conduit::Node& n_elems = n_topo.fetch_existing("elements"); + const auto sizes = n_elems.fetch_existing("sizes").as_index_t_accessor(); + const auto offsets = n_elems.fetch_existing("offsets").as_index_t_accessor(); + const auto conn = n_elems.fetch_existing("connectivity").as_index_t_accessor(); + + std::map, int> edgeUse; + const conduit::index_t nZones = sizes.number_of_elements(); + for(conduit::index_t z = 0; z < nZones; ++z) + { + const auto nCorners = static_cast(sizes[z]); + const auto offset = static_cast(offsets[z]); + for(axom::IndexType e = 0; e < nCorners; ++e) + { + axom::IndexType a = static_cast(conn[offset + e]); + axom::IndexType b = static_cast(conn[offset + ((e + 1) % nCorners)]); + if(a == b) + { + continue; // degenerate edge; ignore + } + if(a > b) + { + std::swap(a, b); + } + edgeUse[{a, b}]++; + } + } + + EdgeManifoldResult res; + for(const auto& kv : edgeUse) + { + res.maxMultiplicity = std::max(res.maxMultiplicity, kv.second); + if(kv.second == 1) + { + res.boundaryEdges++; + } + else if(kv.second == 2) + { + res.interiorEdges++; + } + else if(kv.second >= 3) + { + res.edgesUsed3PlusTimes++; + } + } + return res; +} + +//--------------------------------------------------------------------------- +// Mesh builders +//--------------------------------------------------------------------------- + +//! @brief Identity warp (no displacement); the default coordinate map. +struct NoWarp +{ + void operator()(double& /*x*/, double& /*y*/, double& /*z*/) const { } +}; + +// A smooth sinusoidal shear that makes the hexes genuinely non-axis-aligned (curvilinear) +// while keeping them valid (small displacement, positive Jacobian). +// Boundaries z=0 and z=1 are pinned so the [0,1]^3 box is preserved enough +// for the interior sphere to stay strictly inside. +struct SinusoidalWarp +{ + double amp; + void operator()(double& x, double& y, double& z) const + { + const double sx = std::sin(M_PI * x), sy = std::sin(M_PI * y), sz = std::sin(M_PI * z); + // Displace interior nodes; boundary planes have a zero sin factor, + // so the outer box edges/corners stay put. + x += amp * sy * sz; + y += amp * sx * sz; + z += amp * sx * sy; + } +}; + +//! @brief Build a single-domain structured explicit 3D mesh [0,1]^3 +//! with @a n cells per side and the analytic field @a f sampled at nodes. +template +void buildStructured3D(conduit::Node& mesh, + int n, + const Field& f, + const std::string& fieldName, + const Warp& warp = Warp {}) +{ + const int nn = n + 1; + const conduit::index_t N = static_cast(nn) * nn * nn; + + mesh.reset(); + + conduit::Node& cs = mesh["coordsets/coords"]; + cs["type"] = "explicit"; + cs["values/x"].set(conduit::DataType::float64(N)); + cs["values/y"].set(conduit::DataType::float64(N)); + cs["values/z"].set(conduit::DataType::float64(N)); + auto* x = cs["values/x"].as_float64_ptr(); + auto* y = cs["values/y"].as_float64_ptr(); + auto* z = cs["values/z"].as_float64_ptr(); + + conduit::Node& topo = mesh["topologies/mesh"]; + topo["type"] = "structured"; + topo["coordset"] = "coords"; + topo["elements/dims/i"] = n; + topo["elements/dims/j"] = n; + topo["elements/dims/k"] = n; + + conduit::Node& fld = mesh["fields/" + fieldName]; + fld["topology"] = "mesh"; + fld["association"] = "vertex"; + fld["values"].set(conduit::DataType::float64(N)); + auto* fv = fld["values"].as_float64_ptr(); + + // i-fastest node ordering, matching bump's StructuredIndexing. + conduit::index_t idx = 0; + for(int k = 0; k < nn; ++k) + { + for(int j = 0; j < nn; ++j) + { + for(int i = 0; i < nn; ++i, ++idx) + { + double px = double(i) / n, py = double(j) / n, pz = double(k) / n; + // Apply the (smooth) coordinate warp, then sample the analytic field at the resulting physical location + // so the on-surface oracle (which evaluates f at output node coords) stays consistent for warped hexes. + warp(px, py, pz); + x[idx] = px; + y[idx] = py; + z[idx] = pz; + fv[idx] = f(px, py, pz); + } + } + } +} + +void addStructuredMask3D(conduit::Node& mesh, + int n, + const std::string& maskFieldName, + int selectedValue, + int rejectedValue) +{ + const conduit::index_t nCells = static_cast(n) * n * n; + + conduit::Node& mask = mesh["fields/" + maskFieldName]; + mask["topology"] = "mesh"; + mask["association"] = "element"; + mask["values"].set(conduit::DataType::int32(nCells)); + auto* values = mask["values"].as_int32_ptr(); + + // Build an element-associated mask that selects the lower half of the + // structured mesh in k. The masked test below then verifies that bump's + // selectedZones path emits contour facets only from cells with this value. + conduit::index_t idx = 0; + for(int k = 0; k < n; ++k) + { + for(int j = 0; j < n; ++j) + { + for(int i = 0; i < n; ++i, ++idx) + { + AXOM_UNUSED_VAR(i); + AXOM_UNUSED_VAR(j); + values[idx] = (k < n / 2) ? selectedValue : rejectedValue; + } + } + } +} + +Point3D contourFacetCentroid3D(const axom::ArrayView& nodeCoords, + const axom::ArrayView& facetCorners, + axom::IndexType facetIndex) +{ + Point3D centroid {}; + for(int c = 0; c < 3; ++c) + { + const axom::IndexType nodeIndex = facetCorners(facetIndex, c); + for(int d = 0; d < 3; ++d) + { + centroid[d] += nodeCoords(nodeIndex, d); + } + } + for(int d = 0; d < 3; ++d) + { + centroid[d] /= 3.0; + } + return centroid; +} + +void addCoordsetPointToBounds(const conduit::Node& n_values, + axom::IndexType nodeIndex, + BoundingBox3D& bounds) +{ + const auto x = n_values.fetch_existing("x").as_float64_accessor(); + const auto y = n_values.fetch_existing("y").as_float64_accessor(); + const auto z = n_values.fetch_existing("z").as_float64_accessor(); + bounds.addPoint(Point3D {static_cast(x[nodeIndex]), + static_cast(y[nodeIndex]), + static_cast(z[nodeIndex])}); +} + +bool structuredCellBounds3D(const conduit::Node& mesh, axom::IndexType cellIndex, BoundingBox3D& bounds) +{ + const conduit::Node& topo = mesh.fetch_existing("topologies/mesh"); + const axom::IndexType ni = + static_cast(topo.fetch_existing("elements/dims/i").to_value()); + const axom::IndexType nj = + static_cast(topo.fetch_existing("elements/dims/j").to_value()); + const axom::IndexType nk = + static_cast(topo.fetch_existing("elements/dims/k").to_value()); + const axom::IndexType nCells = ni * nj * nk; + if(cellIndex < 0 || cellIndex >= nCells) + { + return false; + } + + const axom::IndexType i = cellIndex % ni; + const axom::IndexType j = (cellIndex / ni) % nj; + const axom::IndexType k = cellIndex / (ni * nj); + + const std::string coordsetName = topo.fetch_existing("coordset").as_string(); + const conduit::Node& n_values = mesh.fetch_existing("coordsets/" + coordsetName + "/values"); + + const axom::IndexType nni = ni + 1; + const axom::IndexType nnj = nj + 1; + // Structured test meshes use i-fastest node ordering, matching the builder above + // and bump's structured indexing. Enumerate the eight logical corners of the + // reported parent cell to form a physical-space AABB. + auto nodeIndex = [=](axom::IndexType ii, axom::IndexType jj, axom::IndexType kk) { + return ii + jj * nni + kk * nni * nnj; + }; + + for(axom::IndexType dk = 0; dk <= 1; ++dk) + { + for(axom::IndexType dj = 0; dj <= 1; ++dj) + { + for(axom::IndexType di = 0; di <= 1; ++di) + { + addCoordsetPointToBounds(n_values, nodeIndex(i + di, j + dj, k + dk), bounds); + } + } + } + return bounds.isValid(); +} + +bool unstructuredHexCellBounds3D(const conduit::Node& mesh, + axom::IndexType cellIndex, + BoundingBox3D& bounds) +{ + const conduit::Node& topo = mesh.fetch_existing("topologies/mesh"); + if(topo.fetch_existing("elements/shape").as_string() != std::string("hex")) + { + return false; + } + + constexpr axom::IndexType HEX_NODES = 8; + const auto conn = topo.fetch_existing("elements/connectivity").as_index_t_accessor(); + const axom::IndexType firstConn = cellIndex * HEX_NODES; + if(cellIndex < 0 || firstConn + HEX_NODES > conn.number_of_elements()) + { + return false; + } + + const std::string coordsetName = topo.fetch_existing("coordset").as_string(); + const conduit::Node& n_values = mesh.fetch_existing("coordsets/" + coordsetName + "/values"); + + for(axom::IndexType c = 0; c < HEX_NODES; ++c) + { + const axom::IndexType nodeIndex = static_cast(conn[firstConn + c]); + addCoordsetPointToBounds(n_values, nodeIndex, bounds); + } + return bounds.isValid(); +} + +bool parentCellBounds3D(const conduit::Node& mesh, axom::IndexType cellIndex, BoundingBox3D& bounds) +{ + const std::string topoType = mesh.fetch_existing("topologies/mesh/type").as_string(); + if(topoType == "structured") + { + return structuredCellBounds3D(mesh, cellIndex, bounds); + } + if(topoType == "unstructured") + { + return unstructuredHexCellBounds3D(mesh, cellIndex, bounds); + } + return false; +} + +//--------------------------------------------------------------------------- +// Core check: run bump-backed MarchingCubes and apply tests O1, O2 and O3 +//--------------------------------------------------------------------------- + +template +void runAndVerify3D(conduit::Node& mesh, + const Field& f, + double contourVal, + RuntimePolicy policy, + const std::string& fieldName, + bool expectClosedInterior, + double analyticSurfaceTol, + const std::string& maskFieldName = {}, + int maskVal = 1, + axom::quest::MarchingCubesRobustnessPolicy robustness = + axom::quest::MarchingCubesRobustnessPolicy::standard) +{ + namespace quest = axom::quest; + + const int allocatorID = axom::policyToDefaultAllocatorID(policy); + quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); + mc.setUseBumpBackend(true); + mc.setRobustnessPolicy(robustness); + + conduit::Node execMesh; + copyBlueprintToPolicy(execMesh, mesh, policy, allocatorID); + mc.setMesh(execMesh, "mesh", maskFieldName); + if(!maskFieldName.empty()) + { + mc.setMaskValue(maskVal); + } + mc.setFunctionField(fieldName); + mc.computeIsocontour(contourVal); + + conduit::Node contourBpExec; + mc.populateContourMeshBlueprint(contourBpExec); + conduit::Node contourBp; + copyBlueprintToHost(contourBp, contourBpExec); + // Validate both output surfaces: the richer welded Blueprint mesh for topology/manifoldness, + // and the legacy fixed-stride arrays for compatibility with existing MarchingCubes callers. + ASSERT_TRUE(conduit::blueprint::mesh::is_multi_domain(contourBp)); + ASSERT_EQ(conduit::blueprint::mesh::number_of_domains(contourBp), 1); + const conduit::Node& contourDom = contourBp.child(0); + ASSERT_TRUE(contourDom.has_path("state/domain_id")); + EXPECT_EQ(contourDom["state/domain_id"].to_int32(), 0); + ASSERT_TRUE(contourDom.has_path("topologies")); + ASSERT_EQ(contourDom["topologies"].number_of_children(), 1); + const conduit::Node& contourTopo = contourDom["topologies"].child(0); + ASSERT_TRUE(contourTopo.has_path("elements/connectivity")); + ASSERT_TRUE(contourTopo.has_path("elements/sizes")); + ASSERT_TRUE(contourTopo.has_path("elements/offsets")); + ASSERT_TRUE(contourDom.has_path("fields/originalElements/values")); + + conduit::Node triContourBpExec; + mc.populateContourMeshBlueprint(triContourBpExec, true); + conduit::Node triContourBp; + copyBlueprintToHost(triContourBp, triContourBpExec); + ASSERT_TRUE(conduit::blueprint::mesh::is_multi_domain(triContourBp)); + ASSERT_EQ(conduit::blueprint::mesh::number_of_domains(triContourBp), 1); + const conduit::Node& triContourDom = triContourBp.child(0); + const conduit::Node& triContourTopo = triContourDom["topologies"].child(0); + const conduit::Node& triElems = triContourTopo.fetch_existing("elements"); + const auto triSizes = triElems.fetch_existing("sizes").as_index_t_accessor(); + const auto triConn = triElems.fetch_existing("connectivity").as_index_t_accessor(); + ASSERT_EQ(triConn.number_of_elements(), triSizes.number_of_elements() * 3); + for(conduit::index_t z = 0; z < triSizes.number_of_elements(); ++z) + { + EXPECT_EQ(triSizes[z], 3); + } + EXPECT_EQ(triContourDom["fields/originalElements/values"].dtype().number_of_elements(), + triSizes.number_of_elements()); + const std::string contourCoordsetName = contourTopo.fetch_existing("coordset").as_string(); + const std::string triCoordsetName = triContourTopo.fetch_existing("coordset").as_string(); + EXPECT_EQ(contourDom.fetch_existing("coordsets/" + contourCoordsetName + "/values/x") + .dtype() + .number_of_elements(), + triContourDom.fetch_existing("coordsets/" + triCoordsetName + "/values/x") + .dtype() + .number_of_elements()); + + const axom::Array coordsHost(mc.getContourNodeCoords(), hostAllocatorID()); + const axom::Array cornersHost(mc.getContourFacetCorners(), hostAllocatorID()); + const axom::Array parentsHost(mc.getContourFacetParents(), hostAllocatorID()); + const auto coords = coordsHost.view(); + const auto corners = cornersHost.view(); + const auto parents = parentsHost.view(); + const axom::IndexType nFacets = mc.getContourCellCount(); + + ASSERT_GT(nFacets, 0) << "Expected a non-empty contour."; + EXPECT_LE(mc.getContourNodeCount(), nFacets * 3) + << "Bump-backed output should reuse welded contour vertices."; + for(axom::IndexType fIdx = 0; fIdx < nFacets; ++fIdx) + { + for(int c = 0; c < 3; ++c) + { + EXPECT_GE(corners(fIdx, c), 0); + EXPECT_LT(corners(fIdx, c), mc.getContourNodeCount()); + } + } + + // O1: on-surface value. + double maxValErr = 0.0; + for(axom::IndexType r = 0; r < mc.getContourNodeCount(); ++r) + { + const double v = f(coords(r, 0), coords(r, 1), coords(r, 2)); + maxValErr = std::max(maxValErr, std::abs(v - contourVal)); + } + EXPECT_LT(maxValErr, analyticSurfaceTol) << "O1: a facet node is off the isosurface."; + + // O2: parent id range + facet centroid within parent cell bounds. + const conduit::index_t nCells = + conduit::blueprint::mesh::topology::length(mesh["topologies/mesh"]); + const conduit::Node* maskValues = nullptr; + if(!maskFieldName.empty()) + { + ASSERT_TRUE(mesh.has_path("fields/" + maskFieldName + "/values")); + maskValues = &mesh.fetch_existing("fields/" + maskFieldName + "/values"); + } + for(axom::IndexType ff = 0; ff < nFacets; ++ff) + { + EXPECT_GE(parents[ff], 0); + EXPECT_LT(parents[ff], static_cast(nCells)) << "O2: parent id out of range."; + if(!maskFieldName.empty() && parents[ff] >= 0 && parents[ff] < nCells) + { + EXPECT_EQ(maskValues->as_int32_accessor()[parents[ff]], maskVal) + << "masked extraction emitted a facet from an unselected parent zone."; + } + if(parents[ff] >= 0 && parents[ff] < nCells) + { + BoundingBox3D parentBounds; + ASSERT_TRUE(parentCellBounds3D(mesh, parents[ff], parentBounds)) + << "O2: could not compute parent cell bounds."; + const Point3D centroid = contourFacetCentroid3D(coords, corners, ff); + parentBounds.expand(1.0e-8); + EXPECT_TRUE(parentBounds.contains(centroid)) + << "O2: facet centroid is outside the reported parent cell bounds."; + } + } + + // O3: edge manifoldness. + const auto em = checkBlueprintEdgeManifold3D(contourDom); + EXPECT_EQ(em.edgesUsed3PlusTimes, 0) + << "O3: a contour edge is shared by 3+ facets (non-manifold)."; + EXPECT_LE(em.maxMultiplicity, 2) << "O3: max edge multiplicity exceeds 2."; + if(expectClosedInterior) + { + // A closed surface strictly interior to the domain has no boundary edges. + EXPECT_EQ(em.boundaryEdges, 0) + << "O3: closed interior surface unexpectedly has boundary (once-used) edges."; + } + + conduit::Node relinquishedBpExec; + mc.relinquishContourDataBlueprint(relinquishedBpExec); + conduit::Node relinquishedBp; + copyBlueprintToHost(relinquishedBp, relinquishedBpExec); + ASSERT_TRUE(conduit::blueprint::mesh::is_multi_domain(relinquishedBp)); + ASSERT_EQ(conduit::blueprint::mesh::number_of_domains(relinquishedBp), 1); + EXPECT_EQ(mc.getContourCellCount(), 0); +} + +//--------------------------------------------------------------------------- +// Tests: structured and unstructured, planar and round, per policy. +//--------------------------------------------------------------------------- + +void test_structured_planar(RuntimePolicy policy) +{ + conduit::Node mesh; + PlanarField f {{0.5, 0.5, 0.5}, {0.0, 0.0, 1.0}}; // horizontal plane z=0.5 + buildStructured3D(mesh, 8, f, "fcn"); + // Planar contour clips the domain -> open surface (boundary edges expected). + runAndVerify3D(mesh, f, 0.0, policy, "fcn", /*expectClosedInterior=*/false, 1.0e-6); +} + +void test_structured_round(RuntimePolicy policy) +{ + conduit::Node mesh; + RoundField f {{0.5, 0.5, 0.5}, 0.25}; // sphere fully inside [0,1]^3 + buildStructured3D(mesh, 16, f, "fcn"); + // Sphere interior to the domain -> closed surface (no boundary edges). + // The contour is exact for the linearly interpolated nodal field, so the + // analytic signed-distance residual is O(h^2), not roundoff. + runAndVerify3D(mesh, f, 0.0, policy, "fcn", /*expectClosedInterior=*/true, 5.0e-3); +} + +void test_structured_planar_mask(RuntimePolicy policy) +{ + conduit::Node mesh; + PlanarField f {{0.5, 0.5, 0.30}, {0.0, 0.0, 1.0}}; // horizontal plane z=0.30 + buildStructured3D(mesh, 8, f, "fcn"); + + // Select only the lower k-slab. Since z=0.30 lies in that selected half, + // the contour should be non-empty, and runAndVerify3D checks every reported + // parent cell has the selected mask value. + addStructuredMask3D(mesh, 8, "mask", /*selectedValue=*/7, /*rejectedValue=*/3); + runAndVerify3D(mesh, + f, + 0.0, + policy, + "fcn", + /*expectClosedInterior=*/false, + 1.0e-6, + "mask", + 7); +} + +// Unstructured hex: build structured, then convert in-place via the Axom mesh helper, +// then run the same verification. Uses a sidre Group because the converter operates on sidre. +void test_unstructured_hex_round(RuntimePolicy policy) +{ + axom::sidre::DataStore ds; + axom::sidre::Group* meshGrp = ds.getRoot()->createGroup("mesh"); + + // Build a structured mesh into a conduit node, import into sidre. + conduit::Node structured; + RoundField f {{0.5, 0.5, 0.5}, 0.25}; + buildStructured3D(structured, 16, f, "fcn"); + meshGrp->importConduitTree(structured); + + // Convert structured -> unstructured single-shape hex in place. + // Keep the test fixture mesh host-readable for the CPU-side oracle below. + // runAndVerify3D copies the finished Blueprint mesh into policy-compatible + // memory before invoking MarchingCubes. + axom::quest::util::convert_blueprint_structured_explicit_to_unstructured_3d(meshGrp, + "mesh", + RuntimePolicy::seq); + + // Re-export to a conduit node for MarchingCubes::setMesh. + conduit::Node unstructured; + meshGrp->createNativeLayout(unstructured); + + ASSERT_EQ(unstructured["topologies/mesh/type"].as_string(), std::string("unstructured")); + runAndVerify3D(unstructured, f, 0.0, policy, "fcn", /*expectClosedInterior=*/true, 5.0e-3); +} + +// Warped/curvilinear unstructured hex (the case Phase 2.3 called out but the +// initial test set omitted). Exercises the non-box-hex geometric path: the +// extractor still classifies topology from corner signs, and edge crossings are +// interpolated along physical edges. The interior sphere remains closed. +void test_unstructured_hex_round_warped(RuntimePolicy policy) +{ + axom::sidre::DataStore ds; + axom::sidre::Group* meshGrp = ds.getRoot()->createGroup("mesh"); + + conduit::Node structured; + RoundField f {{0.5, 0.5, 0.5}, 0.22}; // slightly smaller r: stays inside after warp + buildStructured3D(structured, 16, f, "fcn", SinusoidalWarp {0.015}); + meshGrp->importConduitTree(structured); + + axom::quest::util::convert_blueprint_structured_explicit_to_unstructured_3d(meshGrp, + "mesh", + RuntimePolicy::seq); + + conduit::Node unstructured; + meshGrp->createNativeLayout(unstructured); + + ASSERT_EQ(unstructured["topologies/mesh/type"].as_string(), std::string("unstructured")); + // Looser analytic tolerance: on a warped mesh the piecewise-linear contour's + // signed-distance residual grows with cell distortion, so this checks + // validity/closedness, not high-accuracy reconstruction. + runAndVerify3D(unstructured, f, 0.0, policy, "fcn", /*expectClosedInterior=*/true, 2.0e-2); +} + +// Phase 6 seam: selecting the `robust` policy must, today, produce a valid +// contour identical to `standard` (robust currently aliases standard). +// This will need to be adjusted once the robust MC backend is added. +void test_robustness_seam(RuntimePolicy policy) +{ + namespace quest = axom::quest; + RoundField f {{0.5, 0.5, 0.5}, 0.25}; + + // Robust currently aliases standard. + // A future robust intersector can update this expectation. + auto facetCountFor = [&](quest::MarchingCubesRobustnessPolicy rp) { + conduit::Node mesh; + buildStructured3D(mesh, 16, f, "fcn"); + const int allocatorID = axom::policyToDefaultAllocatorID(policy); + conduit::Node execMesh; + copyBlueprintToPolicy(execMesh, mesh, policy, allocatorID); + quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); + mc.setUseBumpBackend(true); + mc.setRobustnessPolicy(rp); + mc.setMesh(execMesh, "mesh"); + mc.setFunctionField("fcn"); + mc.computeIsocontour(0.0); + return mc.getContourCellCount(); + }; + + const auto stdCount = facetCountFor(quest::MarchingCubesRobustnessPolicy::standard); + const auto robustCount = facetCountFor(quest::MarchingCubesRobustnessPolicy::robust); + EXPECT_GT(stdCount, 0); + EXPECT_EQ(stdCount, robustCount) + << "robust policy is expected to alias standard until a robust intersector exists."; +} + +//--------------------------------------------------------------------------- +// GTest registration (sequential always; others when compiled in). +//--------------------------------------------------------------------------- + +TEST(quest_marching_cubes_bump, structured_planar_seq) +{ + test_structured_planar(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_bump, structured_round_seq) { test_structured_round(RuntimePolicy::seq); } +TEST(quest_marching_cubes_bump, structured_planar_mask_seq) +{ + test_structured_planar_mask(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_seq) +{ + test_unstructured_hex_round(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_warped_seq) +{ + test_unstructured_hex_round_warped(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_bump, robustness_seam_nfc_seq) +{ + test_robustness_seam(RuntimePolicy::seq); +} + +#if defined(AXOM_RUNTIME_POLICY_USE_OPENMP) && !defined(_WIN32) +TEST(quest_marching_cubes_bump, structured_round_omp) { test_structured_round(RuntimePolicy::omp); } +TEST(quest_marching_cubes_bump, structured_planar_mask_omp) +{ + test_structured_planar_mask(RuntimePolicy::omp); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_omp) +{ + test_unstructured_hex_round(RuntimePolicy::omp); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_warped_omp) +{ + test_unstructured_hex_round_warped(RuntimePolicy::omp); +} +#endif + +#if defined(AXOM_RUNTIME_POLICY_USE_CUDA) +TEST(quest_marching_cubes_bump, structured_round_cuda) +{ + test_structured_round(RuntimePolicy::cuda); +} +TEST(quest_marching_cubes_bump, structured_planar_mask_cuda) +{ + test_structured_planar_mask(RuntimePolicy::cuda); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_cuda) +{ + test_unstructured_hex_round(RuntimePolicy::cuda); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_warped_cuda) +{ + test_unstructured_hex_round_warped(RuntimePolicy::cuda); +} +#endif + +#if defined(AXOM_RUNTIME_POLICY_USE_HIP) +TEST(quest_marching_cubes_bump, structured_round_hip) { test_structured_round(RuntimePolicy::hip); } +TEST(quest_marching_cubes_bump, structured_planar_mask_hip) +{ + test_structured_planar_mask(RuntimePolicy::hip); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_hip) +{ + test_unstructured_hex_round(RuntimePolicy::hip); +} +TEST(quest_marching_cubes_bump, unstructured_hex_round_warped_hip) +{ + test_unstructured_hex_round_warped(RuntimePolicy::hip); +} +#endif + +// Self-test of the O3 edge-manifold helper (independent of MarchingCubes). +TEST(quest_marching_cubes_bump, edge_manifold_helper_selftest) +{ + // Two triangles sharing edge (0,0,0)-(1,0,0): a manifold pair. + axom::Array coords(axom::ArrayOptions::Uninitialized(), 6, 3); + // tri 0: (0,0,0),(1,0,0),(0,1,0) + coords(0, 0) = 0; + coords(0, 1) = 0; + coords(0, 2) = 0; + coords(1, 0) = 1; + coords(1, 1) = 0; + coords(1, 2) = 0; + coords(2, 0) = 0; + coords(2, 1) = 1; + coords(2, 2) = 0; + // tri 1: (0,0,0),(1,0,0),(0,-1,0) -> shares edge (0,0,0)-(1,0,0) + coords(3, 0) = 0; + coords(3, 1) = 0; + coords(3, 2) = 0; + coords(4, 0) = 1; + coords(4, 1) = 0; + coords(4, 2) = 0; + coords(5, 0) = 0; + coords(5, 1) = -1; + coords(5, 2) = 0; + + axom::Array corners(axom::ArrayOptions::Uninitialized(), 2, 3); + corners(0, 0) = 0; + corners(0, 1) = 1; + corners(0, 2) = 2; + corners(1, 0) = 3; + corners(1, 1) = 4; + corners(1, 2) = 5; + + const auto em = checkEdgeManifold3D(coords.view(), corners.view(), 1.0e-9); + EXPECT_EQ(em.maxMultiplicity, 2); // shared edge used twice + EXPECT_EQ(em.interiorEdges, 1); // exactly one shared edge + EXPECT_EQ(em.boundaryEdges, 4); // the other four edges used once + EXPECT_EQ(em.edgesUsed3PlusTimes, 0); +} + +} // namespace + +int main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + axom::slic::SimpleLogger logger; + + int result = RUN_ALL_TESTS(); + return result; +} diff --git a/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp b/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp new file mode 100644 index 0000000000..7e1106013a --- /dev/null +++ b/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp @@ -0,0 +1,1600 @@ +// Copyright (c) Lawrence Livermore National Security, LLC and other +// Axom Project Contributors. See top-level LICENSE and COPYRIGHT +// files for dates and other details. +// +// SPDX-License-Identifier: (BSD-3-Clause) + +/*! + * @file quest_marching_cubes_equivalence.cpp + * + * @brief Compares the legacy and bump MarchingCubes backends on structured meshes. + * + * The outputs do not match one for one. + * - The legacy backend emits unwelded facets. Each facet has its own DIM nodes, + and adjacent facets duplicate shared vertices. + * - The bump backend welds the coordset. In 3D it can produce polygons, + * which the adaptor fan-triangulates when filling the legacy triangle output. + * + * Counts can differ, but underlying geometry of the extracted mesh cannot. + * + * Checks: + * + * E1. CROSSING-CELL SET. The set of parent cell ids that produce at least one + * facet must match. This is a relatively cheap test. + * Supported structured layouts use the same i-fastest cell numbering in both backends, + * and permuted field layouts are rejected. + * + * E2. VERTEX SET. An edge of a cell receives a contour vertex if and only if + * its two endpoints lie on opposite sides of the isovalue. + * That is a property of the sign pattern not of the case table. + * + * The comparison is a two-sided Hausdorff check with a spatial hash. + * Any binning scheme has boundary cases where two near-identical points land in different bins. + * The tolerance covers that and bump's single-precision edge interpolation. + * + * E3. TOTAL AREA (3D) / LENGTH (2D). On an ambiguous cell the two case tables may + * triangulate the same vertex set differently, and triangulating a non-planar bump polygon + * gives an area that depends on the fan origin. + */ + +#include "axom/config.hpp" + +#ifndef AXOM_USE_CONDUIT + #error "quest_marching_cubes_equivalence.cpp requires conduit" +#endif +#ifndef AXOM_USE_BUMP + #error "quest_marching_cubes_equivalence.cpp requires bump" +#endif + +#include "axom/core.hpp" +#include "axom/slic.hpp" +#include "axom/primal.hpp" +#include "axom/bump/utilities/conduit_memory.hpp" +#include "axom/quest/MarchingCubes.hpp" + +#include "conduit_blueprint.hpp" + +#include "gtest/gtest.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using RuntimePolicy = axom::runtime_policy::Policy; + +int hostAllocatorID() { return axom::execution_space::allocatorID(); } + +void copyBlueprintToPolicy(conduit::Node& dst, + const conduit::Node& src, + RuntimePolicy policy, + int allocatorID) +{ + namespace bputils = axom::bump::utilities; + +#if defined(AXOM_RUNTIME_POLICY_USE_CUDA) + if(policy == RuntimePolicy::cuda) + { + bputils::copy>(dst, src, allocatorID); + return; + } +#endif + +#if defined(AXOM_RUNTIME_POLICY_USE_HIP) + if(policy == RuntimePolicy::hip) + { + bputils::copy>(dst, src, allocatorID); + return; + } +#endif + + AXOM_UNUSED_VAR(policy); + AXOM_UNUSED_VAR(allocatorID); + dst.set(src); +} + +void copyBlueprintToHost(conduit::Node& dst, const conduit::Node& src) +{ + axom::bump::utilities::copy(dst, src, hostAllocatorID()); +} + +//--------------------------------------------------------------------------- +// Analytic fields +//--------------------------------------------------------------------------- + +//! @brief Signed distance to a plane. On an axis-aligned grid it produces no +//! ambiguous cells, so it is the case where E3 is safe to assert. +struct PlanarField +{ + double nx, ny, nz, d; + double operator()(double x, double y, double z) const { return nx * x + ny * y + nz * z - d; } +}; + +//! @brief Signed distance to a sphere (3D) / circle (2D). +struct RoundField +{ + double cx, cy, cz, r; + double operator()(double x, double y, double z) const + { + const double dx = x - cx, dy = y - cy, dz = z - cz; + return std::sqrt(dx * dx + dy * dy + dz * dz) - r; + } +}; + +/*! + * @brief A gyroid. + * + * Its curvature produces non-planar cut polygons. That makes it the harshest + * test of the fan-triangulation tolerance in E3. + */ +struct GyroidField +{ + double scale; + double operator()(double x, double y, double z) const + { + const double sx = scale * x, sy = scale * y, sz = scale * z; + return std::sin(sx) * std::cos(sy) + std::sin(sy) * std::cos(sz) + std::sin(sz) * std::cos(sx); + } +}; + +//--------------------------------------------------------------------------- +// Mesh construction. Structured plus explicit meshes that both backends accept. +//--------------------------------------------------------------------------- + +//! @brief Build a single-domain structured explicit mesh on [0,1]^DIM with +//! @a n cells per side, sampling @a f at nodes in i-fastest order. +template +void buildStructured(conduit::Node& mesh, int n, const Field& f, const std::string& fieldName) +{ + static_assert(DIM == 2 || DIM == 3, "DIM must be 2 or 3"); + const int nn = n + 1; + conduit::index_t N = static_cast(nn) * nn; + if(DIM == 3) + { + N *= nn; + } + + mesh.reset(); + + conduit::Node& cs = mesh["coordsets/coords"]; + cs["type"] = "explicit"; + cs["values/x"].set(conduit::DataType::float64(N)); + cs["values/y"].set(conduit::DataType::float64(N)); + auto* x = cs["values/x"].as_float64_ptr(); + auto* y = cs["values/y"].as_float64_ptr(); + double* z = nullptr; + if(DIM == 3) + { + cs["values/z"].set(conduit::DataType::float64(N)); + z = cs["values/z"].as_float64_ptr(); + } + + conduit::Node& topo = mesh["topologies/mesh"]; + topo["type"] = "structured"; + topo["coordset"] = "coords"; + topo["elements/dims/i"] = n; + topo["elements/dims/j"] = n; + if(DIM == 3) + { + topo["elements/dims/k"] = n; + } + + conduit::Node& fld = mesh["fields/" + fieldName]; + fld["topology"] = "mesh"; + fld["association"] = "vertex"; + fld["values"].set(conduit::DataType::float64(N)); + auto* fv = fld["values"].as_float64_ptr(); + + const int nk = (DIM == 3) ? nn : 1; + conduit::index_t idx = 0; + for(int k = 0; k < nk; ++k) + { + for(int j = 0; j < nn; ++j) + { + for(int i = 0; i < nn; ++i, ++idx) + { + const double px = double(i) / n; + const double py = double(j) / n; + const double pz = (DIM == 3) ? double(k) / n : 0.0; + x[idx] = px; + y[idx] = py; + if(z != nullptr) + { + z[idx] = pz; + } + fv[idx] = f(px, py, pz); + } + } + } +} + +/*! + * @brief Build the same box as buildStructured<3>, but as a uniform coordset + uniform topology. + * + * @note n must be a power of two. The explicit builder writes node coordinates + * as double(i)/n while a uniform coordset is evaluated as origin + i*spacing. + * Those agree bit-for-bit only when 1/n is exactly representable, and the test + * below compares vertex sets, so a non-power-of-two n would fail for a reason + * that has nothing to do with the code under test. + */ +template +void buildUniform3D(conduit::Node& mesh, int n, const Field& f, const std::string& fieldName) +{ + const int nn = n + 1; + const conduit::index_t N = static_cast(nn) * nn * nn; + mesh.reset(); + + conduit::Node& cs = mesh["coordsets/coords"]; + cs["type"] = "uniform"; + cs["dims/i"] = nn; + cs["dims/j"] = nn; + cs["dims/k"] = nn; + cs["origin/x"] = 0.0; + cs["origin/y"] = 0.0; + cs["origin/z"] = 0.0; + cs["spacing/dx"] = 1.0 / n; + cs["spacing/dy"] = 1.0 / n; + cs["spacing/dz"] = 1.0 / n; + + conduit::Node& topo = mesh["topologies/mesh"]; + topo["type"] = "uniform"; + topo["coordset"] = "coords"; + + conduit::Node& fld = mesh["fields/" + fieldName]; + fld["topology"] = "mesh"; + fld["association"] = "vertex"; + fld["values"].set(conduit::DataType::float64(N)); + auto* fv = fld["values"].as_float64_ptr(); + conduit::index_t idx = 0; + for(int k = 0; k < nn; ++k) + { + for(int j = 0; j < nn; ++j) + { + for(int i = 0; i < nn; ++i, ++idx) + { + fv[idx] = f(double(i) / n, double(j) / n, double(k) / n); + } + } + } +} + +//! @brief The same box again, as a rectilinear coordset + rectilinear topology. +template +void buildRectilinear3D(conduit::Node& mesh, int n, const Field& f, const std::string& fieldName) +{ + const int nn = n + 1; + const conduit::index_t N = static_cast(nn) * nn * nn; + mesh.reset(); + + conduit::Node& cs = mesh["coordsets/coords"]; + cs["type"] = "rectilinear"; + for(const char* comp : {"x", "y", "z"}) + { + cs[std::string("values/") + comp].set(conduit::DataType::float64(nn)); + auto* v = cs[std::string("values/") + comp].as_float64_ptr(); + for(int i = 0; i < nn; ++i) + { + v[i] = double(i) / n; + } + } + + conduit::Node& topo = mesh["topologies/mesh"]; + topo["type"] = "rectilinear"; + topo["coordset"] = "coords"; + + conduit::Node& fld = mesh["fields/" + fieldName]; + fld["topology"] = "mesh"; + fld["association"] = "vertex"; + fld["values"].set(conduit::DataType::float64(N)); + auto* fv = fld["values"].as_float64_ptr(); + conduit::index_t idx = 0; + for(int k = 0; k < nn; ++k) + { + for(int j = 0; j < nn; ++j) + { + for(int i = 0; i < nn; ++i, ++idx) + { + fv[idx] = f(double(i) / n, double(j) / n, double(k) / n); + } + } + } +} + +/*! + * @brief Build a strided-structured (ghost-padded) version of the same box. + * + * The real zone extent is n^3, but the coordset and field arrays are allocated + * over a padded (n+2*g)^3 window, with a topology that has elements/dims/{offsets,strides}. + * This is the layout quest_marching_cubes_example produces with --strided. + * The test below exercises that layout directly without relying on the example driver. + */ +template +void buildStridedStructured3D(conduit::Node& mesh, + int n, + int g, + const Field& f, + const std::string& fieldName) +{ + const int nnReal = n + 1; // real points per axis + const int nnPad = nnReal + 2 * g; // padded points per axis + const conduit::index_t N = static_cast(nnPad) * nnPad * nnPad; + mesh.reset(); + + conduit::Node& cs = mesh["coordsets/coords"]; + cs["type"] = "explicit"; + for(const char* comp : {"x", "y", "z"}) + { + cs[std::string("values/") + comp].set(conduit::DataType::float64(N)); + } + auto* x = cs["values/x"].as_float64_ptr(); + auto* y = cs["values/y"].as_float64_ptr(); + auto* z = cs["values/z"].as_float64_ptr(); + + conduit::Node& fld = mesh["fields/" + fieldName]; + fld["topology"] = "mesh"; + fld["association"] = "vertex"; + fld["values"].set(conduit::DataType::float64(N)); + auto* fv = fld["values"].as_float64_ptr(); + fld["offsets"].set(std::vector {g, g, g}); + fld["strides"].set(std::vector {1, nnPad, nnPad * nnPad}); + + // Fill the whole padded window; ghosts get values continuing the same field so + // a ghost leak shows up as extra facets rather than as garbage. + conduit::index_t idx = 0; + for(int k = 0; k < nnPad; ++k) + { + for(int j = 0; j < nnPad; ++j) + { + for(int i = 0; i < nnPad; ++i, ++idx) + { + const double px = double(i - g) / n, py = double(j - g) / n, pz = double(k - g) / n; + x[idx] = px; + y[idx] = py; + z[idx] = pz; + fv[idx] = f(px, py, pz); + } + } + } + + conduit::Node& topo = mesh["topologies/mesh"]; + topo["type"] = "structured"; + topo["coordset"] = "coords"; + topo["elements/dims/i"] = n; + topo["elements/dims/j"] = n; + topo["elements/dims/k"] = n; + topo["elements/dims/offsets"].set(std::vector {g, g, g}); + topo["elements/dims/strides"].set(std::vector {1, nnPad, nnPad * nnPad}); +} + +//--------------------------------------------------------------------------- +// Extracted result, normalized so the two backends are comparable +//--------------------------------------------------------------------------- + +struct BackendResult +{ + std::set crossingCells; //!< E1 + std::vector> vertices; //!< E2 (z==0 in 2D) + double measure {0.0}; //!< E3: area in 3D, length in 2D + axom::IndexType facetCount {0}; + axom::IndexType nodeCount {0}; +}; + +template +BackendResult runBackend(const conduit::Node& mesh, + const std::string& fieldName, + double contourVal, + RuntimePolicy policy, + bool useBump, + conduit::Node* bumpBlueprint = nullptr) +{ + namespace quest = axom::quest; + + const int allocatorID = axom::policyToDefaultAllocatorID(policy); + quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); + mc.setUseBumpBackend(useBump); + + conduit::Node execMesh; + copyBlueprintToPolicy(execMesh, mesh, policy, allocatorID); + mc.setMesh(execMesh, "mesh"); + mc.setFunctionField(fieldName); + mc.computeIsocontour(contourVal); + + if(useBump && bumpBlueprint != nullptr) + { + conduit::Node bumpBlueprintExec; + mc.populateContourMeshBlueprint(bumpBlueprintExec); + copyBlueprintToHost(*bumpBlueprint, bumpBlueprintExec); + } + + BackendResult r; + r.facetCount = mc.getContourCellCount(); + r.nodeCount = mc.getContourNodeCount(); + + const axom::Array coordsHost(mc.getContourNodeCoords(), hostAllocatorID()); + const axom::Array cornersHost(mc.getContourFacetCorners(), hostAllocatorID()); + const axom::Array parentsHost(mc.getContourFacetParents(), hostAllocatorID()); + const auto coords = coordsHost.view(); + const auto corners = cornersHost.view(); + const auto parents = parentsHost.view(); + + for(axom::IndexType f = 0; f < r.facetCount; ++f) + { + r.crossingCells.insert(parents[f]); + } + + for(axom::IndexType v = 0; v < r.nodeCount; ++v) + { + axom::primal::Point p {}; + p[0] = coords(v, 0); + p[1] = coords(v, 1); + p[2] = (DIM == 3) ? coords(v, 2) : 0.0; + r.vertices.push_back(p); + } + + for(axom::IndexType f = 0; f < r.facetCount; ++f) + { + if(DIM == 3) + { + axom::primal::Point a {}, b {}, c {}; + for(int d = 0; d < 3; ++d) + { + a[d] = coords(corners(f, 0), d); + b[d] = coords(corners(f, 1), d); + c[d] = coords(corners(f, 2), d); + } + const auto u = axom::primal::Vector(a, b); + const auto w = axom::primal::Vector(a, c); + r.measure += 0.5 * axom::primal::Vector::cross_product(u, w).norm(); + } + else + { + axom::primal::Point a {}, b {}; + for(int d = 0; d < 2; ++d) + { + a[d] = coords(corners(f, 0), d); + b[d] = coords(corners(f, 1), d); + } + r.measure += axom::primal::Vector(a, b).norm(); + } + } + + return r; +} + +//--------------------------------------------------------------------------- +// E2 support: two-sided Hausdorff check via a spatial hash. +// +// Deliberately NOT quantize-and-compare: binning to a grid has a boundary +// problem where two points 1e-12 apart straddle a bin edge and compare +// unequal. Hashing into cells of side `tol` and probing the 3^3 neighborhood +// finds any partner within `tol` regardless of where the bin edges fall. +//--------------------------------------------------------------------------- + +using CellKey = std::int64_t; + +CellKey cellKey(std::int64_t i, std::int64_t j, std::int64_t k) +{ + // Small mixing hash; the coordinate range here is [0,1] so the cell indices + // are bounded by 1/tol and collisions are handled by the bucket vector. + const std::int64_t h = (i * 73856093) ^ (j * 19349663) ^ (k * 83492791); + return h; +} + +class PointLocator +{ +public: + PointLocator(const std::vector>& pts, double tol) + : m_pts(pts) + , m_tol(tol) + { + for(std::size_t n = 0; n < pts.size(); ++n) + { + m_buckets[keyOf(pts[n])].push_back(n); + } + } + + //! @brief Distance from @a q to the nearest stored point, or infinity if none + //! lies within the search neighborhood. + double nearestDistance(const axom::primal::Point& q) const + { + const auto ci = cellIndex(q); + double best = std::numeric_limits::infinity(); + for(std::int64_t di = -1; di <= 1; ++di) + { + for(std::int64_t dj = -1; dj <= 1; ++dj) + { + for(std::int64_t dk = -1; dk <= 1; ++dk) + { + const auto it = m_buckets.find(cellKey(ci[0] + di, ci[1] + dj, ci[2] + dk)); + if(it == m_buckets.end()) + { + continue; + } + for(const auto n : it->second) + { + const double d = axom::primal::Vector(q, m_pts[n]).norm(); + best = std::min(best, d); + } + } + } + } + return best; + } + +private: + axom::StackArray cellIndex(const axom::primal::Point& p) const + { + axom::StackArray c; + for(int d = 0; d < 3; ++d) + { + c[d] = static_cast(std::floor(p[d] / m_tol)); + } + return c; + } + + CellKey keyOf(const axom::primal::Point& p) const + { + const auto c = cellIndex(p); + return cellKey(c[0], c[1], c[2]); + } + + const std::vector>& m_pts; + double m_tol; + std::unordered_map> m_buckets; +}; + +//! @brief One-sided Hausdorff: max over @a from of the distance to the nearest +//! point of @a to. Returns the max and the index attaining it. +double oneSidedHausdorff(const std::vector>& from, + const PointLocator& to, + std::size_t& argMax) +{ + double worst = 0.0; + argMax = 0; + for(std::size_t n = 0; n < from.size(); ++n) + { + const double d = to.nearestDistance(from[n]); + if(d > worst) + { + worst = d; + argMax = n; + } + } + return worst; +} + +//--------------------------------------------------------------------------- +// E3 support 1/2. Ambiguous cells. +// +// Two reasons the case tables can legitimately triangulate the same vertex set +// differently: +// +// Face ambiguity. A face has the checkerboard sign pattern. Its diagonal +// pairs match internally and disagree with each other. +// +// Body-diagonal ambiguity. The minority sign class is exactly a body-diagonal +// pair. This is classic case 4. It has no ambiguous face, so a face-only +// detector misses it. +// +// Expressed in corner SIGNS only, so it is independent of either backend's +// table indexing convention. Corner n is (i,j,k) with i fastest: n = i+2j+4k. +// +// Exhaustively self-tested below (ambiguity_detector_selftest). A detector +// without a negative control is not much use. +//--------------------------------------------------------------------------- + +//! Cyclic corner order of each of the 6 faces. +constexpr int kFaces[6][4] = + {{0, 1, 3, 2}, {4, 5, 7, 6}, {0, 1, 5, 4}, {2, 3, 7, 6}, {0, 2, 6, 4}, {1, 3, 7, 5}}; +//! The 4 body diagonals. +constexpr int kBodyDiagonals[4][2] = {{0, 7}, {1, 6}, {2, 5}, {3, 4}}; + +bool cellHasFaceAmbiguity3D(const bool s[8]) +{ + for(const auto& f : kFaces) + { + if(s[f[0]] == s[f[2]] && s[f[1]] == s[f[3]] && s[f[0]] != s[f[1]]) + { + return true; + } + } + return false; +} + +bool cellHasBodyDiagonalAmbiguity3D(const bool s[8]) +{ + int nPos = 0; + for(int i = 0; i < 8; ++i) + { + nPos += s[i] ? 1 : 0; + } + if(nPos != 2 && nPos != 6) + { + return false; + } + const bool minority = (nPos == 2); + int a = -1, b = -1; + for(int i = 0; i < 8; ++i) + { + if(s[i] == minority) + { + (a < 0 ? a : b) = i; + } + } + for(const auto& d : kBodyDiagonals) + { + if((a == d[0] && b == d[1]) || (a == d[1] && b == d[0])) + { + return true; + } + } + return false; +} + +bool cellIsAmbiguous3D(const bool s[8]) +{ + return cellHasFaceAmbiguity3D(s) || cellHasBodyDiagonalAmbiguity3D(s); +} + +//! 2D: corners cyclic 0,1,3,2 (same n = i+2j convention). Only the two +//! checkerboard patterns are ambiguous. +bool cellIsAmbiguous2D(const bool s[4]) +{ + return (s[0] == s[3]) && (s[1] == s[2]) && (s[0] != s[1]); +} + +/*! + * @brief Count ambiguous cells. + * + * @note The corner test uses `>=`, matching MarchingCubesImpl::computeCrossingCase + * and (after the isoValueForBump nudge) the bump backend. Using `>` here was a + * bug in the first version: it disagreed with the code under test at exactly + * the nodes where the tie convention matters, so the detector could classify a + * different cell set than either backend actually cut. + */ +template +axom::IndexType countAmbiguousCells(int n, const Field& f, double contourVal) +{ + auto sign = [&](int i, int j, int k) { + const double px = double(i) / n, py = double(j) / n, pz = (DIM == 3) ? double(k) / n : 0.0; + return f(px, py, pz) >= contourVal; + }; + + axom::IndexType count = 0; + const int nk = (DIM == 3) ? n : 1; + for(int k = 0; k < nk; ++k) + { + for(int j = 0; j < n; ++j) + { + for(int i = 0; i < n; ++i) + { + if(DIM == 2) + { + const bool s[4] = {sign(i, j, 0), + sign(i + 1, j, 0), + sign(i, j + 1, 0), + sign(i + 1, j + 1, 0)}; + count += cellIsAmbiguous2D(s) ? 1 : 0; + } + else + { + const bool s[8] = {sign(i, j, k), + sign(i + 1, j, k), + sign(i, j + 1, k), + sign(i + 1, j + 1, k), + sign(i, j, k + 1), + sign(i + 1, j, k + 1), + sign(i, j + 1, k + 1), + sign(i + 1, j + 1, k + 1)}; + count += cellIsAmbiguous3D(s) ? 1 : 0; + } + } + } + } + return count; +} + +//--------------------------------------------------------------------------- +// E3 support 2/2. Fan-triangulation sensitivity. +// +// The quest adaptor fan-triangulates bump's polygonal cut faces from local +// corner 0 (MarchingCubesBumpAdaptor.hpp, adaptCutFieldOutputViews). For a +// planar polygon every fan gives the same area. For a non-planar polygon the +// area depends on which corner the fan starts from, so the reported surface +// area is partly an artifact of an arbitrary choice. On a high-curvature field +// the cut polygons can be markedly non-planar. That, not table ambiguity, is +// what makes a total-area comparison against a differently triangulated backend +// questionable. +// +// Measure it instead of guessing. Re-fan each polygon from corner 1 and report +// the relative spread. Zero spread means every polygon is planar, or already a +// triangle. In that case E3 is a fair comparison. +//--------------------------------------------------------------------------- + +struct FanSensitivity +{ + double areaFan0 {0.0}; + double areaFan1 {0.0}; + double relSpread {0.0}; //!< Aggregate |A(fan@0) - A(fan@1)| / A(fan@0) + double maxPolyRelSpread {0.0}; //!< Worst single-polygon spread. The aggregate can cancel. + axom::IndexType polygonCount {0}; + axom::IndexType maxCorners {0}; +}; + +double triArea(const axom::primal::Point& a, + const axom::primal::Point& b, + const axom::primal::Point& c) +{ + const auto u = axom::primal::Vector(a, b); + const auto w = axom::primal::Vector(a, c); + return 0.5 * axom::primal::Vector::cross_product(u, w).norm(); +} + +//! @brief Area of a polygon fan-triangulated starting at local corner @a origin. +double polygonFanArea(const std::vector>& v, int origin) +{ + const int N = static_cast(v.size()); + if(N < 3) + { + return 0.0; + } + double area = 0.0; + for(int t = 0; t < N - 2; ++t) + { + area += triArea(v[origin], v[(origin + 1 + t) % N], v[(origin + 2 + t) % N]); + } + return area; +} + +//! @brief Measure how much bump's polygonal output's area depends on the fan origin. +FanSensitivity measureFanSensitivity(const conduit::Node& contourDom) +{ + FanSensitivity fs; + + const conduit::Node& topo = contourDom.fetch_existing("topologies").child(0); + const conduit::Node& elems = topo.fetch_existing("elements"); + if(!elems.has_child("sizes")) + { + return fs; + } + const auto sizes = elems.fetch_existing("sizes").as_index_t_accessor(); + const auto offsets = elems.fetch_existing("offsets").as_index_t_accessor(); + const auto conn = elems.fetch_existing("connectivity").as_index_t_accessor(); + + const std::string csName = topo.fetch_existing("coordset").as_string(); + const conduit::Node& vals = contourDom.fetch_existing("coordsets/" + csName + "/values"); + const bool has3 = vals.has_child("z"); + const auto xs = vals.fetch_existing("x").as_double_accessor(); + const auto ys = vals.fetch_existing("y").as_double_accessor(); + + for(conduit::index_t z = 0; z < sizes.number_of_elements(); ++z) + { + const auto nc = sizes[z]; + fs.maxCorners = std::max(fs.maxCorners, static_cast(nc)); + if(nc < 3) + { + continue; + } + ++fs.polygonCount; + std::vector> v; + for(conduit::index_t c = 0; c < nc; ++c) + { + const auto id = conn[offsets[z] + c]; + axom::primal::Point p {}; + p[0] = xs[id]; + p[1] = ys[id]; + p[2] = has3 ? vals.fetch_existing("z").as_double_accessor()[id] : 0.0; + v.push_back(p); + } + const double a0 = polygonFanArea(v, 0); + const double a1 = polygonFanArea(v, 1); + fs.areaFan0 += a0; + fs.areaFan1 += a1; + fs.maxPolyRelSpread = std::max(fs.maxPolyRelSpread, std::abs(a0 - a1) / std::max(a0, 1.0e-300)); + } + + fs.relSpread = std::abs(fs.areaFan0 - fs.areaFan1) / std::max(fs.areaFan0, 1.0e-300); + return fs; +} + +//--------------------------------------------------------------------------- +// The comparison itself +//--------------------------------------------------------------------------- + +template +void compareBackends(int n, + const Field& f, + double contourVal, + RuntimePolicy policy, + const std::string& label, + double vertexTol = 1.0e-5, + // bump's FieldIntersector interpolates edge crossings in + // float (FieldType == float), so contour vertices carry + // ~1e-7 relative error and the measure inherits it. An + // initial 1e-9 here was a TEST bug, not a code defect: it + // is below what single-precision interpolation can deliver. + // Measured relDiff on the passing cases is 5e-9 to 3e-6. + double measureRelTol = 1.0e-5) +{ + const std::string fieldName = "fcn"; + conduit::Node mesh; + buildStructured(mesh, n, f, fieldName); + + conduit::Node info; + ASSERT_TRUE(conduit::blueprint::mesh::verify(mesh, info)) << info.to_yaml(); + + const auto legacy = runBackend(mesh, fieldName, contourVal, policy, /*useBump=*/false); + conduit::Node bumpBp; + const auto bump = runBackend(mesh, fieldName, contourVal, policy, /*useBump=*/true, &bumpBp); + + const auto ambiguous = countAmbiguousCells(n, f, contourVal); + FanSensitivity fan; + if(DIM == 3 && bumpBp.number_of_children() > 0) + { + fan = measureFanSensitivity(bumpBp.child(0)); + } + + SLIC_INFO(axom::fmt::format( + "[{}] legacy: {} facets / {} nodes / {} cells; bump: {} facets / {} nodes / {} cells; " + "ambiguous cells: {}", + label, + legacy.facetCount, + legacy.nodeCount, + legacy.crossingCells.size(), + bump.facetCount, + bump.nodeCount, + bump.crossingCells.size(), + ambiguous)); + + ASSERT_GT(legacy.facetCount, 0) << "[" << label << "] legacy produced an empty contour"; + ASSERT_GT(bump.facetCount, 0) << "[" << label << "] bump produced an empty contour"; + + // E1. crossing-cell set + { + std::vector onlyLegacy, onlyBump; + std::set_difference(legacy.crossingCells.begin(), + legacy.crossingCells.end(), + bump.crossingCells.begin(), + bump.crossingCells.end(), + std::back_inserter(onlyLegacy)); + std::set_difference(bump.crossingCells.begin(), + bump.crossingCells.end(), + legacy.crossingCells.begin(), + legacy.crossingCells.end(), + std::back_inserter(onlyBump)); + + EXPECT_TRUE(onlyLegacy.empty()) + << "E1 [" << label << "]: " << onlyLegacy.size() + << " cells produce facets in legacy but not bump (first: " << onlyLegacy.front() + << "). A cell dropped by the bump crossing pre-filter is the likely cause."; + EXPECT_TRUE(onlyBump.empty()) << "E1 [" << label << "]: " << onlyBump.size() + << " cells produce facets in bump but not legacy (first: " + << (onlyBump.empty() ? -1 : onlyBump.front()) << ")."; + } + + // E2. vertex set (table-independent; see file header) + { + const PointLocator legacyLoc(legacy.vertices, vertexTol); + const PointLocator bumpLoc(bump.vertices, vertexTol); + + std::size_t argMax = 0; + const double bumpToLegacy = oneSidedHausdorff(bump.vertices, legacyLoc, argMax); + EXPECT_LT(bumpToLegacy, vertexTol) + << "E2 [" << label << "]: a bump contour vertex has no legacy counterpart within tolerance" + << " (worst distance " << bumpToLegacy << " at bump vertex " << argMax << ")."; + + const double legacyToBump = oneSidedHausdorff(legacy.vertices, bumpLoc, argMax); + EXPECT_LT(legacyToBump, vertexTol) + << "E2 [" << label << "]: a legacy contour vertex has no bump counterpart within tolerance" + << " (worst distance " << legacyToBump << " at legacy vertex " << argMax << ")."; + } + + // E2b. welding happened + // Legacy stores DIM nodes per facet with no sharing; bump welds. On a + // surface with shared edges the welded count must be strictly smaller. + EXPECT_LT(bump.nodeCount, legacy.nodeCount) + << "[" << label << "]: bump node count is not smaller than legacy's. Welding regressed."; + + // E3. measure (assert only when unambiguous) + const double relDiff = std::abs(bump.measure - legacy.measure) / std::max(legacy.measure, 1.0e-300); + SLIC_INFO(axom::fmt::format("[{}] measure legacy={:.12g} bump={:.12g} relDiff={:.3e}", + label, + legacy.measure, + bump.measure, + relDiff)); + SLIC_INFO(axom::fmt::format( + "[{}] fan sensitivity: {} polygons, max {} corners, area(fan@0)={:.12g} area(fan@1)={:.12g} " + "relSpread={:.3e} maxPolyRelSpread={:.3e}", + label, + fan.polygonCount, + fan.maxCorners, + fan.areaFan0, + fan.areaFan1, + fan.relSpread, + fan.maxPolyRelSpread)); + + // E3 tolerance. + // + // Do not gate the check on fan spread. That creates cliffs where a tiny change + // in resolution flips the assertion on and off. Instead fold the measured fan + // spread into the tolerance. bump's reported area is only stable within that + // spread. Differences below it do not say much. Differences above it do. + const double e3Tol = std::max(measureRelTol, fan.relSpread); + EXPECT_LT(fan.relSpread, 0.1) + << "[" << label << "]: fan-origin spread is so large that E3 is nearly vacuous; bump's " + << "polygons are extremely non-planar at this resolution."; + if(ambiguous == 0) + { + EXPECT_LT(relDiff, e3Tol) + << "E3 [" << label << "]: contour measure differs by more than the fan-origin ambiguity (" + << fan.relSpread << ") can explain, and there are no ambiguous cells, so the two backends " + << "disagree on the triangulation of an identical vertex set."; + } + else + { + SLIC_INFO(axom::fmt::format( + "[{}] E3 not asserted: {} ambiguous cells, where the two case tables may legitimately " + "triangulate the same vertex set differently and no principled tolerance exists.", + label, + ambiguous)); + } +} + +/*! + * @brief An isovalue outside the data range is valid input, not an error. + * + * Before the fix, runExtraction() allocated m_output before dispatching and left + * it non-null-but-EMPTY on the no-crossing path. hasContourMeshBlueprint() then + * reported true, populateContourMeshBlueprint passed its guard, and + * triangulateBlueprintMesh reached fetch_existing("topologies") on an empty node. + */ +void test_empty_contour(RuntimePolicy policy) +{ + namespace quest = axom::quest; + conduit::Node mesh; + RoundField f {0.5, 0.5, 0.5, 0.25}; + buildStructured<3>(mesh, 6, f, "fcn"); + + const int allocatorID = axom::policyToDefaultAllocatorID(policy); + quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); + mc.setUseBumpBackend(true); + mc.setMesh(mesh, "mesh"); + mc.setFunctionField("fcn"); + mc.computeIsocontour(1000.0); // far outside the range of the signed distance + + EXPECT_EQ(mc.getContourCellCount(), 0); + EXPECT_EQ(mc.getContourNodeCount(), 0); + + // Neither of these may throw or abort. + conduit::Node bp; + mc.populateContourMeshBlueprint(bp); + conduit::Node bpTri; + mc.populateContourMeshBlueprint(bpTri, /*triangulate=*/true); + + conduit::Node relinquished; + mc.relinquishContourDataBlueprint(relinquished); + SUCCEED(); +} + +/*! + * @brief Uniform and rectilinear input must work, and must agree with the + * explicit-structured mesh describing the same geometry. + * + * setDomain() accepts "uniform" and "rectilinear" and the sphinx/RELEASE-NOTES + * advertise them, but every m_isStructured path constructed MeshViewUtil, which + * requires a "structured" topology AND an "explicit" coordset and SLIC_ERRORs + * otherwise. That used to hard-error inside the crossing pre-filter. + * + * The legacy backend cannot read uniform or rectilinear input, so there is no + * direct legacy reference. Instead compare bump-on-uniform and + * bump-on-rectilinear against bump-on-structured-explicit. The tests above + * already pin bump-on-structured-explicit to the legacy backend. All three + * describe the same box, so the results must be identical. + */ +void test_uniform_and_rectilinear(RuntimePolicy policy) +{ + const int n = 8; // power of two: see buildUniform3D's note on coordinate agreement + RoundField f {0.5, 0.5, 0.5, 0.25}; + const std::string fieldName = "fcn"; + + conduit::Node structured, uniform, rectilinear; + buildStructured<3>(structured, n, f, fieldName); + buildUniform3D(uniform, n, f, fieldName); + buildRectilinear3D(rectilinear, n, f, fieldName); + + for(const auto& m : {&uniform, &rectilinear}) + { + conduit::Node info; + ASSERT_TRUE(conduit::blueprint::mesh::verify(*m, info)) << info.to_yaml(); + } + + const auto refRun = runBackend<3>(structured, fieldName, 0.0, policy, /*useBump=*/true); + ASSERT_GT(refRun.facetCount, 0); + + const auto uniformRun = runBackend<3>(uniform, fieldName, 0.0, policy, /*useBump=*/true); + const auto rectRun = runBackend<3>(rectilinear, fieldName, 0.0, policy, /*useBump=*/true); + + SLIC_INFO( + axom::fmt::format("[uniform/rectilinear] structured-explicit: {} facets; uniform: {} facets; " + "rectilinear: {} facets", + refRun.facetCount, + uniformRun.facetCount, + rectRun.facetCount)); + + for(const auto& kv : + {std::make_pair("uniform", &uniformRun), std::make_pair("rectilinear", &rectRun)}) + { + const std::string what = kv.first; + const BackendResult& r = *kv.second; + EXPECT_EQ(r.crossingCells, refRun.crossingCells) + << what << " cut a different cell set than the equivalent structured-explicit mesh"; + EXPECT_EQ(r.facetCount, refRun.facetCount) << what << " produced a different facet count"; + EXPECT_EQ(r.nodeCount, refRun.nodeCount) << what << " produced a different node count"; + EXPECT_NEAR(r.measure, refRun.measure, 1.0e-12 * std::max(refRun.measure, 1.0)) + << what << " produced a different contour measure"; + } +} + +/*! + * @brief A float32 function field must be rejected, not silently misread. + * + * The structured pre-filter reads the field via + * MeshViewUtil::getConstFieldView(), which assumes the values are + * double and does not check. A float32 field was reinterpreted as float64, so + * the pre-filter selected a garbage cell set while bump's extractor read the + * field correctly. That is a wrong answer with no error. The minimum bar here + * is a loud rejection that names the dtype. + */ +void test_float32_field_rejected(RuntimePolicy policy) +{ + namespace quest = axom::quest; + const int n = 6; + RoundField f {0.5, 0.5, 0.5, 0.25}; + conduit::Node mesh; + buildStructured<3>(mesh, n, f, "fcn"); + + // Re-write the function field as float32, keeping the same values. + { + const conduit::Node& n_old = mesh.fetch_existing("fields/fcn/values"); + const auto acc = n_old.as_double_accessor(); + const conduit::index_t N = n_old.dtype().number_of_elements(); + std::vector tmp(static_cast(N)); + for(conduit::index_t i = 0; i < N; ++i) + { + tmp[static_cast(i)] = static_cast(acc[i]); + } + mesh["fields/fcn/values"].set(tmp.data(), static_cast(tmp.size())); + } + ASSERT_TRUE(mesh.fetch_existing("fields/fcn/values").dtype().is_float32()); + + const int allocatorID = axom::policyToDefaultAllocatorID(policy); + quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); + mc.setUseBumpBackend(true); + + // SLIC's default handler aborts, so this is a death test. SimpleLogger writes + // to stdout while gtest's death test captures only stderr, so the message must + // be routed to stderr INSIDE the forked child or the regex has nothing to + // match (an empty "Actual msg" is the symptom). + EXPECT_DEATH_IF_SUPPORTED( + { + axom::slic::addStreamToAllMsgLevels( + new axom::slic::GenericOutputStream(&std::cerr, "[] \n")); + mc.setMesh(mesh, "mesh"); + mc.setFunctionField("fcn"); + mc.computeIsocontour(0.0); + }, + "float64"); +} + +//! @brief Run the bump backend in a death-test child and require a field-layout error. +void expectBumpFieldLayoutRejected(const conduit::Node& mesh, + RuntimePolicy policy, + const char* expectedMessage) +{ + EXPECT_DEATH_IF_SUPPORTED( + { + axom::slic::addStreamToAllMsgLevels( + new axom::slic::GenericOutputStream(&std::cerr, "[] \n")); + const int allocatorID = axom::policyToDefaultAllocatorID(policy); + axom::quest::MarchingCubes mc(policy, + allocatorID, + axom::quest::MarchingCubesDataParallelism::byPolicy); + mc.setUseBumpBackend(true); + mc.setMesh(mesh, "mesh"); + mc.setFunctionField("fcn"); + mc.computeIsocontour(0.0); + }, + expectedMessage); +} + +/*! + * @brief Unsupported strided function-field layouts must be rejected rather + * than silently indexed with the topology's compact zone numbering. + * + * A permuted field is not i-fastest. A separately padded field can remain + * i-fastest, but its offsets and strides differ from those of the topology. + * bump's flat field view cannot represent either case correctly. + */ +void test_invalid_field_layouts_rejected(RuntimePolicy policy) +{ + constexpr int n = 6; + constexpr int pad = 2; + constexpr int nnPad = n + 1 + 2 * pad; + RoundField f {0.5, 0.5, 0.5, 0.25}; + + conduit::Node permuted; + buildStridedStructured3D(permuted, n, pad, f, "fcn"); + permuted["fields/fcn/strides"].set(std::vector {nnPad * nnPad, nnPad, 1}); + expectBumpFieldLayoutRejected(permuted, policy, "i-fastest"); + + conduit::Node mismatched; + buildStridedStructured3D(mismatched, n, pad, f, "fcn"); + mismatched["fields/fcn/offsets"].set(std::vector {pad + 1, pad, pad}); + expectBumpFieldLayoutRejected(mismatched, policy, "same offsets and strides"); +} + +/*! + * @brief An input mesh already carrying a field named "originalElements" must + * not hijack the reported parent cell ids. + * + * bump's TableBasedExtractor::makeOriginalElements branches on whether the INPUT + * mesh has a field of the configured name and, if so, maps those values forward + * instead of writing zone indices. Any mesh produced by a prior bump operation + * carries exactly that field, and the empty "fields" option does not suppress + * the branch. MarchingCubes therefore requests a private name and renames the + * result back before anyone sees it. + */ +void test_original_elements_collision(RuntimePolicy policy) +{ + const int n = 8; + RoundField f {0.5, 0.5, 0.5, 0.25}; + const std::string fieldName = "fcn"; + + conduit::Node clean, poisoned; + buildStructured<3>(clean, n, f, fieldName); + poisoned.set(clean); + + // Plant a decoy: an element field of the colliding name whose values are + // deliberately nothing like zone indices. + { + const conduit::index_t nCells = static_cast(n) * n * n; + conduit::Node& fld = poisoned["fields/originalElements"]; + fld["topology"] = "mesh"; + fld["association"] = "element"; + fld["values"].set(conduit::DataType::int64(nCells)); + auto* v = fld["values"].as_int64_ptr(); + for(conduit::index_t i = 0; i < nCells; ++i) + { + v[i] = -7; // if these leak through as parent ids, the check below fails + } + } + conduit::Node info; + ASSERT_TRUE(conduit::blueprint::mesh::verify(poisoned, info)) << info.to_yaml(); + + const auto cleanRun = runBackend<3>(clean, fieldName, 0.0, policy, /*useBump=*/true); + const auto poisonedRun = runBackend<3>(poisoned, fieldName, 0.0, policy, /*useBump=*/true); + + ASSERT_GT(cleanRun.facetCount, 0); + EXPECT_EQ(poisonedRun.crossingCells, cleanRun.crossingCells) + << "a pre-existing 'originalElements' field on the input changed the reported parent ids"; + for(const auto id : poisonedRun.crossingCells) + { + EXPECT_GE(id, 0) << "decoy 'originalElements' values leaked through as parent cell ids"; + } +} + +/*! + * @brief Strided-structured input works with the bump backend. + * + * This directly covers the native strided path. Earlier example-driver + * compaction hid it by densifying coordinates and stripping metadata before + * MarchingCubes saw the mesh. + * + * Static reading says it should work. dispatch_only_structured_topology routes + * elements/dims/{offsets,strides} to make_strided_structured_topology, and + * StridedStructuredIndexing::indexToLogicalIndex uses the LOCAL zone dims, so + * bump's zone index space is compact and i-fastest. That matches quest's + * MDMapping(cellShape, COLUMN). This test settles it empirically. + * + * Oracle. Use the same geometry as a dense structured mesh, which the tests + * above already pin to the legacy backend. Ghost values are poisoned, so an + * implementation that ignores the offsets cannot accidentally agree. + */ +void test_strided_structured(RuntimePolicy policy) +{ + const int n = 8; + const int pad = 2; // ghost layers + RoundField f {0.5, 0.5, 0.5, 0.25}; + const std::string fieldName = "fcn"; + + conduit::Node compact, strided; + buildStructured<3>(compact, n, f, fieldName); + buildStridedStructured3D(strided, n, pad, f, fieldName); + + conduit::Node info; + ASSERT_TRUE(conduit::blueprint::mesh::verify(strided, info)) << info.to_yaml(); + + /* + Three-way agreement on ghost-padded input. + + This faulted before the dispatch-path fix in dispatch_structured_topology.hpp: + the strided predicate probed topo.has_path("offsets") instead of + "elements/dims/offsets", so a padded mesh was read as compact and makeTopology + walked off the end of the coordset. + + Legacy-on-compact is the reference (pinned to bump-on-compact by the tests + above). Legacy-on-strided shows the padded fixture is well formed, so a + bump-only failure cannot be blamed on the fixture. Bump-on-strided is the + claim. The parent-id range check catches a ghost leak, which is the failure + mode a facet count alone would miss. + */ + const auto legacyCompact = runBackend<3>(compact, fieldName, 0.0, policy, /*useBump=*/false); + const auto legacyStrided = runBackend<3>(strided, fieldName, 0.0, policy, /*useBump=*/false); + const auto bumpStrided = runBackend<3>(strided, fieldName, 0.0, policy, /*useBump=*/true); + + SLIC_INFO(axom::fmt::format( + "[strided] legacy/compact={} facets, legacy/strided={} facets, bump/strided={} facets", + legacyCompact.facetCount, + legacyStrided.facetCount, + bumpStrided.facetCount)); + + ASSERT_GT(legacyCompact.facetCount, 0); + ASSERT_EQ(legacyStrided.crossingCells, legacyCompact.crossingCells) + << "the legacy backend disagrees between strided and compact input, so the padded fixture " + << "is malformed. Fix the fixture before reading anything into the bump result"; + + EXPECT_EQ(bumpStrided.crossingCells, legacyCompact.crossingCells) + << "bump on strided-structured input cut a different cell set than the equivalent compact " + << "mesh: a ghost leak or a zone-numbering mismatch"; + + const axom::IndexType nCells = static_cast(n) * n * n; + for(const auto id : bumpStrided.crossingCells) + { + EXPECT_GE(id, 0) << "parent id below the real zone range (ghost leak)"; + EXPECT_LT(id, nCells) << "parent id above the real zone range (ghost leak)"; + } +} + +//--------------------------------------------------------------------------- +// Test bodies +//--------------------------------------------------------------------------- + +void test_planar_3d(RuntimePolicy policy) +{ + // Plane z = 0.5. An axis-aligned planar field has no saddle cells, so E3 is + // safe to assert here. This is the strictest case. + PlanarField f {0.0, 0.0, 1.0, 0.5}; + compareBackends<3>(8, f, 0.0, policy, "planar3d"); +} + +void test_oblique_planar_3d(RuntimePolicy policy) +{ + // Oblique plane: still no saddles, but every case-table entry gets exercised + // rather than just the axis-aligned ones. + const double s = 1.0 / std::sqrt(1.0 + 0.16 + 1.44); + PlanarField f {1.0 * s, 0.4 * s, 1.2 * s, 1.3 * s}; + compareBackends<3>(8, f, 0.0, policy, "oblique_planar3d"); +} + +void test_round_3d(RuntimePolicy policy) +{ + RoundField f {0.5, 0.5, 0.5, 0.25}; + compareBackends<3>(12, f, 0.0, policy, "round3d"); +} + +void test_gyroid_3d(RuntimePolicy policy) +{ + // Chosen to produce strongly non-planar polygons. E3 remains asserted, with + // its tolerance widened by the independently measured fan-origin spread. + GyroidField f {3.0 * M_PI}; + compareBackends<3>(10, f, 0.0, policy, "gyroid3d"); +} + +void test_planar_2d(RuntimePolicy policy) +{ + PlanarField f {0.0, 1.0, 0.0, 0.5}; + compareBackends<2>(8, f, 0.0, policy, "planar2d"); +} + +void test_round_2d(RuntimePolicy policy) +{ + RoundField f {0.5, 0.5, 0.0, 0.25}; + compareBackends<2>(12, f, 0.0, policy, "round2d"); +} + +/*! + * @brief Falsification control for E1. + * + * The bump crossing pre-filter classifies corners in double + * (`fcnView(...) > m_contourVal`) while bump's FieldIntersector classifies in + * float (`FieldIntersector::FieldType == float`). Since float() is monotone, + * bump's positive set is always a subset of the pre-filter's, and the dangerous + * direction is a cell whose corners are ALL strictly above the isovalue in + * double but where at least one rounds to exactly float(isovalue): the + * pre-filter excludes the cell as non-crossing, while bump would have emitted a + * fragment. + * + * This test constructs exactly that cell. It is expected to FAIL on the branch + * as-is, and to pass once the pre-filter compares in the intersector's type. + * A regression test that has never been observed to fail is not evidence; this + * one demonstrates that E1 can detect the specific defect. + */ +void test_float_ulp_band_falsification(RuntimePolicy policy) +{ + const int n = 4; + const std::string fieldName = "fcn"; + const double contourVal = 1.0; + + conduit::Node mesh; + PlanarField f {0.0, 0.0, 1.0, 0.5}; + buildStructured<3>(mesh, n, f, fieldName); + + // Overwrite the field: put every node strictly above the isovalue in double, + // then pull one cell's corners into the band [contourVal, contourVal+ulp) + // where float rounds them down onto float(contourVal). + auto* fv = mesh["fields/" + fieldName + "/values"].as_float64_ptr(); + const conduit::index_t N = mesh["fields/" + fieldName + "/values"].dtype().number_of_elements(); + const int nn = n + 1; + auto nodeAt = [&](int i, int j, int k) { return i + j * nn + k * nn * nn; }; + for(conduit::index_t i = 0; i < N; ++i) + { + fv[i] = contourVal + 1.0; + } + // Put a genuine contour in the upper part of the mesh. Without it the entire + // field sits above the isovalue, both paths report zero facets, and the + // comparison below passes as 0 == 0. That would not catch a regression that + // breaks both paths together. + for(int k = 3; k <= n; ++k) + { + for(int j = 0; j < nn; ++j) + { + for(int i = 0; i < nn; ++i) + { + fv[nodeAt(i, j, k)] = contourVal - 1.0; + } + } + } + // Cell (0,0,0): seven corners well above, one corner just barely above in + // double but equal to contourVal after rounding to float. + const double tiny = std::nextafter(contourVal, 2.0) - contourVal; // one double ULP + fv[nodeAt(0, 0, 0)] = contourVal + tiny; + fv[nodeAt(1, 0, 0)] = contourVal + tiny; + fv[nodeAt(0, 1, 0)] = contourVal + tiny; + fv[nodeAt(1, 1, 0)] = contourVal + tiny; + + // Sanity: in double every corner is strictly above; in float the perturbed + // ones are not. If this fails, the platform's float rounding differs and the + // test premise is void. + ASSERT_GT(fv[nodeAt(0, 0, 0)], contourVal); + ASSERT_FALSE(static_cast(fv[nodeAt(0, 0, 0)]) > static_cast(contourVal)) + << "premise void: the perturbed value does not collapse onto float(contourVal)"; + + // Build the SAME geometry as an unstructured hex topology. This is the + // control that makes the test discriminating: the unstructured path's + // pre-filter calls intersectorView.determineTableCase() (i.e. bump's own + // float classification), while the structured path re-implements the + // classification in double. Same nodes, same field, same cells. Any + // difference isolates the defect to the structured pre-filter rather than + // resting on an unverified claim about what bump "would" emit. + conduit::Node unstructuredMesh; + unstructuredMesh.set(mesh); + { + conduit::Node& topo = unstructuredMesh["topologies/mesh"]; + topo.reset(); + topo["type"] = "unstructured"; + topo["coordset"] = "coords"; + topo["elements/shape"] = "hex"; + const conduit::index_t nCells = static_cast(n) * n * n; + topo["elements/connectivity"].set(conduit::DataType::int64(nCells * 8)); + auto* c = topo["elements/connectivity"].as_int64_ptr(); + conduit::index_t at = 0; + for(int k = 0; k < n; ++k) + { + for(int j = 0; j < n; ++j) + { + for(int i = 0; i < n; ++i) + { + c[at++] = nodeAt(i, j, k); + c[at++] = nodeAt(i + 1, j, k); + c[at++] = nodeAt(i + 1, j + 1, k); + c[at++] = nodeAt(i, j + 1, k); + c[at++] = nodeAt(i, j, k + 1); + c[at++] = nodeAt(i + 1, j, k + 1); + c[at++] = nodeAt(i + 1, j + 1, k + 1); + c[at++] = nodeAt(i, j + 1, k + 1); + } + } + } + } + conduit::Node uinfo; + ASSERT_TRUE(conduit::blueprint::mesh::verify(unstructuredMesh, uinfo)) << uinfo.to_yaml(); + + const auto structuredRun = runBackend<3>(mesh, fieldName, contourVal, policy, /*useBump=*/true); + const auto unstructuredRun = + runBackend<3>(unstructuredMesh, fieldName, contourVal, policy, /*useBump=*/true); + + SLIC_INFO(axom::fmt::format( + "[ulp_band] bump/structured: {} facets; bump/unstructured (same geometry): {} facets", + structuredRun.facetCount, + unstructuredRun.facetCount)); + + ASSERT_GT(structuredRun.facetCount, 0) + << "the comparison below would be vacuous: this mesh must carry a real contour"; + EXPECT_EQ(structuredRun.crossingCells, unstructuredRun.crossingCells) + << "the two paths cut different cell sets on identical geometry"; + EXPECT_EQ(structuredRun.facetCount, unstructuredRun.facetCount) + << "E1 falsification: the bump backend gives different answers for the same geometry " + << "depending on whether the topology is structured or unstructured. The structured " + << "crossing pre-filter classifies corners in double (fcnView(...) > m_contourVal) while " + << "the unstructured path delegates to intersectorView.determineTableCase(), which " + << "classifies in FieldIntersector::FieldType (float). Fix: make the structured " + << "pre-filter compare in the intersector's type."; +} + +} // namespace + +//--------------------------------------------------------------------------- + +//--------------------------------------------------------------------------- +// Self-test for the ambiguity detector. +// +// A detector used to gate an assertion needs its own negative controls, or a +// silently-always-false detector would make E3 look permanently trustworthy. +// Expected counts were derived independently (by enumeration outside this +// file) before being written here: +// - 120 of 256 sign patterns have an ambiguous face; +// - 8 of 256 are the body-diagonal (case 4) pattern and its complement; +// - the two classes are disjoint. Case 4 has no ambiguous face, which is +// exactly why a face-only detector misses it; +// - 128 of 256 total, i.e. exactly half, and complement-symmetric. +//--------------------------------------------------------------------------- +TEST(quest_marching_cubes_equivalence, ambiguity_detector_selftest) +{ + auto pattern = [](int mask, bool s[8]) { + for(int i = 0; i < 8; ++i) + { + s[i] = ((mask >> i) & 1) != 0; + } + }; + + int nFace = 0, nBody = 0, nBoth = 0, nAny = 0; + for(int mask = 0; mask < 256; ++mask) + { + bool s[8]; + pattern(mask, s); + const bool fa = cellHasFaceAmbiguity3D(s); + const bool ba = cellHasBodyDiagonalAmbiguity3D(s); + nFace += fa ? 1 : 0; + nBody += ba ? 1 : 0; + nBoth += (fa && ba) ? 1 : 0; + nAny += cellIsAmbiguous3D(s) ? 1 : 0; + + // Complement symmetry: flipping every sign cannot change whether the + // configuration is ambiguous. + bool c[8]; + for(int i = 0; i < 8; ++i) + { + c[i] = !s[i]; + } + EXPECT_EQ(cellIsAmbiguous3D(s), cellIsAmbiguous3D(c)) + << "complement symmetry violated for sign mask " << mask; + } + + EXPECT_EQ(nFace, 120); + EXPECT_EQ(nBody, 8); + EXPECT_EQ(nBoth, 0) << "face and body-diagonal ambiguity should be disjoint classes"; + EXPECT_EQ(nAny, 128); + + // Named configurations. Corner n is (i,j,k) with i fastest: n = i + 2j + 4k. + auto mk = [&](std::initializer_list on, bool s[8]) { + for(int i = 0; i < 8; ++i) + { + s[i] = false; + } + for(int i : on) + { + s[i] = true; + } + }; + bool s[8]; + + mk({}, s); + EXPECT_FALSE(cellIsAmbiguous3D(s)) << "all-outside must not be ambiguous (negative control)"; + mk({0, 1, 2, 3, 4, 5, 6, 7}, s); + EXPECT_FALSE(cellIsAmbiguous3D(s)) << "all-inside must not be ambiguous (negative control)"; + mk({0}, s); + EXPECT_FALSE(cellIsAmbiguous3D(s)) << "case 1 (single corner)"; + mk({0, 1}, s); + EXPECT_FALSE(cellIsAmbiguous3D(s)) << "case 2 (edge pair)"; + mk({0, 1, 2, 3}, s); + EXPECT_FALSE(cellIsAmbiguous3D(s)) << "case 8 (whole face)"; + mk({0, 3}, s); + EXPECT_TRUE(cellHasFaceAmbiguity3D(s)) << "case 3 (face diagonal) is face-ambiguous"; + mk({0, 7}, s); + EXPECT_FALSE(cellHasFaceAmbiguity3D(s)) + << "case 4 (body diagonal) has no ambiguous face. This is why a face-only " + "detector misses it, and why cellHasBodyDiagonalAmbiguity3D exists"; + EXPECT_TRUE(cellHasBodyDiagonalAmbiguity3D(s)) << "case 4 (body diagonal)"; + EXPECT_TRUE(cellIsAmbiguous3D(s)); + + // 2D: exactly the two checkerboards of the 16 patterns. + int n2 = 0; + for(int mask = 0; mask < 16; ++mask) + { + bool q[4]; + for(int i = 0; i < 4; ++i) + { + q[i] = ((mask >> i) & 1) != 0; + } + n2 += cellIsAmbiguous2D(q) ? 1 : 0; + } + EXPECT_EQ(n2, 2); +} + +TEST(quest_marching_cubes_equivalence, planar_3d_seq) { test_planar_3d(RuntimePolicy::seq); } +TEST(quest_marching_cubes_equivalence, oblique_planar_3d_seq) +{ + test_oblique_planar_3d(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_equivalence, round_3d_seq) { test_round_3d(RuntimePolicy::seq); } +TEST(quest_marching_cubes_equivalence, gyroid_3d_seq) { test_gyroid_3d(RuntimePolicy::seq); } +TEST(quest_marching_cubes_equivalence, planar_2d_seq) { test_planar_2d(RuntimePolicy::seq); } +TEST(quest_marching_cubes_equivalence, round_2d_seq) { test_round_2d(RuntimePolicy::seq); } +TEST(quest_marching_cubes_equivalence, uniform_and_rectilinear_seq) +{ + test_uniform_and_rectilinear(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_equivalence, strided_structured_seq) +{ + test_strided_structured(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_equivalence, float32_field_rejected_seq) +{ + test_float32_field_rejected(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_equivalence, invalid_field_layouts_rejected_seq) +{ + test_invalid_field_layouts_rejected(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_equivalence, original_elements_collision_seq) +{ + test_original_elements_collision(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_equivalence, empty_contour_seq) +{ + test_empty_contour(RuntimePolicy::seq); +} +TEST(quest_marching_cubes_equivalence, float_ulp_band_falsification_seq) +{ + test_float_ulp_band_falsification(RuntimePolicy::seq); +} + +#if defined(AXOM_RUNTIME_POLICY_USE_OPENMP) +TEST(quest_marching_cubes_equivalence, planar_3d_omp) { test_planar_3d(RuntimePolicy::omp); } +TEST(quest_marching_cubes_equivalence, round_3d_omp) { test_round_3d(RuntimePolicy::omp); } +TEST(quest_marching_cubes_equivalence, gyroid_3d_omp) { test_gyroid_3d(RuntimePolicy::omp); } +TEST(quest_marching_cubes_equivalence, round_2d_omp) { test_round_2d(RuntimePolicy::omp); } +#endif + +#if defined(AXOM_RUNTIME_POLICY_USE_CUDA) +TEST(quest_marching_cubes_equivalence, round_3d_cuda) { test_round_3d(RuntimePolicy::cuda); } +TEST(quest_marching_cubes_equivalence, gyroid_3d_cuda) { test_gyroid_3d(RuntimePolicy::cuda); } +#endif + +#if defined(AXOM_RUNTIME_POLICY_USE_HIP) +TEST(quest_marching_cubes_equivalence, round_3d_hip) { test_round_3d(RuntimePolicy::hip); } +TEST(quest_marching_cubes_equivalence, gyroid_3d_hip) { test_gyroid_3d(RuntimePolicy::hip); } +#endif + +int main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + ::testing::FLAGS_gtest_death_test_style = "threadsafe"; + axom::slic::SimpleLogger logger; + return RUN_ALL_TESTS(); +} diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index e8782b9992..17734bd014 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -234,6 +234,16 @@ if(NANOBIND_FOUND) COMMAND ${PROJECT_BINARY_DIR}/bin/run_python_with_axom.sh -c "import axom.sidre, conduit, numpy") endif() + #-------------------------------------------------------------------------- + # Python utilities for generating Blueprint mesh inputs. + #-------------------------------------------------------------------------- + foreach(_mesh_gen_script gen-multidom-structured-mesh.py) + axom_configure_file ("${CMAKE_CURRENT_SOURCE_DIR}/${_mesh_gen_script}" + "${PROJECT_BINARY_DIR}/bin/${_mesh_gen_script}" COPYONLY) + axom_configure_file ("${CMAKE_CURRENT_SOURCE_DIR}/${_mesh_gen_script}" + "${CMAKE_INSTALL_PREFIX}/bin/${_mesh_gen_script}" COPYONLY) + endforeach() + #-------------------------------------------------------------------------- # convert_sidre_protocol.py is a python version of the # convert_sidre_protocol.cpp utility diff --git a/src/tools/gen-multidom-structured-mesh.py b/src/tools/gen-multidom-structured-mesh.py index b41dc79f3a..0fe4c49419 100755 --- a/src/tools/gen-multidom-structured-mesh.py +++ b/src/tools/gen-multidom-structured-mesh.py @@ -1,203 +1,439 @@ #!/usr/bin/env python3 -# gen-multidom-structured-mesh.py -# Write a simple multidomain structured blueprint mesh for testing. - -# This script requires a conduit installation configured with python3 and hdf5. -# Make sure PYTHONPATH includes /path/to/conduit/install/python-modules, -# or use Axom's convenience script /path/to/axom_build_dir/bin/run_python_with_axom.sh -# that includes Conduit in PYTHONPATH. +# Copyright (c) Lawrence Livermore National Security, LLC and other +# Axom Project Contributors. See top-level LICENSE and COPYRIGHT +# files for dates and other details. +# +# SPDX-License-Identifier: (BSD-3-Clause) + +# Write a multidomain Blueprint mesh for testing. +# +# You need Conduit's Python module available on PYTHONPATH. +# Axom's build tree usually provides `bin/run_python_with_axom.sh` for that. +# +# The generated Blueprint hierarchy is: +# +# ├── Map entry, or list child with --useList +# │ ├── topologies +# │ │ └── mesh +# │ │ ├─• type == "structured" or "unstructured" +# │ │ ├─• coordset == "coords" +# │ │ └── elements +# │ │ ├── dims Structured only +# │ │ │ ├─• i +# │ │ │ ├─• j +# │ │ │ └─• [k] +# │ │ ├─• shape Unstructured only, quad or hex +# │ │ └─• connectivity Unstructured only, flat int64 connectivity +# │ ├── coordsets +# │ │ └── coords +# │ │ ├─• type == "explicit" +# │ │ └── values i-fastest node ordering, ghost padded for --strided +# │ │ ├─• x +# │ │ ├─• y +# │ │ └─• [z] +# │ └── fields +# │ ├── field Conduit's example field +# │ │ ├─• association == "element" +# │ │ ├─• topology == "mesh" +# │ │ └─• values +# │ └── Only with --field. Nodal for MarchingCubes +# │ ├─• association == "vertex" +# │ ├─• topology == "mesh" +# │ └─• values +# └── ... try: import conduit import conduit.blueprint import conduit.relay except ModuleNotFoundError as e: - print( - f'{e}\nMake sure your PYTHONPATH includes /path/to/conduit/install/python-modules\nConduit must be configured with python and hdf5.\nAlternatively, you can use the convenience script\n/path/to/axom_build_dir/bin/run_python_with_axom.sh\nthat includes Conduit in PYTHONPATH.' - ) + print(f'{e}\n' + 'Add Conduit to PYTHONPATH, for example:\n' + ' export PYTHONPATH=/path/to/conduit/install/python-modules:$PYTHONPATH\n' + 'If you have an Axom build directory, you can also run:\n' + ' /path/to/axom_build_dir/bin/run_python_with_axom.sh ...\n' + 'Note: HDF5 support is only needed when using `--protocol hdf5`.') exit(-1) import numpy as np - - -def i_c(s): - '''Convert comma-separated string to list of integers.''' - return list(map(int, s.split(','))) - - -def f_c(s): - '''Convert comma-separated string to list of floating point numbers.''' - return list(map(float, s.split(','))) - - from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter -ps = ArgumentParser(description='Write a blueprint strided-unstructured mesh.', - formatter_class=ArgumentDefaultsHelpFormatter) -ps.add_argument('--useList', action='store_true', help='Put domains in a list instead of a map') -ps.add_argument('-ml', type=f_c, default=(0., 0.), help='Mesh lower coordinates') -ps.add_argument('-mu', type=f_c, default=(1., 1.), help='Mesh upper coordinates') -ps.add_argument('-ms', type=i_c, default=(3, 3), help='Logical size of mesh (cells)') -ps.add_argument('-dc', type=i_c, default=(1, 1), help='Domain counts in each index direction') -ps.add_argument('-o', '--output', type=str, default='mdmesh', help='Output file base name') -ps.add_argument('--strided', action='store_true', help='Use strided_structured (has ghosts)') -ps.add_argument('-v', '--verbose', action='store_true', help='Print additional info') -opts, unkn = ps.parse_known_args() -if (opts.verbose): print(opts, unkn) -if (unkn): - print("Unrecognized arguments:", *unkn) - quit(1) - -dim = len(opts.dc) - -if dim not in (2,3) \ - or len(opts.ms) != dim \ - or len(opts.ml) != dim \ - or len(opts.mu) != dim: - raise RuntimeError('dc, ms, ml and mu options must have the same dimensions (2 or 3)') - -# Must have enough cells for requested partitioning. -goodDc = [opts.ms[i] >= opts.dc[i] for i in range(dim)] -if sum(goodDc) < dim: - raise RuntimeError(f'ms ({opts.ms}) must be >= dc ({opts.dc}) in all directions.') - -# Number of phony nodes on left and right sides, for strided option -if opts.strided: - npnl, npnr = 2, 1 -else: - npnl, npnr = 0, 0 - - -def scale_structured_domain(n, startCoord, endCoord): - '''This function scales and shifts a blueprint structured domain after - it has been created. There's no way to specify the physical extent - of a domain using conduit.blueprint.mesh.examples methods, as far as - I can tell. - ''' - #print(f'Rescaling to {startCoord} -> {endCoord}') - - ndim = n['coordsets/coords/values'].number_of_children() - - domLens = n['topologies/mesh/elements/dims'] + +def parse_component_list(values, cast): + '''Convert comma-separated and/or space-separated values to a list.''' + result = [] + for value in values: + for component in value.split(','): + component = component.strip() + if component: + result.append(cast(component)) + return result + + +def parse_args(): + ps = ArgumentParser(description='Write a multidomain Blueprint mesh.', + formatter_class=ArgumentDefaultsHelpFormatter) + ps.add_argument('--useList', action='store_true', help='Put domains in a list instead of a map') + ps.add_argument('-ml', + '--min', + dest='ml', + nargs='+', + default=('0.', '0.'), + help='Mesh lower coordinates, space- or comma-separated') + ps.add_argument('-mu', + '--max', + dest='mu', + nargs='+', + default=('1.', '1.'), + help='Mesh upper coordinates, space- or comma-separated') + ps.add_argument('-ms', + '--res', + dest='ms', + nargs='+', + default=('3', '3'), + help='Logical size of mesh (cells), space- or comma-separated') + ps.add_argument('-dc', + '--domains', + dest='dc', + nargs='+', + default=('1', '1'), + help='Domain counts in each index direction, space- or comma-separated') + ps.add_argument('-o', '--output', type=str, default='mdmesh', help='Output file base name') + ps.add_argument('--strided', action='store_true', help='Use strided_structured (has ghosts)') + ps.add_argument( + '--topology', + choices=('structured', 'unstructured'), + default='structured', + help='Topology type. "unstructured" emits single-shape quad or hex connectivity ' + 'over the same nodes. Incompatible with --strided.') + ps.add_argument( + '--field', + choices=('none', 'sphere', 'plane'), + default='none', + help='Add an analytic nodal field, which MarchingCubes needs. The Conduit example ' + 'field is element-associated.') + ps.add_argument('--fieldName', type=str, default='fcn', help='Name of the analytic nodal field') + ps.add_argument('--center', + nargs='+', + default=None, + help='Center for --field sphere, or point on plane for --field plane. ' + 'Defaults to the mesh center.') + ps.add_argument( + '--radius', + type=float, + default=None, + help='Radius for --field sphere. Defaults to one quarter of the shortest mesh extent.') + ps.add_argument('--normal', + nargs='+', + default=None, + help='Normal direction for --field plane. Defaults to +y in 2D and +z in 3D.') + ps.add_argument('--protocol', + choices=('hdf5', 'json', 'yaml'), + default='hdf5', + help='Conduit relay output protocol. json/yaml let readers run without HDF5.') + ps.add_argument('-v', '--verbose', action='store_true', help='Print additional info') + + opts, unkn = ps.parse_known_args() + opts.ml = parse_component_list(opts.ml, float) + opts.mu = parse_component_list(opts.mu, float) + opts.ms = parse_component_list(opts.ms, int) + opts.dc = parse_component_list(opts.dc, int) + + if opts.verbose: + print(opts, unkn) + if unkn: + print("Unrecognized arguments:", *unkn) + quit(1) + return opts + + +def validated_mesh_options(opts): + dim = len(opts.dc) + + if dim not in (2, 3) or len(opts.ms) != dim or len(opts.ml) != dim or len(opts.mu) != dim: + raise RuntimeError('dc, ms, ml and mu options must have the same dimensions (2 or 3)') + + if any(s <= 0 for s in opts.ms): + raise RuntimeError(f'ms ({opts.ms}) entries must be positive') + if any(d <= 0 for d in opts.dc): + raise RuntimeError(f'dc ({opts.dc}) entries must be positive') + + # Must have enough cells for requested partitioning. + if any(opts.ms[i] < opts.dc[i] for i in range(dim)): + raise RuntimeError(f'ms ({opts.ms}) must be >= dc ({opts.dc}) in all directions.') + + mesh_size = np.array(opts.ms, dtype=int) + mesh_lower = np.array(opts.ml, dtype=float) + mesh_upper = np.array(opts.mu, dtype=float) + mesh_extent = mesh_upper - mesh_lower + if np.any(mesh_extent <= 0.0): + raise RuntimeError(f'mu ({opts.mu}) must be greater than ml ({opts.ml}) in all directions') + + domain_counts = opts.dc if dim == 3 else (*opts.dc, 1) + domain_counts = np.array(domain_counts, dtype=int) + + domain_size = mesh_size // domain_counts[:dim] + domain_size_remainder = mesh_size % domain_counts[:dim] + + if opts.topology == 'unstructured' and opts.strided: + raise RuntimeError( + '--topology unstructured is incompatible with --strided. The ghost padded ' + 'coordset does not have compact node numbering to build connectivity over.') + + mesh_center = 0.5 * (mesh_lower + mesh_upper) + if opts.center is None: + center = mesh_center + else: + center = np.array(parse_component_list(opts.center, float), dtype=float) + if len(center) < dim: + raise RuntimeError(f'--center ({opts.center}) needs at least {dim} components') + + if opts.radius is None: + radius = 0.25 * float(np.min(mesh_extent)) + else: + radius = float(opts.radius) + if radius <= 0.0: + raise RuntimeError(f'--radius must be positive (got {radius})') + + if opts.normal is None: + normal = np.array((0.0, 1.0) if dim == 2 else (0.0, 0.0, 1.0), dtype=float) + else: + normal = np.array(parse_component_list(opts.normal, float), dtype=float) + if opts.field != 'none': + if opts.field == 'plane' and len(normal) < dim: + raise RuntimeError(f'--normal ({opts.normal}) needs at least {dim} components') + if opts.field == 'plane': + normal_norm = float(np.linalg.norm(normal[:dim])) + if normal_norm == 0.0: + raise RuntimeError('--normal must be nonzero for --field plane') + normal = normal / normal_norm + + return { + 'center': center, + 'normal': normal, + 'radius': radius, + 'dim': dim, + 'domain_counts': domain_counts, + 'mesh_size': mesh_size, + 'mesh_lower': mesh_lower, + 'mesh_upper': mesh_upper, + 'cell_physical_size': mesh_extent / mesh_size, + 'domain_size': domain_size, + 'domain_size_remainder': domain_size_remainder, + 'num_phony_nodes_left': 2 if opts.strided else 0, + 'num_phony_nodes_right': 1 if opts.strided else 0, + } + + +def domain_index_begin(context, di, dj, dk=None): + '''Compute first cell index of the domain with multi-dimensional index (di, dj, dk).''' + dim = context['dim'] + ds = (di, dj) if dim == 2 else (di, dj, dk) + idx = np.array(ds) + std = context['domain_size'] * ds + extra = np.where(idx < context['domain_size_remainder'][:dim], idx, + context['domain_size_remainder'][:dim]) + return std + extra + + +def domain_node(md_mesh, opts, di, dj, dk): + if opts.useList: + return md_mesh.append() + + dom_name = f'domain_{di:1d}_{dj:1d}' + if len(opts.dc) == 3: + dom_name += f'_{dk:1d}' + return md_mesh[dom_name] + + +def generate_topology(dom, opts, context, point_counts, cell_start, cell_end): + '''Generate a structured Blueprint topology and seed matching example data.''' + dim = context['dim'] + npnl = context['num_phony_nodes_left'] + npnr = context['num_phony_nodes_right'] + + point_counts_3 = point_counts if len(point_counts) == 3 else (*point_counts, 0) + if opts.strided: + elem_extents = (cell_end - cell_start) + (npnl + npnr + 1) + vert_extents = np.array(point_counts) + (npnl + npnr) + elem_offset = np.full(dim, npnl) + vert_offset = np.full(dim, npnl) + + desc = conduit.Node() + desc['vertex_data/shape'].set(vert_extents) + desc['vertex_data/origin'].set(vert_offset) + desc['element_data/shape'].set(elem_extents) + desc['element_data/origin'].set(elem_offset) + conduit.blueprint.mesh.examples.strided_structured(desc, *point_counts_3, dom) + if dom.has_child("state"): + dom.remove_child("state") + else: + conduit.blueprint.mesh.examples.basic('structured', *point_counts_3, dom) + + +def generate_coordset(dom, context, start_coord, end_coord): + '''Scale and shift the generated explicit coordset to the requested domain bounds.''' + npnl = context['num_phony_nodes_left'] + npnr = context['num_phony_nodes_right'] + + ndim = dom['coordsets/coords/values'].number_of_children() + + dom_lens_node = dom['topologies/mesh/elements/dims'] dirs = 'ij' if ndim == 2 else 'ijk' - domLens = [domLens[d] for d in dirs] - domLens = np.array(domLens) - domPhysicalSize = np.array(endCoord) - np.array(startCoord) - #print(f'domLens={domLens} domPhysicalSize={domPhysicalSize}') - assert (n['topologies/mesh/type'] == 'structured') - assert (len(startCoord) >= ndim) - assert (len(domPhysicalSize) >= ndim) + dom_lens = np.array([dom_lens_node[d] for d in dirs]) + domain_physical_size = np.array(end_coord) - np.array(start_coord) + + assert (dom['topologies/mesh/type'] == 'structured') + assert (len(start_coord) >= ndim) + assert (len(domain_physical_size) >= ndim) - coordArrayLens = domLens + 1 + npnl + npnr - #print(f'coordArrayLens={coordArrayLens}') + coord_array_lens = dom_lens + 1 + npnl + npnr xyz = 'xyz' for d in range(ndim): - coords = n['coordsets/coords/values'][d] - coords = np.reshape(coords, np.flip(coordArrayLens)) + coords = dom['coordsets/coords/values'][d] + coords = np.reshape(coords, np.flip(coord_array_lens)) - # realCoords excludes the ghost layers. + # real_coords excludes the ghost layers. if ndim == 2: - if npnr == 0: - realCoords = coords[npnl:, npnl:] - else: - realCoords = coords[npnl:-npnr, npnl:-npnr] + real_coords = coords[npnl:, npnl:] if npnr == 0 else coords[npnl:-npnr, npnl:-npnr] else: - if npnr == 0: - realCoords = coords[npnl:, npnl:, npnl:] - else: - realCoords = coords[npnl:-npnr, npnl:-npnr, npnl:-npnr] + real_coords = coords[npnl:, npnl:, + npnl:] if npnr == 0 else coords[npnl:-npnr, npnl:-npnr, npnl:-npnr] + + min_coord, max_coord = np.amin(real_coords), np.amax(real_coords) + cur_range = max_coord - min_coord + coords = (coords - min_coord) * domain_physical_size[d] / cur_range + start_coord[d] + dom['coordsets/coords/values'][xyz[d]] = coords + + +def add_analytic_nodal_field(dom, opts, context): + '''Add a nodal scalar field sampled at every coordset node. The implementation is vectorized''' + + if opts.field == 'none': + return + + dim = context['dim'] + vals = dom['coordsets/coords/values'] + comps = [np.asarray(vals[c], dtype=np.float64) for c in 'xyz'[:dim]] + pts = np.stack(comps, axis=1) + + center = np.array(context['center'][:dim], dtype=np.float64) + if opts.field == 'sphere': + values = np.linalg.norm(pts - center, axis=1) - context['radius'] + else: + normal = np.array(context['normal'][:dim], dtype=np.float64) + values = (pts - center) @ normal + + field = dom[f'fields/{opts.fieldName}'] + field['topology'] = 'mesh' + field['association'] = 'vertex' + field['values'] = values + + +def structured_to_unstructured(dom, context): + '''Rewrite the structured topology as single-shape quad/hex connectivity. + + This uses numpy broadcasting instead of a per-cell Python loop. Node order + within a cell matches Blueprint's quad/hex convention. Node ids follow the + coordset's i-fastest numbering, so the coordset stays as-is. + ''' + dim = context['dim'] + topo = dom['topologies/mesh'] + dims = topo['elements/dims'] + cells = np.array([dims['i'], dims['j']] + ([dims['k']] if dim == 3 else []), dtype=np.int64) + pts = cells + 1 + + if dim == 2: + j, i = np.meshgrid(np.arange(cells[1]), np.arange(cells[0]), indexing='ij') + base = (i + j * pts[0]).ravel() + offs = [0, 1, 1 + pts[0], pts[0]] + else: + k, j, i = np.meshgrid(np.arange(cells[2]), + np.arange(cells[1]), + np.arange(cells[0]), + indexing='ij') + base = (i + j * pts[0] + k * pts[0] * pts[1]).ravel() + pij = pts[0] * pts[1] + offs = [0, 1, 1 + pts[0], pts[0], pij, pij + 1, pij + 1 + pts[0], pij + pts[0]] + + conn = (base[:, None] + np.array(offs, dtype=np.int64)[None, :]).ravel() + + topo.remove_child('elements') + topo['type'] = 'unstructured' + topo['elements/shape'] = 'quad' if dim == 2 else 'hex' + topo['elements/connectivity'] = conn + + +def generate_fields(dom, opts, context): + '''Keep Conduit's example element field and ensure it references the generated topology.''' + del opts, context + if dom.has_path('fields/field'): + dom['fields/field/topology'] = 'mesh' + dom['fields/field/association'] = 'element' + + +def generate_domain(md_mesh, opts, context, di, dj, dk): + dim = context['dim'] + mesh_lower = context['mesh_lower'] + cell_physical_size = context['cell_physical_size'] + + dom = domain_node(md_mesh, opts, di, dj, dk) + + cell_start = domain_index_begin(context, di, dj, dk) + cell_end = domain_index_begin(context, di + 1, dj + 1, dk + 1 if dim == 3 else 0) + point_counts = cell_end - cell_start + 1 + + generate_topology(dom, opts, context, point_counts, cell_start, cell_end) + + dom_lower = mesh_lower[:dim] + cell_start * cell_physical_size[:dim] + dom_upper = mesh_lower[:dim] + cell_end * cell_physical_size[:dim] + generate_coordset(dom, context, dom_lower, dom_upper) + generate_fields(dom, opts, context) + # Sample the field before rewriting the topology. The unstructured rewrite drops + # elements/dims, and we still need those to size the coordset. + add_analytic_nodal_field(dom, opts, context) + if opts.topology == 'unstructured': + structured_to_unstructured(dom, context) + + +def generate_mesh(opts): + context = validated_mesh_options(opts) + domain_counts = context['domain_counts'] + + if opts.verbose: + print(f"meshSize={context['mesh_size']} cells, domCounts={domain_counts[0:context['dim']]}" + f" domSize={context['domain_size']}" + f" domSizeRem={context['domain_size_remainder']}") - minC, maxC = np.amin(realCoords), np.amax(realCoords) - curRange = maxC - minC - shift = startCoord[d] - minC - scale = domPhysicalSize[d] / curRange - coords = (coords - minC) * domPhysicalSize[d] / curRange + startCoord[d] - n['coordsets/coords/values'][xyz[d]] = coords - - -domType = 'structured' - -domCounts = opts.dc if dim == 3 else (*opts.dc, 1) # domCounts must be length 3, even for 2D. -meshSize = opts.ms -meshLower = opts.ml -meshUpper = opts.mu - -# Convert to np.array to use element-wise arithmetic. -domCounts = np.array(domCounts, dtype=int) -meshSize = np.array(meshSize, dtype=int) -meshLower = np.array(meshLower) -meshUpper = np.array(meshUpper) - -domPhysicalSize = (meshUpper - meshLower) / domCounts[:dim] -cellPhysicalSize = (meshUpper - meshLower) / meshSize - -domSize = meshSize // domCounts[:dim] -domSizeRem = meshSize % domCounts[:dim] -if opts.verbose: - print(f'meshSize={meshSize} cells, domCounts={domCounts[0:dim]}' - f' domSize={domSize} domSizeRem={domSizeRem}') - - -def domain_index_begin(di, dj, dk=None): - '''Compute first cell index of the domain with multi-dimensional index (di, dj, dk).''' - ds = (di, dj) if dim == 2 else (di, dj, dk) - idx = np.array(ds) - std = domSize * ds - extra = np.where(idx < domSizeRem[:dim], idx, domSizeRem[:dim]) - begin = std + extra - return begin - - -mdMesh = conduit.Node() -for dk in range(domCounts[2]): - for dj in range(domCounts[1]): - for di in range(domCounts[0]): - if opts.useList: - dom = mdMesh.append() - else: - domName = f'domain_{di:1d}_{dj:1d}' - if len(opts.dc) == 3: domName += f'_{dk:1d}' - dom = mdMesh[domName] - - cellStart = domain_index_begin(di, dj, dk) - cellEnd = domain_index_begin(di + 1, dj + 1, dk + 1 if dim == 3 else 0) - pointCounts = cellEnd - cellStart + 1 - #print(f'cellStart={cellStart} cellEnd={cellEnd} pointCounts={pointCounts}') - - elemExtents = (cellEnd - cellStart) + (npnl + npnr + 1) - vertExtents = np.array(pointCounts) + (npnl + npnr) - elemOffset = np.full(dim, npnl) - vertOffset = np.full(dim, npnl) - #print(f'\n{domName}: {cellStart} -> {cellEnd}') - - pointCounts3 = pointCounts if len(pointCounts) == 3 else (*pointCounts, 0) - if opts.strided: - desc = conduit.Node() - desc['vertex_data/shape'].set(vertExtents) - desc['vertex_data/origin'].set(vertOffset) - desc['element_data/shape'].set(elemExtents) - desc['element_data/origin'].set(elemOffset) - #print(f'\ndesc({di},{dj},{dk}):', end=''); print(desc) - conduit.blueprint.mesh.examples.strided_structured(desc, *pointCounts3, dom) - if dom.has_child("state"): dom.remove_child("state") - else: - conduit.blueprint.mesh.examples.basic(domType, *pointCounts3, dom) - - domLower = meshLower[:dim] + cellStart * cellPhysicalSize[:dim] - domUpper = meshLower[:dim] + cellEnd * cellPhysicalSize[:dim] - scale_structured_domain(dom, domLower, domUpper) - # if opts.verbose: print(f'Domain [{di},{dj},{dk}]: {dom}') - -if opts.verbose: - print('mdMesh:') - print(mdMesh) - -info = conduit.Node() -if not conduit.blueprint.mesh.verify(mdMesh, info): - print("Mesh failed blueprint verification. Info:") - print(info) - -conduit.relay.io.blueprint.save_mesh(mdMesh, opts.output, "hdf5") -print(f'Wrote mesh {opts.output}') + md_mesh = conduit.Node() + for dk in range(domain_counts[2]): + for dj in range(domain_counts[1]): + for di in range(domain_counts[0]): + generate_domain(md_mesh, opts, context, di, dj, dk) + + return md_mesh + + +def main(): + opts = parse_args() + md_mesh = generate_mesh(opts) + + if opts.verbose: + print('mdMesh:') + print(md_mesh) + + info = conduit.Node() + if not conduit.blueprint.mesh.verify(md_mesh, info): + print("Mesh failed blueprint verification. Info:") + print(info) + return 2 + + conduit.relay.io.blueprint.save_mesh(md_mesh, opts.output, opts.protocol) + print(f'Wrote mesh {opts.output}') + return 0 + + +if __name__ == '__main__': + exit(main())