From 364da479410fb113e23361130927256c7f9a2fed Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Fri, 10 Jul 2026 11:42:28 -0700 Subject: [PATCH 01/36] quest: Add bump-backed MarchingCubes implementation and labels the old implementation as legacy The original implementation only supported structured input meshes. This implementation leverages bump's support for both structured and unstructured inputs. --- src/axom/quest/CMakeLists.txt | 26 +- src/axom/quest/MarchingCubes.cpp | 63 +- src/axom/quest/MarchingCubes.hpp | 159 +++- .../quest/detail/MarchingCubesBumpAdaptor.hpp | 324 ++++++++ .../quest/detail/MarchingCubesBumpImpl.hpp | 708 ++++++++++++++++++ .../detail/MarchingCubesSingleDomain.cpp | 202 +++-- .../detail/MarchingCubesSingleDomain.hpp | 156 ++-- 7 files changed, 1483 insertions(+), 155 deletions(-) create mode 100644 src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp create mode 100644 src/axom/quest/detail/MarchingCubesBumpImpl.hpp diff --git a/src/axom/quest/CMakeLists.txt b/src/axom/quest/CMakeLists.txt index 3abfd86a5e..a84de7ba4a 100644 --- a/src/axom/quest/CMakeLists.txt +++ b/src/axom/quest/CMakeLists.txt @@ -136,17 +136,21 @@ 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 - ) +if(AXOM_ENABLE_BUMP) + blt_list_append( + TO quest_headers + ELEMENTS MarchingCubes.hpp + detail/MarchingCubesSingleDomain.hpp + detail/MarchingCubesImpl.hpp + detail/MarchingCubesBumpImpl.hpp + IF CONDUIT_FOUND) + + blt_list_append( + TO quest_sources + ELEMENTS MarchingCubes.cpp + detail/MarchingCubesSingleDomain.cpp + IF CONDUIT_FOUND) +endif() blt_list_append( TO quest_depends_on ELEMENTS conduit::conduit IF CONDUIT_FOUND ) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index 4a0d4f855e..becdf96583 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}; @@ -113,6 +111,8 @@ void MarchingCubes::computeIsocontour(double contourVal) auto& single = *m_singles[d]; single.setContourValue(contourVal); single.setMaskValue(m_maskVal); + single.setParentCellIdMode(m_parentCellIdMode); + single.setRobustnessPolicy(m_robustnessPolicy); single.markCrossings(); single.scanCrossings(); m_facetIndexOffsets[d] = m_facetCount; @@ -234,6 +234,60 @@ 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"); @@ -248,5 +302,4 @@ void MarchingCubes::allocateOutputBuffers() } } -} // 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..aae51fac08 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -28,26 +28,25 @@ // 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. + * + * This setting controls only the legacy structured-mesh backend. + * When MarchingCubes is configured to use the bump::extraction::CutField backend, + * bump manages its own internal parallelism and this value is accepted only for API compatibility. */ enum class MarchingCubesDataParallelism { @@ -56,6 +55,58 @@ enum class MarchingCubesDataParallelism fullParallel = 2 }; +/*! + * @brief Enum controlling the meaning of the parent-cell ids reported for generated contour facets + * (see MarchingCubes::getContourFacetParents and MarchingCubes::populateContourMesh). + * + * The legacy marching cubes implementation numbered parent cells by their flat + * index in the same row- or column-major ordering as the input scalar function array + * (i.e. following the function field's stride order). + * The bump-backed implementation natively numbers cells by their Blueprint zone index, + * which uses a fixed i-fastest ordering independent of how the field is stored in memory. + * For structured input these two numberings coincide only when the field is stored i-fastest; + *otherwise they differ by a stride-order permutation. + * + * This enum lets callers choose which numbering they receive: + * - \c blueprintZoneId (default): report the Blueprint zone index. This is the natural, + * mesh-type-agnostic identifier and the only meaningful choice for unstructured input. + * - \c legacyFieldOrder: reproduce the legacy numbering (flat index in the function field's stride order). + * Provided so existing structured-mesh callers that depend on the historical meaning are unaffected. + * This option only applies to structured input; for unstructured input the Blueprint zone id is always used. + */ +enum class MarchingCubesParentCellIdMode +{ + blueprintZoneId = 0, + legacyFieldOrder = 1 +}; + +/*! + * @brief Enum selecting the isosurface case-table / intersector robustness used by the bump backend + * + * The bump CutField 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 (its \c FieldType is \c float), + * 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. + * + * 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. @@ -118,7 +169,9 @@ class MarchingCubes * 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, @@ -158,6 +211,52 @@ class MarchingCubes */ void setMaskValue(int maskVal) { m_maskVal = maskVal; } + /*! + * @brief Set how parent-cell ids are numbered for generated contour facets. + * @param [in] mode A value from MarchingCubesParentCellIdMode. + * + * The default is MarchingCubesParentCellIdMode::blueprintZoneId. + * See that enum for the meaning of each mode and for why the two modes can differ + * for structured input. Has no effect unless parent-cell ids are requested + * (via getContourFacetParents() or the cellIdField of populateContourMesh()). + * + * @note The legacyFieldOrder mode only affects structured input; + * unstructured input always reports the Blueprint zone id. + */ + void setParentCellIdMode(MarchingCubesParentCellIdMode mode) { m_parentCellIdMode = mode; } + + /*! + * @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 otherwise has no effect. + * 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) { m_useBumpBackend = 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 @@ -198,6 +297,21 @@ class MarchingCubes 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. + * + * 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}. + * The existing fixed-stride array accessors still expose the legacy un-welded triangle/segment soup. + * + * 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) const; + /*! * @brief Return view of facet corner node indices (connectivity) Array. * @@ -274,6 +388,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. @@ -295,7 +420,7 @@ class MarchingCubes RuntimePolicy m_runtimePolicy; int m_allocatorID = axom::INVALID_ALLOCATOR_ID; - //! @brief Choice of full or partial data-parallelism, or byPolicy. + //! @brief Legacy backend data-parallel scan strategy, or byPolicy. MarchingCubesDataParallelism m_dataParallelism = MarchingCubesDataParallelism::byPolicy; //! @brief Number of domains. @@ -315,6 +440,15 @@ class MarchingCubes int m_maskVal = 1; + //! @brief How to number parent-cell ids of generated facets. + MarchingCubesParentCellIdMode m_parentCellIdMode = MarchingCubesParentCellIdMode::blueprintZoneId; + + //! @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; @@ -359,7 +493,6 @@ class MarchingCubes void allocateOutputBuffers(); }; -} // namespace quest -} // namespace axom +} // namespace axom::quest #endif // AXOM_USE_CONDUIT diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp new file mode 100644 index 0000000000..e7970aa63c --- /dev/null +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -0,0 +1,324 @@ +// 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" + * topologies//elements/connectivity (flat, ConnectivityType) + * topologies//elements/sizes (per-zone corner count) + * topologies//elements/offsets (per-zone start into connectivity) + * topologies//elements/shapes (per-zone Blueprint ShapeID) + * coordsets//values/{x,y[,z]} (explicit, blended/welded points) + * fields/originalElements/values (element-assoc, input zone per fragment) + * + * The legacy MarchingCubes output is an unwelded fixed-stride representation: + * m_facetNodeCoords : (facetCount*DIM, DIM) one row per facet-corner + * m_facetNodeIds : (facetCount, DIM) DIM corner ids per facet, where + * the ids index into m_facetNodeCoords and are offset by + * m_facetIndexOffset*DIM (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. Re-expand: every output facet writes DIM fresh rows into m_facetNodeCoords + * (so the legacy facetCount*DIM node-count invariant holds) + * and its m_facetNodeIds row is the consecutive ids of those rows. + * 3. Parent id per facet := originalElements[srcZone], optionally remapped to + * the legacy field-stride flat order (structured input + legacyFieldOrder). + * + * This file performs no triangle *welding* of its own; it intentionally expands + * back to the legacy soup so existing users are byte-compatible. Callers who + * want bump's richer welded mesh use the additive Blueprint accessors. + */ + +#ifndef AXOM_QUEST_MARCHINGCUBESBUMPADAPTOR_H_ +#define AXOM_QUEST_MARCHINGCUBESBUMPADAPTOR_H_ + +#include "axom/config.hpp" + +#if defined(AXOM_USE_CONDUIT) && defined(AXOM_USE_BUMP) + + #include "axom/core/execution/execution_space.hpp" + #include "axom/core/execution/for_all.hpp" + #include "axom/core/memory_management.hpp" + #include "axom/core/Array.hpp" + #include "axom/core/ArrayView.hpp" + #include "axom/core/MDMapping.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 + +namespace axom::quest::detail::marching_cubes +{ +/*! + * @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(DIM == 3) + { + return nCorners >= 3 ? (nCorners - 2) : 0; + } + // DIM == 2: a line segment. + return nCorners >= 2 ? 1 : 0; +} + +/*! + * @brief Convert one bump CutField output (single domain) into the legacy + * fixed-stride 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 (totalFacetCount*DIM, 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]). Node-coord rows and node ids + * are written starting at facetIndexOffset (ids offset by *DIM). + * @param thisDomainFacetCount Number of facets this domain produces (already + * computed by the caller; equals sum of facetsPerZone over the bump zones). + * @param fieldStrideRemap If non-null, a precomputed per-input-zone map from + * bump's i-fastest Blueprint zone id to the legacy field-stride flat id. When + * null, originalElements ids are written through unchanged. + * + * @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 thisDomainFacetCount, + axom::ArrayView fieldStrideRemap) +{ + 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); + 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)); + const conduit::Node& n_elems = n_topo.fetch_existing("elements"); + + // bump always emits explicit sizes/offsets/connectivity for cut output. + const conduit::Node& n_sizes = n_elems.fetch_existing("sizes"); + const conduit::Node& n_offsets = n_elems.fetch_existing("offsets"); + const conduit::Node& n_conn = n_elems.fetch_existing("connectivity"); + + // originalElements: element-associated, one entry per output zone (fragment). + const conduit::Node& n_orig = n_output.fetch_existing("fields/originalElements/values"); + + 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) { + 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(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()); + + // --- 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. + const int allocatorID = axom::execution_space::allocatorID(); + 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); + + // Capture raw views for the kernel. + const bool doRemap = !fieldStrideRemap.empty(); + + // --- The fan-triangulation + re-expansion kernel ----------------------- + // One thread per bump zone. Each zone writes facetsPerZone facets; for + // each facet we emit DIM corner coords (re-expanded / un-welded) and DIM + // 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. + axom::IndexType parentId = static_cast(origView[z]); + if(doRemap) + { + parentId = fieldStrideRemap[parentId]; + } + + // 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; + } + + // Re-expanded node rows for this facet are contiguous: each facet + // owns exactly DIM rows in m_facetNodeCoords at facetIdx*DIM .. +DIM. + const axom::IndexType nodeRowBase = facetIdx * DIM; + + for(int c = 0; c < DIM; ++c) + { + const axom::IndexType weldedNode = + static_cast(connView[connStart + local[c]]); + const axom::IndexType outRow = nodeRowBase + c; + + facetNodeCoords(outRow, 0) = xView[weldedNode]; + facetNodeCoords(outRow, 1) = yView[weldedNode]; + if(DIM == 3) + { + facetNodeCoords(outRow, 2) = zView[weldedNode]; + } + + // Legacy ids index into m_facetNodeCoords directly. + facetNodeIds(facetIdx, c) = outRow; + } + + facetParentIds[facetIdx] = parentId; + } + }); + }; + + #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 +} + +/*! + * @brief Build the per-input-zone remap from bump's i-fastest Blueprint zone id + * to the legacy field-stride flat id, for structured input. + * + * bump's StructuredIndexing numbers zones flat = i + j*nx + k*nx*ny (i-fastest), independent of memory layout. + * The legacy parent-cell id is the flat index in the function field's stride order. + * This routine, given the per-dimension cell counts \a cellDims (logical, in i,j,k order) and the function field's + * \a fieldSlowestDirs (the slowest-to-fastest permutation from the field's MDMapping), produces remap[bumpZoneId] = legacyZoneId. + * + * Returns an empty Array when the two orderings coincide (i-fastest field), so callers can skip remapping entirely. + * + * @note Built in host memory then copied to ExecSpace memory by the caller. + */ +template +axom::Array buildFieldStrideRemap( + const axom::StackArray& cellDims, + const axom::StackArray& fieldSlowestDirs) +{ + // Identity stride order is "i fastest" == slowestDirs {DIM-1, ..., 1, 0}. + bool isIFastest = true; + for(int d = 0; d < DIM; ++d) + { + if(fieldSlowestDirs[d] != static_cast(DIM - 1 - d)) + { + isIFastest = false; + break; + } + } + if(isIFastest) + { + return axom::Array(0, 0); // no remap needed + } + + axom::IndexType numZones = 1; + for(int d = 0; d < DIM; ++d) + { + numZones *= cellDims[d]; + } + + // bump mapping: i-fastest. + axom::MDMapping bumpMap(cellDims, axom::ArrayStrideOrder::COLUMN); + // legacy mapping: field stride order via slowestDirs. + axom::MDMapping legacyMap; + legacyMap.initializeShape(cellDims, fieldSlowestDirs); + + axom::Array remap(numZones, numZones); + auto remapView = remap.view(); + // Host loop: enumerate logical multi-indices, map each to both flat ids. + for(axom::IndexType bumpId = 0; bumpId < numZones; ++bumpId) + { + const auto multi = bumpMap.toMultiIndex(bumpId); + remapView[bumpId] = legacyMap.toFlatIndex(multi); + } + return remap; +} + +} // namespace axom::quest::detail::marching_cubes + +#endif // AXOM_USE_CONDUIT && AXOM_USE_BUMP +#endif // AXOM_QUEST_MARCHINGCUBESBUMPADAPTOR_H_ diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp new file mode 100644 index 0000000000..fd8e5ed717 --- /dev/null +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -0,0 +1,708 @@ +// 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. + * To preserve the legacy fixed-stride (facetCount, DIM) "triangle soup" output contract, + * the adaptor fan-triangulates polygons and re-expands welded points into per-facet corners + * when filling the legacy output buffers. (Richer, welded output is exposed via additive accessors on MarchingCubes.) + */ + +#ifndef AXOM_QUEST_MARCHINGCUBESBUMPIMPL_H_ +#define AXOM_QUEST_MARCHINGCUBESBUMPIMPL_H_ + +#include "axom/config.hpp" + +#if defined(AXOM_USE_CONDUIT) && defined(AXOM_USE_BUMP) + + #include "axom/core/execution/execution_space.hpp" + #include "axom/core/execution/for_all.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/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 + +namespace axom::quest::detail::marching_cubes +{ +/*! + * @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(); + m_isStructured = (topoType != "unstructured"); + 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)); + } + } + + void setFunctionField(const std::string& fcnFieldName) override { m_fcnFieldName = fcnFieldName; } + + void setContourValue(double contourVal) override { m_contourVal = contourVal; } + + void setMaskValue(int maskVal) override { m_maskVal = maskVal; } + + /*! + * @brief Honor the requested parent-cell-id numbering. + * + * blueprintZoneId (default): use bump's originalElements directly. + * legacyFieldOrder: for structured input, remap the Blueprint zone index (which bump orders i-fastest, + * independent of memory layout) to the legacy flat index in the function field's stride order. + * For unstructured input this mode is ignored (no canonical "field stride order" exists) + * and the Blueprint zone id is used. + */ + void setParentCellIdMode(MarchingCubesParentCellIdMode mode) override + { + m_parentCellIdMode = mode; + } + + /*! + * @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 { runExtraction(); } + + //! @brief Copy cached bump output into the parent-allocated output buffers. + void computeFacets() override { fillLegacyOutputBuffers(); } + + axom::IndexType getContourCellCount() const override { return m_facetCount; } + + bool hasContourMeshBlueprint() const override { return m_output != nullptr; } + + void copyContourMeshBlueprint(conduit::Node& bpMesh) const override + { + SLIC_ERROR_IF(m_output == nullptr, + "MarchingCubes bump backend has no Blueprint contour output. " + "Call computeIsocontour() before requesting it."); + axom::bump::utilities::copy(bpMesh, *m_output, m_allocatorID); + } + + void relinquishContourMeshBlueprint(conduit::Node& bpMesh) override + { + SLIC_ERROR_IF(m_output == nullptr, + "MarchingCubes bump backend has no Blueprint contour output. " + "Call computeIsocontour() before requesting it."); + bpMesh.reset(); + bpMesh.swap(*m_output); + m_output.reset(); + m_facetCount = 0; + } + + void clearDomain() override + { + m_output.reset(); + m_facetCount = 0; + } + +private: + /*! @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_isStructured) + { + 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, + AXOM_LAMBDA(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 + { + 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, + AXOM_LAMBDA(axom::IndexType zoneIndex) { return maskView[zoneIndex] == maskVal; }, + n_options, + selectedZones); + } + } + + /*! + * @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"] = "originalElements"; + + 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. + 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); + axom::Array selectedZones; + addMaskSelectedZonesOption(topologyView, n_options, selectedZones); + conduit::Node execOptions; + axom::bump::utilities::copy(execOptions, n_options, m_allocatorID); + iso.execute(*m_dom, execOptions, n_out); + }); + }); + + // 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. + m_facetCount = computeTriangulatedFacetCount(n_out); + + // For the opt-in legacyFieldOrder numbering on structured input, capture the logical cell dims + // and the function field's stride order so the output adaptor can remap bump's i-fastest zone ids back to the legacy ordering. + if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_isStructured) + { + captureStructuredMetadata(); + } + } + + /*! + * @brief Populate m_cellDims and m_fieldSlowestDirs from the structured domain, + * for the legacyFieldOrder parent-id remap. + * + * Uses MeshViewUtil to read the logical cell shape and the function field's strides, + * from which an MDMapping yields the slowest->fastest permutation. + * + * NOTE: This path is exercised only when a caller explicitly opts into legacyFieldOrder on structured input; + * it is the part of the bump backend most in need of build/test validation (MeshViewUtil templating x ExecSpace). + */ + void captureStructuredMetadata() + { + axom::quest::MeshViewUtil mvu(*m_dom, m_topologyName); + m_cellDims = mvu.getCellShape(); + const auto fcnView = mvu.template getConstFieldView(m_fcnFieldName, false); + // Build an MDMapping from the field strides to extract the stride order. + axom::MDMapping fcnMap(fcnView.strides()); + m_fieldSlowestDirs = fcnMap.slowestDirs(); + } + + /*! + * @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"); + const auto sizes = n_sizes.as_index_t_accessor(); + const conduit::index_t n = sizes.number_of_elements(); + axom::IndexType facets = 0; + for(conduit::index_t i = 0; i < n; ++i) + { + const auto p = static_cast(sizes[i]); + facets += (DIM == 3) ? (p >= 3 ? p - 2 : 0) : 1; + } + return facets; + } + + // Fixed-shape output (e.g. all-tri or all-segment): + // infer count from connectivity length / corners-per-element. + const conduit::Node& n_conn = n_elems.fetch_existing("connectivity"); + const auto cornersPerElem = (DIM == 3) ? 3 : 2; // tri or segment + return static_cast(n_conn.dtype().number_of_elements() / cornersPerElem); + } + + /*! + * @brief Fill the parent-allocated legacy output buffers from cached bump output, + * fan-triangulating polygons and re-expanding welded points so the legacy (facetCount, DIM) un-welded contract is preserved exactly. + */ + void fillLegacyOutputBuffers() + { + SLIC_ASSERT(m_output != nullptr); + + // Build the legacy field-stride remap only when the user asked for the legacy numbering AND the input is structured + // (unstructured has no canonical field stride order; we leave the remap empty -> pass-through). + axom::Array remapHost(0, 0); + if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_isStructured) + { + remapHost = buildFieldStrideRemap(m_cellDims, m_fieldSlowestDirs); + } + + // Move the (possibly empty) remap into ExecSpace memory for the kernel. + axom::Array remapDevice; + axom::ArrayView remapView; + if(!remapHost.empty()) + { + remapDevice = axom::Array(remapHost, m_allocatorID); + remapView = remapDevice.view(); + } + + adaptCutFieldOutput(*m_output, + m_facetNodeIds, + m_facetNodeCoords, + m_facetParentIds, + m_facetIndexOffset, + m_facetCount, + remapView); + } + + //! @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; + + //! @brief How to number parent-cell ids of generated facets. + MarchingCubesParentCellIdMode m_parentCellIdMode = MarchingCubesParentCellIdMode::blueprintZoneId; + MarchingCubesRobustnessPolicy m_robustnessPolicy = MarchingCubesRobustnessPolicy::standard; + + //! @name Structured metadata, captured only for the legacyFieldOrder remap. + //! @{ + bool m_isStructured = false; + axom::StackArray m_cellDims {}; + axom::StackArray m_fieldSlowestDirs {}; + //! @} + + //! @brief Cached bump CutField output (Blueprint mesh). + std::unique_ptr m_output; + + //! @brief Legacy facet count (post fan-triangulation). + axom::IndexType m_facetCount = 0; +}; + +} // namespace axom::quest::detail::marching_cubes + +#endif // AXOM_USE_CONDUIT && AXOM_USE_BUMP +#endif // AXOM_QUEST_MARCHINGCUBESBUMPIMPL_H_ diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.cpp b/src/axom/quest/detail/MarchingCubesSingleDomain.cpp index b550a84f73..0bf89a6cad 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.cpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.cpp @@ -15,15 +15,14 @@ #include "axom/core/execution/execution_space.hpp" #include "axom/quest/detail/MarchingCubesSingleDomain.hpp" #include "axom/quest/detail/MarchingCubesImpl.hpp" +#if defined(AXOM_USE_CONDUIT) && 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 +49,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 +80,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 +101,133 @@ 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."); + } + } +#else + AXOM_UNUSED_VAR(useBumpBackend); +#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 +249,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..2a9b7785d3 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp @@ -29,57 +29,46 @@ // 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() { } /*! - @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 +76,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,15 +107,33 @@ class MarchingCubesSingleDomain } } + void setParentCellIdMode(MarchingCubesParentCellIdMode mode) + { + m_parentCellIdMode = mode; + if(m_impl) + { + m_impl->setParentCellIdMode(m_parentCellIdMode); + } + } + + 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. @@ -137,15 +143,14 @@ class MarchingCubesSingleDomain axom::IndexType getContourNodeCount() const { return m_ndim * getContourCellCount(); } /*! - @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 +167,23 @@ class MarchingCubesSingleDomain virtual void setContourValue(double contourVal) = 0; virtual void setMaskValue(int maskVal) = 0; + /*! + * @brief Set how parent-cell ids of generated facets are numbered. + * + * Default is a no-op so backends that only ever produce the legacy numbering + * (the structured-only MarchingCubesImpl) need not implement it. + * The bump backend overrides this to honor both numbering modes. + */ + virtual void setParentCellIdMode(MarchingCubesParentCellIdMode) { } + + /*! + * @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,6 +207,25 @@ class MarchingCubesSingleDomain //! @brief Return number of contour mesh facets generated. virtual axom::IndexType getContourCellCount() 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) const { 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, @@ -213,6 +254,7 @@ class MarchingCubesSingleDomain }; ImplBase& getImpl() { return *m_impl; } + const ImplBase& getImpl() const { return *m_impl; } private: //! @brief Multi-domain implementation this object is under. @@ -241,6 +283,8 @@ class MarchingCubesSingleDomain double m_contourVal = 0.0; int m_maskVal = 1; + MarchingCubesParentCellIdMode m_parentCellIdMode = MarchingCubesParentCellIdMode::blueprintZoneId; + MarchingCubesRobustnessPolicy m_robustnessPolicy = MarchingCubesRobustnessPolicy::standard; std::unique_ptr m_impl; @@ -253,12 +297,8 @@ class MarchingCubesSingleDomain /// @brief Allocate MarchingCubesImpl object std::unique_ptr newMarchingCubesImpl(); +}; -}; // class MarchingCubesSingleDomain - -} // end namespace marching_cubes -} // end namespace detail -} // namespace quest -} // namespace axom +} // namespace axom::quest::detail::marching_cubes #endif // AXOM_USE_CONDUIT From 6058d66eaae9755859f771f8c75177f7490fc60b Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Fri, 10 Jul 2026 11:42:44 -0700 Subject: [PATCH 02/36] quest: Add bump-backend Marching Cubes test for structured/unstructured on all exec spaces --- src/axom/quest/tests/CMakeLists.txt | 26 + .../quest/tests/quest_marching_cubes_bump.cpp | 906 ++++++++++++++++++ 2 files changed, 932 insertions(+) create mode 100644 src/axom/quest/tests/quest_marching_cubes_bump.cpp diff --git a/src/axom/quest/tests/CMakeLists.txt b/src/axom/quest/tests/CMakeLists.txt index 2d6eedaa74..0c3369eab3 100644 --- a/src/axom/quest/tests/CMakeLists.txt +++ b/src/axom/quest/tests/CMakeLists.txt @@ -310,6 +310,32 @@ 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 + ) + +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..edfc766bdc --- /dev/null +++ b/src/axom/quest/tests/quest_marching_cubes_bump.cpp @@ -0,0 +1,906 @@ +// 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" + +#if defined(AXOM_USE_CONDUIT) && defined(AXOM_USE_BUMP) + + #include "axom/core.hpp" + #include "axom/bump/utilities/conduit_memory.hpp" + #include "axom/primal.hpp" + #include "axom/quest/MarchingCubes.hpp" + #include "axom/quest/util/mesh_helpers.hpp" + #include "axom/sidre.hpp" + #include "axom/spin/MortonIndex.hpp" + #include "axom/mint/mesh/UnstructuredMesh.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 soup, 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); + + // MarchingCubes' public input contract is multi-domain. + // Keep the wrapped node alive through computeIsocontour(), + // since the single-domain objects cache pointers into it. + conduit::Node mdMesh; + mdMesh.append().set(mesh); + conduit::Node execMdMesh; + copyBlueprintToPolicy(execMdMesh, mdMesh, policy, allocatorID); + mc.setMesh(execMdMesh, "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")); + + 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."; + // Legacy invariant: node count == facetCount * DIM. + EXPECT_EQ(mc.getContourNodeCount(), nFacets * 3); + + // 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"); + conduit::Node mdMesh; + mdMesh.append().set(mesh); + const int allocatorID = axom::policyToDefaultAllocatorID(policy); + conduit::Node execMdMesh; + copyBlueprintToPolicy(execMdMesh, mdMesh, policy, allocatorID); + quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); + mc.setUseBumpBackend(true); + mc.setRobustnessPolicy(rp); + mc.setMesh(execMdMesh, "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 + +#endif // AXOM_USE_CONDUIT && AXOM_USE_BUMP + +int main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + int result = RUN_ALL_TESTS(); + return result; +} From b34a798f2cad48da0b7b0e68e2b6dce80945c412 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Fri, 10 Jul 2026 16:02:53 -0700 Subject: [PATCH 03/36] quest: Adds bump-based support to MC example And improves some documentation. --- src/axom/quest/MarchingCubes.hpp | 7 ++- .../quest/detail/MarchingCubesBumpAdaptor.hpp | 43 +++++++++------ src/axom/quest/examples/CMakeLists.txt | 47 ++++++++++++++++ .../examples/quest_marching_cubes_example.cpp | 53 ++++++++++++++++++- 4 files changed, 133 insertions(+), 17 deletions(-) diff --git a/src/axom/quest/MarchingCubes.hpp b/src/axom/quest/MarchingCubes.hpp index aae51fac08..4963f5e614 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -292,6 +292,10 @@ 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 fan-triangulates those polygons when filling the legacy + * fixed-stride output consumed here, so this method still populates a triangle mesh in 3D. */ void populateContourMesh(axom::mint::UnstructuredMesh& mesh, const std::string& cellIdField = {}, @@ -304,7 +308,8 @@ class MarchingCubes * 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}. - * The existing fixed-stride array accessors still expose the legacy un-welded triangle/segment soup. + * The existing fixed-stride array accessors and populateContourMesh() still expose the + * legacy un-welded triangle/segment soup; 3D polygonal faces are fan-triangulated there. * * 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 diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index e7970aa63c..a1eb6a3548 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -11,13 +11,24 @@ * the legacy quest::MarchingCubes fixed-stride output buffers. * * bump's CutField output is a welded, mixed-shape unstructured Blueprint topology: - * topologies//type == "unstructured" - * topologies//elements/connectivity (flat, ConnectivityType) - * topologies//elements/sizes (per-zone corner count) - * topologies//elements/offsets (per-zone start into connectivity) - * topologies//elements/shapes (per-zone Blueprint ShapeID) - * coordsets//values/{x,y[,z]} (explicit, blended/welded points) - * fields/originalElements/values (element-assoc, input zone per fragment) + * + * ├── 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 an unwelded fixed-stride representation: * m_facetNodeCoords : (facetCount*DIM, DIM) one row per facet-corner @@ -128,15 +139,18 @@ void adaptCutFieldOutput(const conduit::Node& n_output, 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); - 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)); - const conduit::Node& n_elems = n_topo.fetch_existing("elements"); // 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 conduit::Node& n_conn = n_elems.fetch_existing("connectivity"); + + + 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("fields/originalElements/values"); @@ -182,9 +196,8 @@ void adaptCutFieldOutput(const conduit::Node& n_output, const bool doRemap = !fieldStrideRemap.empty(); // --- The fan-triangulation + re-expansion kernel ----------------------- - // One thread per bump zone. Each zone writes facetsPerZone facets; for - // each facet we emit DIM corner coords (re-expanded / un-welded) and DIM - // ids. + // One thread per bump zone. Each zone writes facetsPerZone facets; + // for each facet we emit DIM corner coords (re-expanded / un-welded) and DIM ids. axom::for_all( numZones, AXOM_LAMBDA(axom::IndexType z) { diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index e168c25f03..a0c1466791 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -585,6 +585,53 @@ if(CONDUIT_FOUND) endforeach() endforeach() + if(AXOM_ENABLE_BUMP) + # Exercise the example's bump-backend CLI path without duplicating the + # full legacy matrix. Use one 2D and one 3D structured mesh; the + # dedicated quest_marching_cubes_bump test covers unstructured input. + set(_bump_meshes "mdmesh.2x1" "mdmesh.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 + --parentCellIdMode legacyFieldOrder + NUM_MPI_TASKS ${_nranks} + NUM_OMP_THREADS ${_num_threads}) + set_tests_properties(${_test} PROPERTIES + PASS_REGULAR_EXPRESSION "Contour mesh has .* cells") + endforeach() + endforeach() + endif() + unset(_nranks) unset(_test) endif() diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 3c19f9734f..46916e661a 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -101,6 +101,16 @@ struct Input quest::MarchingCubesDataParallelism dataParallelism = quest::MarchingCubesDataParallelism::byPolicy; + quest::MarchingCubesParentCellIdMode parentCellIdMode = + quest::MarchingCubesParentCellIdMode::blueprintZoneId; + + // 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; // Contour generation count for each MarchingCubes objects. @@ -122,6 +132,22 @@ struct Input }; // clang-format on + // clang-format off + const std::map s_validParentCellIdModes + { + {"blueprintZoneId", quest::MarchingCubesParentCellIdMode::blueprintZoneId} + , {"legacyFieldOrder", quest::MarchingCubesParentCellIdMode::legacyFieldOrder} + }; + // clang-format on + + // clang-format off + const std::map s_validRobustnessPolicies + { + {"standard", quest::MarchingCubesRobustnessPolicy::standard} + , {"robust", quest::MarchingCubesRobustnessPolicy::robust} + }; + // clang-format on + public: bool isVerbose() const { return _verboseOutput; } @@ -133,10 +159,32 @@ 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_option("--parentCellIdMode", parentCellIdMode) + ->description( + "How to number parent-cell ids of generated facets: " + "'blueprintZoneId' (default) or 'legacyFieldOrder' (structured only)") + ->capture_default_str() + ->transform(axom::CLI::CheckedTransformer(s_validParentCellIdModes)); + + 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 " @@ -843,6 +891,9 @@ struct ContourTestBase initializationTimer.start(); mcPtr = std::make_unique(params.policy, s_allocatorId, params.dataParallelism); + mcPtr->setUseBumpBackend(params.useBumpBackend); + mcPtr->setParentCellIdMode(params.parentCellIdMode); + mcPtr->setRobustnessPolicy(params.robustnessPolicy); mcPtr->setMesh(computationalMesh.asConduitNode(), "mesh", "mask"); initializationTimer.stop(); } From 57a2057f5482f8ac90e3e5c06ed600daed564970 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Fri, 10 Jul 2026 19:50:06 -0700 Subject: [PATCH 04/36] quest: Reuse vertices when triangulating MC polygons --- src/axom/quest/MarchingCubes.cpp | 22 +- src/axom/quest/MarchingCubes.hpp | 17 +- .../quest/detail/MarchingCubesBumpAdaptor.hpp | 248 +++++++++++++++--- .../quest/detail/MarchingCubesBumpImpl.hpp | 28 +- src/axom/quest/detail/MarchingCubesImpl.hpp | 6 +- .../detail/MarchingCubesSingleDomain.hpp | 16 +- .../quest/tests/quest_marching_cubes_bump.cpp | 41 ++- 7 files changed, 313 insertions(+), 65 deletions(-) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index becdf96583..0d508735d9 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -40,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) @@ -106,6 +107,7 @@ void MarchingCubes::computeIsocontour(double contourVal) // 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]; @@ -116,7 +118,9 @@ void MarchingCubes::computeIsocontour(double contourVal) 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 +134,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 +152,12 @@ 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() { m_facetCount = 0; + m_nodeCount = 0; m_facetNodeIds.clear(); m_facetNodeCoords.clear(); m_facetParentIds.clear(); @@ -234,7 +235,7 @@ void MarchingCubes::populateContourMesh(axom::mint::UnstructuredMesh(d)); @@ -294,9 +295,8 @@ void 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); } diff --git a/src/axom/quest/MarchingCubes.hpp b/src/axom/quest/MarchingCubes.hpp index 4963f5e614..6467d68894 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -293,9 +293,8 @@ class MarchingCubes * 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 fan-triangulates those polygons when filling the legacy - * fixed-stride output consumed here, so this method still populates a triangle mesh in 3D. + * 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 = {}, @@ -304,18 +303,19 @@ class MarchingCubes /*! * @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}. - * The existing fixed-stride array accessors and populateContourMesh() still expose the - * legacy un-welded triangle/segment soup; 3D polygonal faces are fan-triangulated there. + * 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) const; + void populateContourMeshBlueprint(conduit::Node& bpMesh, bool triangulate = false) const; /*! * @brief Return view of facet corner node indices (connectivity) Array. @@ -472,6 +472,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(). @@ -484,6 +487,8 @@ class MarchingCubes */ axom::Array m_facetNodeCoords; + axom::Array m_nodeIndexOffsets; + /*! * @brief Flat index of parent cell of facets. * @see allocateOutputBuffers(). diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index a1eb6a3548..27a4ea1b5a 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -30,25 +30,19 @@ * └── originalElements * └─• values (element-assoc, input zone per fragment) * - * The legacy MarchingCubes output is an unwelded fixed-stride representation: - * m_facetNodeCoords : (facetCount*DIM, DIM) one row per facet-corner - * m_facetNodeIds : (facetCount, DIM) DIM corner ids per facet, where + * 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 - * m_facetIndexOffset*DIM (the parent concatenates domains) + * 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. Re-expand: every output facet writes DIM fresh rows into m_facetNodeCoords - * (so the legacy facetCount*DIM node-count invariant holds) - * and its m_facetNodeIds row is the consecutive ids of those rows. + * 2. Reuse bump's welded vertex coordinates and write only triangle/segment connectivity. * 3. Parent id per facet := originalElements[srcZone], optionally remapped to * the legacy field-stride flat order (structured input + legacyFieldOrder). - * - * This file performs no triangle *welding* of its own; it intentionally expands - * back to the legacy soup so existing users are byte-compatible. Callers who - * want bump's richer welded mesh use the additive Blueprint accessors. */ #ifndef AXOM_QUEST_MARCHINGCUBESBUMPADAPTOR_H_ @@ -60,6 +54,7 @@ #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" @@ -96,20 +91,206 @@ AXOM_HOST_DEVICE inline axom::IndexType facetsPerZone(axom::IndexType nCorners) return nCorners >= 2 ? 1 : 0; } +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) { + 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]; + } + }); + }); + + n_values.move(newValues); +} + +/*! + * @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(DIM != 3) + { + return; + } + + namespace bputils = axom::bump::utilities; + namespace bpviews = axom::bump::views; + + 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) { + using ConnectivityType = typename decltype(connView)::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); + n_elems["shapes"].move(newShapes); + n_elems["shape_map"].reset(); + n_elems["shape_map"][bpviews::TriTraits::name()] = bpviews::Tri_ShapeID; + }; + + #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 +} + /*! - * @brief Convert one bump CutField output (single domain) into the legacy - * fixed-stride output buffers supplied by the parent MarchingCubes. + * @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 (totalFacetCount*DIM, 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]). Node-coord rows and node ids - * are written starting at facetIndexOffset (ids offset by *DIM). + * 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 fieldStrideRemap If non-null, a precomputed per-input-zone map from @@ -124,6 +305,7 @@ void adaptCutFieldOutput(const conduit::Node& n_output, axom::ArrayView facetNodeCoords, axom::ArrayView facetParentIds, axom::IndexType facetIndexOffset, + axom::IndexType nodeIndexOffset, axom::IndexType thisDomainFacetCount, axom::ArrayView fieldStrideRemap) { @@ -146,11 +328,9 @@ void adaptCutFieldOutput(const conduit::Node& n_output, 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("fields/originalElements/values"); @@ -175,6 +355,18 @@ void adaptCutFieldOutput(const conduit::Node& n_output, } 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]; + if(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 @@ -195,9 +387,9 @@ void adaptCutFieldOutput(const conduit::Node& n_output, // Capture raw views for the kernel. const bool doRemap = !fieldStrideRemap.empty(); - // --- The fan-triangulation + re-expansion kernel ----------------------- + // --- The fan-triangulation kernel ------------------------------------- // One thread per bump zone. Each zone writes facetsPerZone facets; - // for each facet we emit DIM corner coords (re-expanded / un-welded) and DIM ids. + // each facet reuses bump's welded coordset vertex ids. axom::for_all( numZones, AXOM_LAMBDA(axom::IndexType z) { @@ -239,25 +431,11 @@ void adaptCutFieldOutput(const conduit::Node& n_output, local[1] = 1; } - // Re-expanded node rows for this facet are contiguous: each facet - // owns exactly DIM rows in m_facetNodeCoords at facetIdx*DIM .. +DIM. - const axom::IndexType nodeRowBase = facetIdx * DIM; - for(int c = 0; c < DIM; ++c) { const axom::IndexType weldedNode = static_cast(connView[connStart + local[c]]); - const axom::IndexType outRow = nodeRowBase + c; - - facetNodeCoords(outRow, 0) = xView[weldedNode]; - facetNodeCoords(outRow, 1) = yView[weldedNode]; - if(DIM == 3) - { - facetNodeCoords(outRow, 2) = zView[weldedNode]; - } - - // Legacy ids index into m_facetNodeCoords directly. - facetNodeIds(facetIdx, c) = outRow; + facetNodeIds(facetIdx, c) = nodeIndexOffset + weldedNode; } facetParentIds[facetIdx] = parentId; diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index fd8e5ed717..0f3bad23e3 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -24,11 +24,9 @@ * - 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). + * - 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. - * To preserve the legacy fixed-stride (facetCount, DIM) "triangle soup" output contract, - * the adaptor fan-triangulates polygons and re-expands welded points into per-facet corners - * when filling the legacy output buffers. (Richer, welded output is exposed via additive accessors on MarchingCubes.) + * The adaptor can optionally triangulate the polygon. */ #ifndef AXOM_QUEST_MARCHINGCUBESBUMPIMPL_H_ @@ -200,14 +198,31 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase axom::IndexType getContourCellCount() const override { return m_facetCount; } + axom::IndexType getContourNodeCount() const override + { + 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()); + } + bool hasContourMeshBlueprint() const override { return m_output != nullptr; } - void copyContourMeshBlueprint(conduit::Node& bpMesh) const override + void copyContourMeshBlueprint(conduit::Node& bpMesh, bool triangulate) const override { SLIC_ERROR_IF(m_output == nullptr, "MarchingCubes bump backend has no Blueprint contour output. " "Call computeIsocontour() before requesting it."); axom::bump::utilities::copy(bpMesh, *m_output, m_allocatorID); + if(triangulate) + { + triangulateBlueprintMesh(bpMesh, m_allocatorID); + } } void relinquishContourMeshBlueprint(conduit::Node& bpMesh) override @@ -636,7 +651,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase /*! * @brief Fill the parent-allocated legacy output buffers from cached bump output, - * fan-triangulating polygons and re-expanding welded points so the legacy (facetCount, DIM) un-welded contract is preserved exactly. + * triangulating the polygons while reusing bump's welded vertex coordinates. */ void fillLegacyOutputBuffers() { @@ -664,6 +679,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase m_facetNodeCoords, m_facetParentIds, m_facetIndexOffset, + m_nodeIndexOffset, m_facetCount, remapView); } diff --git a/src/axom/quest/detail/MarchingCubesImpl.hpp b/src/axom/quest/detail/MarchingCubesImpl.hpp index 48d0b98955..e9b9d9772a 100644 --- a/src/axom/quest/detail/MarchingCubesImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesImpl.hpp @@ -468,6 +468,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 +482,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 +499,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; diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp index 2a9b7785d3..6c65ecdb18 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp @@ -140,7 +140,7 @@ class MarchingCubesSingleDomain 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 @@ -208,6 +208,9 @@ 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; } @@ -217,7 +220,11 @@ class MarchingCubesSingleDomain * The legacy backend does not provide this representation; callers should * check hasContourMeshBlueprint() before invoking this method. */ - virtual void copyContourMeshBlueprint(conduit::Node& bpMesh) const { bpMesh.reset(); } + 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. @@ -231,12 +238,14 @@ class MarchingCubesSingleDomain 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() { } @@ -251,6 +260,7 @@ 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; } diff --git a/src/axom/quest/tests/quest_marching_cubes_bump.cpp b/src/axom/quest/tests/quest_marching_cubes_bump.cpp index edfc766bdc..853d2b0f7a 100644 --- a/src/axom/quest/tests/quest_marching_cubes_bump.cpp +++ b/src/axom/quest/tests/quest_marching_cubes_bump.cpp @@ -139,7 +139,7 @@ struct RoundField //--------------------------------------------------------------------------- /*! - * @brief Count, for a 3D triangle soup, how many facets use each undirected edge + * @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. */ @@ -568,6 +568,33 @@ void runAndVerify3D(conduit::Node& mesh, 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()); @@ -577,8 +604,16 @@ void runAndVerify3D(conduit::Node& mesh, const axom::IndexType nFacets = mc.getContourCellCount(); ASSERT_GT(nFacets, 0) << "Expected a non-empty contour."; - // Legacy invariant: node count == facetCount * DIM. - EXPECT_EQ(mc.getContourNodeCount(), nFacets * 3); + 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; From ac1a631503a0e32aadf2ac06185e42bd99f1b664 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 13 Jul 2026 16:49:11 -0700 Subject: [PATCH 05/36] quest: MarchingCubes example now has a hard dependency on bump --- src/axom/quest/examples/CMakeLists.txt | 93 +++++++------- .../examples/quest_marching_cubes_example.cpp | 115 +++++------------- 2 files changed, 76 insertions(+), 132 deletions(-) diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index a0c1466791..3c0e934fd5 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( @@ -585,52 +580,50 @@ if(CONDUIT_FOUND) endforeach() endforeach() - if(AXOM_ENABLE_BUMP) - # Exercise the example's bump-backend CLI path without duplicating the - # full legacy matrix. Use one 2D and one 3D structured mesh; the - # dedicated quest_marching_cubes_bump test covers unstructured input. - set(_bump_meshes "mdmesh.2x1" "mdmesh.2x2x1") - foreach(_pol ${AXOM_EXECUTION_POLICIES}) - set(_num_threads) - if(_pol STREQUAL "omp") - set(_num_threads ${AXOM_TEST_NUM_OMP_THREADS}) + # Exercise the example's bump-backend CLI path without duplicating the + # full legacy matrix. Use one 2D and one 3D structured mesh; the + # dedicated quest_marching_cubes_bump test covers unstructured input. + set(_bump_meshes "mdmesh.2x1" "mdmesh.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() - 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 - --parentCellIdMode legacyFieldOrder - NUM_MPI_TASKS ${_nranks} - NUM_OMP_THREADS ${_num_threads}) - set_tests_properties(${_test} PROPERTIES - PASS_REGULAR_EXPRESSION "Contour mesh has .* cells") - endforeach() + 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 + --parentCellIdMode legacyFieldOrder + NUM_MPI_TASKS ${_nranks} + NUM_OMP_THREADS ${_num_threads}) + set_tests_properties(${_test} PROPERTIES + PASS_REGULAR_EXPRESSION "Contour mesh has .* cells") endforeach() - endif() + endforeach() unset(_nranks) unset(_test) diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 46916e661a..ad4931ec9a 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -21,6 +21,9 @@ #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" @@ -31,6 +34,7 @@ #include "axom/core/MDMapping.hpp" #include "axom/quest/MarchingCubes.hpp" #include "axom/quest/MeshViewUtil.hpp" +#include "axom/bump/utilities/conduit_memory.hpp" #if defined(AXOM_USE_SIDRE) #include "axom/sidre.hpp" #endif @@ -323,26 +327,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 @@ -356,6 +340,24 @@ void getIntMinMax(int inVal, int& minVal, int& maxVal, int& sumVal) #endif } +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 +} + Input params; int myRank = -1, numRanks = -1; // MPI stuff, set in main(). @@ -489,8 +491,6 @@ struct BlueprintStructuredMesh int dimension() const { return _ndims; } - const std::string& coordsetPath() const { return _coordsetPath; } - /*! @return largest mesh spacing. @@ -561,11 +561,7 @@ struct BlueprintStructuredMesh 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(); @@ -576,21 +572,13 @@ 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: @@ -611,11 +599,7 @@ struct BlueprintStructuredMesh SLIC_ASSERT(!meshFilename.empty()); _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 + loadBlueprintMesh(meshFilename, _mdMesh); SLIC_ASSERT(conduit::blueprint::mesh::is_multi_domain(_mdMesh)); _domCount = conduit::blueprint::mesh::number_of_domains(_mdMesh); @@ -704,11 +688,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(); @@ -801,22 +781,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) @@ -969,21 +934,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()); } From 8edbcea472e259a60aee49ff6b18d8b768331104 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 13 Jul 2026 19:30:49 -0700 Subject: [PATCH 06/36] quest: Fixes MC dependencies on conduit and bump All of MC depends on conduit and the bump Impl/Adapter also depend on bump. The example now depends on bump. Also includes some misc. formatting and doxygen changes. --- src/axom/quest/CMakeLists.txt | 23 ++-- src/axom/quest/MarchingCubes.cpp | 10 ++ src/axom/quest/MarchingCubes.hpp | 114 ++++++++-------- .../quest/detail/MarchingCubesBumpAdaptor.hpp | 65 +++++----- .../quest/detail/MarchingCubesBumpImpl.hpp | 77 +++++------ src/axom/quest/detail/MarchingCubesImpl.hpp | 55 ++++---- .../detail/MarchingCubesSingleDomain.cpp | 9 +- .../detail/MarchingCubesSingleDomain.hpp | 59 ++++----- .../examples/quest_marching_cubes_example.cpp | 122 ++++++++---------- .../quest/tests/quest_marching_cubes_bump.cpp | 76 ++++++----- 10 files changed, 302 insertions(+), 308 deletions(-) diff --git a/src/axom/quest/CMakeLists.txt b/src/axom/quest/CMakeLists.txt index a84de7ba4a..276862a433 100644 --- a/src/axom/quest/CMakeLists.txt +++ b/src/axom/quest/CMakeLists.txt @@ -136,23 +136,28 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_MPI) conduit::conduit_mpi) endif() -if(AXOM_ENABLE_BUMP) +if(CONDUIT_FOUND) blt_list_append( TO quest_headers - ELEMENTS MarchingCubes.hpp - detail/MarchingCubesSingleDomain.hpp - detail/MarchingCubesImpl.hpp - detail/MarchingCubesBumpImpl.hpp - IF CONDUIT_FOUND) + ELEMENTS MarchingCubes.hpp + detail/MarchingCubesSingleDomain.hpp + detail/MarchingCubesImpl.hpp) blt_list_append( TO quest_sources ELEMENTS MarchingCubes.cpp - detail/MarchingCubesSingleDomain.cpp - IF CONDUIT_FOUND) + 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() -blt_list_append( TO quest_depends_on ELEMENTS conduit::conduit IF CONDUIT_FOUND ) 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 0d508735d9..1683185a0b 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -100,6 +100,16 @@ 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"); diff --git a/src/axom/quest/MarchingCubes.hpp b/src/axom/quest/MarchingCubes.hpp index 6467d68894..e3ff3413e7 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -38,15 +38,13 @@ class MarchingCubesSingleDomain; /*! * @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. * - * This setting controls only the legacy structured-mesh backend. - * When MarchingCubes is configured to use the bump::extraction::CutField backend, - * bump manages its own internal parallelism and this value is accepted only for API compatibility. + * @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 { @@ -59,20 +57,21 @@ enum class MarchingCubesDataParallelism * @brief Enum controlling the meaning of the parent-cell ids reported for generated contour facets * (see MarchingCubes::getContourFacetParents and MarchingCubes::populateContourMesh). * - * The legacy marching cubes implementation numbered parent cells by their flat - * index in the same row- or column-major ordering as the input scalar function array + * The legacy marching cubes implementation numbered parent cells by their flat index + * in the same row- or column-major ordering as the input scalar function array * (i.e. following the function field's stride order). - * The bump-backed implementation natively numbers cells by their Blueprint zone index, - * which uses a fixed i-fastest ordering independent of how the field is stored in memory. + * + * The bump-backed implementation natively numbers cells by their Blueprint zone index. * For structured input these two numberings coincide only when the field is stored i-fastest; - *otherwise they differ by a stride-order permutation. + * otherwise they differ by a stride-order permutation. * * This enum lets callers choose which numbering they receive: * - \c blueprintZoneId (default): report the Blueprint zone index. This is the natural, * mesh-type-agnostic identifier and the only meaningful choice for unstructured input. * - \c legacyFieldOrder: reproduce the legacy numbering (flat index in the function field's stride order). * Provided so existing structured-mesh callers that depend on the historical meaning are unaffected. - * This option only applies to structured input; for unstructured input the Blueprint zone id is always used. + * + * @note This option only applies to structured input; for unstructured input the Blueprint zone id is always used. */ enum class MarchingCubesParentCellIdMode { @@ -83,17 +82,16 @@ enum class MarchingCubesParentCellIdMode /*! * @brief Enum selecting the isosurface case-table / intersector robustness used by the bump backend * - * The bump CutField 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 (its \c FieldType is \c float), + * 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. * - * This enum is in anticipation of the more robust case that will be added soon and only applies to the - * new bump-based backend: + * @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). @@ -118,8 +116,8 @@ enum class MarchingCubesRobustnessPolicy * * 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 @@ -140,20 +138,19 @@ enum class MarchingCubesRobustnessPolicy * } * @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 { @@ -161,8 +158,7 @@ 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 @@ -188,8 +184,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, @@ -203,8 +199,8 @@ 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. @@ -233,7 +229,7 @@ class MarchingCubes * (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 otherwise has no effect. + * Requesting the bump backend without bump is an error. * The legacy backend supports only structured input. * * @note The MarchingCubesDataParallelism constructor argument is a legacy @@ -244,7 +240,7 @@ class MarchingCubes * 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) { m_useBumpBackend = useBump; } + void setUseBumpBackend(bool useBump); /*! * @brief Select the isosurface robustness policy for the bump backend. @@ -278,13 +274,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. * @@ -303,8 +298,8 @@ class MarchingCubes /*! * @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. + * @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 @@ -421,12 +416,16 @@ 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 Legacy backend data-parallel scan strategy, or byPolicy. - MarchingCubesDataParallelism m_dataParallelism = MarchingCubesDataParallelism::byPolicy; + MarchingCubesDataParallelism m_dataParallelism {MarchingCubesDataParallelism::byPolicy}; //! @brief Number of domains. axom::IndexType m_domainCount; @@ -443,16 +442,16 @@ class MarchingCubes std::string m_maskFieldName; std::string m_maskPath; - int m_maskVal = 1; + int m_maskVal {1}; //! @brief How to number parent-cell ids of generated facets. - MarchingCubesParentCellIdMode m_parentCellIdMode = MarchingCubesParentCellIdMode::blueprintZoneId; + MarchingCubesParentCellIdMode m_parentCellIdMode {MarchingCubesParentCellIdMode::blueprintZoneId}; //! @brief Whether to use the bump CutField backend (opt-in; default legacy). - bool m_useBumpBackend = false; + bool m_useBumpBackend {false}; //! @brief Isosurface robustness policy for the bump backend (Phase 6 seam). - MarchingCubesRobustnessPolicy m_robustnessPolicy = MarchingCubesRobustnessPolicy::standard; + MarchingCubesRobustnessPolicy m_robustnessPolicy {MarchingCubesRobustnessPolicy::standard}; //! @brief First facet index from each parent domain. axom::Array m_facetIndexOffsets; @@ -498,9 +497,6 @@ class MarchingCubes /// @brief Domain ids of facets axom::Array m_facetDomainIds; ///@} - - //! @brief Allocate output buffers corresponding to runtime policy. - void allocateOutputBuffers(); }; } // namespace axom::quest diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index 27a4ea1b5a..ee5e586316 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -45,32 +45,36 @@ * the legacy field-stride flat order (structured input + legacyFieldOrder). */ -#ifndef AXOM_QUEST_MARCHINGCUBESBUMPADAPTOR_H_ -#define AXOM_QUEST_MARCHINGCUBESBUMPADAPTOR_H_ +#pragma once #include "axom/config.hpp" -#if defined(AXOM_USE_CONDUIT) && defined(AXOM_USE_BUMP) - - #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/MDMapping.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 +#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/MDMapping.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 namespace axom::quest::detail::marching_cubes { @@ -268,13 +272,13 @@ void triangulateBlueprintMesh(conduit::Node& n_output, int allocatorID) n_elems["shape_map"][bpviews::TriTraits::name()] = bpviews::Tri_ShapeID; }; - #if defined(_WIN32) +#if defined(_WIN32) triangulateViews(bputils::make_array_view(n_sizes), bputils::make_array_view(n_offsets), bputils::make_array_view(n_conn)); - #else +#else bpviews::indexNodeToArrayViewSame(n_sizes, n_offsets, n_conn, std::move(triangulateViews)); - #endif +#endif } /*! @@ -443,14 +447,14 @@ void adaptCutFieldOutput(const conduit::Node& n_output, }); }; - #if defined(_WIN32) +#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 +#else bpviews::indexNodeToArrayViewSame(n_sizes, n_offsets, n_conn, n_orig, std::move(adaptViews)); - #endif +#endif } /*! @@ -510,6 +514,3 @@ axom::Array buildFieldStrideRemap( } } // namespace axom::quest::detail::marching_cubes - -#endif // AXOM_USE_CONDUIT && AXOM_USE_BUMP -#endif // AXOM_QUEST_MARCHINGCUBESBUMPADAPTOR_H_ diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 0f3bad23e3..30e77b7f98 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -29,36 +29,40 @@ * The adaptor can optionally triangulate the polygon. */ -#ifndef AXOM_QUEST_MARCHINGCUBESBUMPIMPL_H_ -#define AXOM_QUEST_MARCHINGCUBESBUMPIMPL_H_ +#pragma once #include "axom/config.hpp" -#if defined(AXOM_USE_CONDUIT) && defined(AXOM_USE_BUMP) - - #include "axom/core/execution/execution_space.hpp" - #include "axom/core/execution/for_all.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/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 +#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/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/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 namespace axom::quest::detail::marching_cubes { @@ -329,7 +333,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase { namespace bumpviews = axom::bump::views; - #if defined(_WIN32) +#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. @@ -387,10 +391,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase { SLIC_ERROR(axom::fmt::format("Unsupported topology type '{}'.", topoType)); } - #else +#else bumpviews::dispatch_topology(n_topo, std::forward(func)); - #endif +#endif } void attachSelectedZonesOption(conduit::Node& n_options, @@ -695,18 +699,18 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase private: int m_allocatorID = axom::INVALID_ALLOCATOR_ID; - const conduit::Node* m_dom = nullptr; + const conduit::Node* m_dom {nullptr}; std::string m_topologyName; std::string m_fcnFieldName; std::string m_maskFieldName; //! @brief How to number parent-cell ids of generated facets. - MarchingCubesParentCellIdMode m_parentCellIdMode = MarchingCubesParentCellIdMode::blueprintZoneId; - MarchingCubesRobustnessPolicy m_robustnessPolicy = MarchingCubesRobustnessPolicy::standard; + MarchingCubesParentCellIdMode m_parentCellIdMode {MarchingCubesParentCellIdMode::blueprintZoneId}; + MarchingCubesRobustnessPolicy m_robustnessPolicy {MarchingCubesRobustnessPolicy::standard}; //! @name Structured metadata, captured only for the legacyFieldOrder remap. //! @{ - bool m_isStructured = false; + bool m_isStructured {false}; axom::StackArray m_cellDims {}; axom::StackArray m_fieldSlowestDirs {}; //! @} @@ -715,10 +719,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase std::unique_ptr m_output; //! @brief Legacy facet count (post fan-triangulation). - axom::IndexType m_facetCount = 0; + axom::IndexType m_facetCount {}; }; } // namespace axom::quest::detail::marching_cubes - -#endif // AXOM_USE_CONDUIT && AXOM_USE_BUMP -#endif // AXOM_QUEST_MARCHINGCUBESBUMPIMPL_H_ diff --git a/src/axom/quest/detail/MarchingCubesImpl.hpp b/src/axom/quest/detail/MarchingCubesImpl.hpp index e9b9d9772a..d91bd98eda 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; @@ -520,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; @@ -768,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 0bf89a6cad..ee465ecbf8 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.cpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.cpp @@ -8,14 +8,15 @@ // 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_CONDUIT) && defined(AXOM_USE_BUMP) + +#if defined(AXOM_USE_BUMP) #include "axom/quest/detail/MarchingCubesBumpImpl.hpp" #endif #include "axom/fmt.hpp" @@ -146,7 +147,9 @@ std::unique_ptr make_impl_leaf( } } #else - AXOM_UNUSED_VAR(useBumpBackend); + SLIC_ERROR_IF(useBumpBackend, + "MarchingCubes bump backend requires Axom to be configured " + "with the bump component."); #endif return std::unique_ptr( new MarchingCubesImpl(allocatorID, diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp index 6c65ecdb18..64e700ad27 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp @@ -16,18 +16,20 @@ #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::quest::detail::marching_cubes { @@ -49,7 +51,7 @@ class MarchingCubesSingleDomain //! \brief Constructor for applying algorithm in a single domain. MarchingCubesSingleDomain(MarchingCubes& mc); - ~MarchingCubesSingleDomain() { } + ~MarchingCubesSingleDomain() = default; /*! * @brief Intitialize object to a domain. @@ -248,7 +250,7 @@ class MarchingCubesSingleDomain m_nodeIndexOffset = nodeIndexOffset; } - virtual ~ImplBase() { } + virtual ~ImplBase() = default; virtual void clearDomain() = 0; @@ -266,15 +268,26 @@ class MarchingCubesSingleDomain 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; @@ -291,24 +304,12 @@ class MarchingCubesSingleDomain //! @brief Path to mask in m_dom. std::string m_maskPath; - double m_contourVal = 0.0; - int m_maskVal = 1; - MarchingCubesParentCellIdMode m_parentCellIdMode = MarchingCubesParentCellIdMode::blueprintZoneId; - MarchingCubesRobustnessPolicy m_robustnessPolicy = MarchingCubesRobustnessPolicy::standard; + double m_contourVal {0.0}; + int m_maskVal {1}; + MarchingCubesParentCellIdMode m_parentCellIdMode {MarchingCubesParentCellIdMode::blueprintZoneId}; + 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(); }; } // namespace axom::quest::detail::marching_cubes - -#endif // AXOM_USE_CONDUIT diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index ad4931ec9a..795c3cd137 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -5,19 +5,19 @@ // 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 @@ -27,19 +27,16 @@ // 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/mint/mesh/UnstructuredMesh.hpp" -#include "axom/core/MDMapping.hpp" #include "axom/quest/MarchingCubes.hpp" #include "axom/quest/MeshViewUtil.hpp" -#include "axom/bump/utilities/conduit_memory.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" @@ -72,13 +69,15 @@ 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: @@ -116,41 +115,29 @@ struct Input 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}}; - // clang-format off - const std::map s_validParentCellIdModes - { - {"blueprintZoneId", quest::MarchingCubesParentCellIdMode::blueprintZoneId} - , {"legacyFieldOrder", quest::MarchingCubesParentCellIdMode::legacyFieldOrder} - }; - // clang-format on + const std::map s_validParentCellIdModes { + {"blueprintZoneId", quest::MarchingCubesParentCellIdMode::blueprintZoneId}, + {"legacyFieldOrder", quest::MarchingCubesParentCellIdMode::legacyFieldOrder}}; - // clang-format off - const std::map s_validRobustnessPolicies - { - {"standard", quest::MarchingCubesRobustnessPolicy::standard} - , {"robust", quest::MarchingCubesRobustnessPolicy::robust} - }; - // clang-format on + const std::map s_validRobustnessPolicies { + {"standard", quest::MarchingCubesRobustnessPolicy::standard}, + {"robust", quest::MarchingCubesRobustnessPolicy::robust}}; public: bool isVerbose() const { return _verboseOutput; } @@ -416,11 +403,11 @@ 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); @@ -492,11 +479,10 @@ struct BlueprintStructuredMesh int dimension() const { return _ndims; } /*! - @return largest mesh spacing. - - Compute only once, because after that, coordinates data may be - moved to devices. - */ + * @return largest mesh spacing. + * + * Compute only once, because after that, coordinates data may be moved to devices. + */ double maxSpacing() const { if(_maxSpacing >= 0) @@ -520,11 +506,10 @@ 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); @@ -591,9 +576,7 @@ struct BlueprintStructuredMesh std::string _coordsetPath; double _maxSpacing = -1.0; - /*! - @brief Read a blueprint mesh into conduit::Node _mdMesh. - */ + //! @brief Read a blueprint mesh into conduit::Node _mdMesh. void readBlueprintMesh(const std::string& meshFilename) { SLIC_ASSERT(!meshFilename.empty()); @@ -721,11 +704,10 @@ static void addToStackArray(axom::StackArray& a, U b) } /*! - @brief Strategy pattern for supporting a variety of contour types. + * @brief Strategy pattern for supporting a variety of contour types. - The strategy encapsulates the scalar functions and things related to - it. -*/ + * The strategy encapsulates the scalar functions and things related to it. + */ template struct ContourTestStrategy { @@ -1377,9 +1359,9 @@ struct ContourTestBase } /*! - Check that computational cells that contain the contour value - have at least one contour mesh cell. - */ + * Check that computational cells that contain the contour value + * have at least one contour mesh cell. + */ int checkCellsContainingContour(BlueprintStructuredMesh& computationalMesh, axom::mint::UnstructuredMesh& contourMesh) { @@ -1678,9 +1660,7 @@ void finalizeLogger() } } -/*! - All the test code that depends on DIM to instantiate. -*/ +//! All the test code that depends on DIM to instantiate. template int testNdimInstance(BlueprintStructuredMesh& computationalMesh) { diff --git a/src/axom/quest/tests/quest_marching_cubes_bump.cpp b/src/axom/quest/tests/quest_marching_cubes_bump.cpp index 853d2b0f7a..38dfb41da7 100644 --- a/src/axom/quest/tests/quest_marching_cubes_bump.cpp +++ b/src/axom/quest/tests/quest_marching_cubes_bump.cpp @@ -31,26 +31,34 @@ #include "axom/config.hpp" -#if defined(AXOM_USE_CONDUIT) && defined(AXOM_USE_BUMP) - - #include "axom/core.hpp" - #include "axom/bump/utilities/conduit_memory.hpp" - #include "axom/primal.hpp" - #include "axom/quest/MarchingCubes.hpp" - #include "axom/quest/util/mesh_helpers.hpp" - #include "axom/sidre.hpp" - #include "axom/spin/MortonIndex.hpp" - #include "axom/mint/mesh/UnstructuredMesh.hpp" - - #include "conduit_blueprint.hpp" - - #include "gtest/gtest.h" - - #include - #include - #include - #include - #include +#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/bump/utilities/conduit_memory.hpp" +#include "axom/primal.hpp" +#include "axom/quest/MarchingCubes.hpp" +#include "axom/quest/util/mesh_helpers.hpp" +#include "axom/sidre.hpp" +#include "axom/spin/MortonIndex.hpp" +#include "axom/mint/mesh/UnstructuredMesh.hpp" + +#include "conduit_blueprint.hpp" + +#include "gtest/gtest.h" + +#include +#include +#include +#include +#include namespace { @@ -68,24 +76,24 @@ void copyBlueprintToPolicy(conduit::Node& dst, { 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) +// 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 +#endif - #if defined(AXOM_RUNTIME_POLICY_USE_HIP) +#if defined(AXOM_RUNTIME_POLICY_USE_HIP) if(policy == RuntimePolicy::hip) { bputils::copy>(dst, src, allocatorID); return; } - #endif +#endif AXOM_UNUSED_VAR(policy); AXOM_UNUSED_VAR(allocatorID); @@ -837,7 +845,7 @@ TEST(quest_marching_cubes_bump, robustness_seam_nfc_seq) test_robustness_seam(RuntimePolicy::seq); } - #if defined(AXOM_RUNTIME_POLICY_USE_OPENMP) && !defined(_WIN32) +#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) { @@ -851,9 +859,9 @@ TEST(quest_marching_cubes_bump, unstructured_hex_round_warped_omp) { test_unstructured_hex_round_warped(RuntimePolicy::omp); } - #endif +#endif - #if defined(AXOM_RUNTIME_POLICY_USE_CUDA) +#if defined(AXOM_RUNTIME_POLICY_USE_CUDA) TEST(quest_marching_cubes_bump, structured_round_cuda) { test_structured_round(RuntimePolicy::cuda); @@ -870,9 +878,9 @@ TEST(quest_marching_cubes_bump, unstructured_hex_round_warped_cuda) { test_unstructured_hex_round_warped(RuntimePolicy::cuda); } - #endif +#endif - #if defined(AXOM_RUNTIME_POLICY_USE_HIP) +#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) { @@ -886,7 +894,7 @@ TEST(quest_marching_cubes_bump, unstructured_hex_round_warped_hip) { test_unstructured_hex_round_warped(RuntimePolicy::hip); } - #endif +#endif // Self-test of the O3 edge-manifold helper (independent of MarchingCubes). TEST(quest_marching_cubes_bump, edge_manifold_helper_selftest) @@ -931,8 +939,6 @@ TEST(quest_marching_cubes_bump, edge_manifold_helper_selftest) } // namespace -#endif // AXOM_USE_CONDUIT && AXOM_USE_BUMP - int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); From 3c835a566bbad5318e440285c20179206c94f001 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 13 Jul 2026 20:11:31 -0700 Subject: [PATCH 07/36] quest: Cleans up dynamic->static dispatch in MC example --- .../examples/quest_marching_cubes_example.cpp | 245 ++++++++++-------- 1 file changed, 134 insertions(+), 111 deletions(-) diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 795c3cd137..6d791bb629 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -57,6 +57,9 @@ #include #include #include +#include +#include +#include namespace quest = axom::quest; namespace slic = axom::slic; @@ -1660,83 +1663,81 @@ void finalizeLogger() } } -//! All the test code that depends on DIM to instantiate. -template -int testNdimInstance(BlueprintStructuredMesh& computationalMesh) +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) +{ + 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() +{ + if(params.policy == RuntimePolicy::seq) { - computationalMesh.printMeshInfo(); + return selectTestDimension(TypeTag {}); } - - // 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 {}); + } #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> {}); } - 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> {}); } +#endif - return errCount; + SLIC_ERROR(axom::fmt::format("Unsupported runtime policy {}", params.policy)); + return TestInstance<2, axom::SEQ_EXEC> {}; } //------------------------------------------------------------------------------ @@ -1818,60 +1819,82 @@ int main(int argc, char** argv) //--------------------------------------------------------------------------- // 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(); + 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; + + if(params.usingPlanar()) + { + planarStrat = std::make_shared>(params.planeNormal(), + params.inplanePoint()); + 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(); From cabeb77e2507ff62f00d1537e24fa7ec10f46704 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 13 Jul 2026 20:46:46 -0700 Subject: [PATCH 08/36] quest: In MarchingCubes example, make Inout params a local variable ... instead of keeping it in global state. Also uses RAII for the logger instead of an explicit initialize/finalize call, and adds some misc formatting/comment changes. --- .../examples/quest_marching_cubes_example.cpp | 196 +++++++++--------- 1 file changed, 102 insertions(+), 94 deletions(-) diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 6d791bb629..0f1e97c88f 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -111,7 +111,7 @@ struct Input quest::MarchingCubesParentCellIdMode::blueprintZoneId; // Use the bump CutField backend (supports unstructured quad/hex) vs legacy. - bool useBumpBackend = false; + bool useBumpBackend {false}; // Bump-backend isosurface robustness policy (Phase 6 seam). quest::MarchingCubesRobustnessPolicy robustnessPolicy = @@ -348,17 +348,15 @@ bool verifyBlueprintMesh(const conduit::Node& mesh, conduit::Node& info) #endif } -Input params; - 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) { @@ -366,7 +364,7 @@ struct BlueprintStructuredMesh for(int d = 0; d < _mdMesh.number_of_children(); ++d) { auto dl = domainLengths(d); - SLIC_INFO_IF(params.isVerbose(), axom::fmt::format("dom[{}] size={}", d, dl)); + SLIC_INFO_IF(verboseOutput, axom::fmt::format("dom[{}] size={}", d, dl)); } _maxSpacing = maxSpacing(); } @@ -708,7 +706,7 @@ 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. */ template @@ -736,9 +734,9 @@ struct ContourTestBase { static constexpr auto MemorySpace = axom::execution_space::memory_space; 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") { } @@ -750,6 +748,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; @@ -791,14 +790,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"); } @@ -820,7 +819,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 @@ -830,20 +829,21 @@ 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->setUseBumpBackend(params.useBumpBackend); - mcPtr->setParentCellIdMode(params.parentCellIdMode); - mcPtr->setRobustnessPolicy(params.robustnessPolicy); + mcPtr = std::make_unique(m_params.policy, + s_allocatorId, + m_params.dataParallelism); + mcPtr->setUseBumpBackend(m_params.useBumpBackend); + mcPtr->setParentCellIdMode(m_params.parentCellIdMode); + mcPtr->setRobustnessPolicy(m_params.robustnessPolicy); mcPtr->setMesh(computationalMesh.asConduitNode(), "mesh", "mask"); initializationTimer.stop(); } @@ -859,20 +859,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) @@ -883,7 +883,7 @@ struct ContourTestBase { contourTimer.start(); } - mc.computeIsocontour(params.contourVal); + mc.computeIsocontour(m_params.contourVal); if(i == 0) { contourTimerM.stop(); @@ -901,8 +901,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")); @@ -958,9 +958,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); @@ -995,7 +995,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())); @@ -1116,7 +1116,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, @@ -1133,10 +1133,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 = {}) @@ -1171,7 +1171,7 @@ struct ContourTestBase { ++errCount; SLIC_INFO_IF( - params.isVerbose(), + m_params.isVerbose(), axom::fmt::format("checkContourSurface: node {} at {} has dist {}, off by {}", iNode, pt, @@ -1179,7 +1179,7 @@ struct ContourTestBase diff)); } } - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("checkContourSurface: found {} errors outside tolerance of {}", errCount, tol)); @@ -1187,7 +1187,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 { @@ -1200,7 +1200,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 { @@ -1232,9 +1232,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) { @@ -1334,7 +1332,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], @@ -1344,7 +1342,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], @@ -1354,7 +1352,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)); @@ -1479,10 +1477,10 @@ 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 + (minFcnValue <= m_params.contourVal && maxFcnValue >= m_params.contourVal); + // If the min or max values in the cell is close to the contour value, // touchesContour and hasCont can go either way. So give it a pass. - if(minFcnValue == params.contourVal || maxFcnValue == params.contourVal) + if(minFcnValue == m_params.contourVal || maxFcnValue == m_params.contourVal) { touchesContour = hasContour; } @@ -1491,7 +1489,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, @@ -1502,7 +1500,7 @@ struct ContourTestBase } } } - SLIC_INFO_IF(params.isVerbose(), + SLIC_INFO_IF(m_params.isVerbose(), axom::fmt::format("checkCellsContainingContour: found {} " "misrepresented computational cells.", errCount)); @@ -1620,49 +1618,59 @@ 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(); + } } -} +}; +// ---------------------------------------------------------------------------- +// Tag dispatch for choosing the desired execution policy and dimension +// ---------------------------------------------------------------------------- template struct TypeTag { @@ -1696,7 +1704,7 @@ using TestInstanceVariant = std::variant, >; template -TestInstanceVariant selectTestDimension(TypeTag) +TestInstanceVariant selectTestDimension(TypeTag, const Input& params) { if(params.ndim == 2) { @@ -1711,28 +1719,28 @@ TestInstanceVariant selectTestDimension(TypeTag) return TestInstance<2, axom::SEQ_EXEC> {}; } -TestInstanceVariant selectTestInstance() +TestInstanceVariant selectTestInstance(const Input& params) { if(params.policy == RuntimePolicy::seq) { - return selectTestDimension(TypeTag {}); + return selectTestDimension(TypeTag {}, params); } #if defined(AXOM_USE_RAJA) && defined(AXOM_USE_OPENMP) if(params.policy == RuntimePolicy::omp) { - return selectTestDimension(TypeTag {}); + return selectTestDimension(TypeTag {}, params); } #endif #if defined(AXOM_USE_RAJA) && defined(AXOM_USE_CUDA) && defined(AXOM_USE_UMPIRE) if(params.policy == RuntimePolicy::cuda) { - return selectTestDimension(TypeTag> {}); + return selectTestDimension(TypeTag> {}, params); } #endif #if defined(AXOM_USE_RAJA) && defined(AXOM_USE_HIP) && defined(AXOM_USE_UMPIRE) if(params.policy == RuntimePolicy::hip) { - return selectTestDimension(TypeTag> {}); + return selectTestDimension(TypeTag> {}, params); } #endif @@ -1747,13 +1755,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 { @@ -1785,14 +1794,14 @@ 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_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 { @@ -1814,12 +1823,12 @@ int main(int argc, char** argv) (double)sum / numRanks)); } - slic::flushStreams(); + raii_logger.flush(); //--------------------------------------------------------------------------- // Run test in the execution space set by command line. //--------------------------------------------------------------------------- - auto testInstance = selectTestInstance(); + auto testInstance = selectTestInstance(params); int errCount = std::visit( [&](const auto& instance) { AXOM_UNUSED_VAR(instance); @@ -1831,7 +1840,7 @@ int main(int argc, char** argv) std::shared_ptr> roundStrat; std::shared_ptr> gyroidStrat; - ContourTestBase contourTest; + ContourTestBase contourTest(params); if(params.usingPlanar()) { @@ -1897,7 +1906,6 @@ int main(int argc, char** argv) questMarchingCubesExample.stop(); printTimingStats(questMarchingCubesExample, "questMarchingCubesExample"); - finalizeLogger(); return errCount != 0; } From def815e86c34a8dbebca8c0c857ddaf9542f4b85 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 13 Jul 2026 21:22:00 -0700 Subject: [PATCH 09/36] quest: Bugfix when checking MC values for bump-based contours Tests the different orderings and checks that the results match expectations. --- src/axom/quest/examples/CMakeLists.txt | 37 ++++++------ .../examples/quest_marching_cubes_example.cpp | 60 ++++++++++++------- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index 3c0e934fd5..ab789f9d50 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -605,23 +605,26 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) 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 - --parentCellIdMode legacyFieldOrder - NUM_MPI_TASKS ${_nranks} - NUM_OMP_THREADS ${_num_threads}) - set_tests_properties(${_test} PROPERTIES - PASS_REGULAR_EXPRESSION "Contour mesh has .* cells") + foreach(_parent_mode blueprintZoneId legacyFieldOrder) + set(_test "quest_marching_cubes_bump_run_${_ndim}D_${_pol}_${_parent_mode}_${_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 + --parentCellIdMode ${_parent_mode} + --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() endforeach() diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 0f1e97c88f..5279aa7a55 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -733,6 +733,7 @@ template struct ContourTestBase { static constexpr auto MemorySpace = axom::execution_space::memory_space; + static constexpr double BumpGeometryToleranceScale = 1.e-5; using PointType = axom::primal::Point; explicit ContourTestBase(const Input& params) : m_params(params) @@ -756,6 +757,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"); @@ -1151,32 +1159,39 @@ 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( - m_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(m_params.isVerbose(), @@ -1310,7 +1325,7 @@ struct ContourTestBase upper[d] = coordsViews[d][upperIdx]; } 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); @@ -1520,6 +1535,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; @@ -1846,6 +1862,10 @@ int main(int argc, char** argv) { planarStrat = std::make_shared>(params.planeNormal(), params.inplanePoint()); + if(params.useBumpBackend) + { + planarStrat->setTolerance(contourTest.geometryTolerance(computationalMesh)); + } contourTest.addTestStrategy(planarStrat); } From 6a45fd99d628fe4e3dc372719e7dabb880bc2ea4 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Tue, 14 Jul 2026 09:59:29 -0700 Subject: [PATCH 10/36] quest: Adds support to MarchingCubes query for single domain mesh blueprint It was previously hard-coded to only support multidomain blueprint. Also adds a python script to generate single domain marching cubes blueprint meshes. --- src/axom/quest/MarchingCubes.cpp | 17 +- src/axom/quest/MarchingCubes.hpp | 13 +- .../quest/tests/quest_marching_cubes_bump.cpp | 19 +- src/tools/CMakeLists.txt | 12 + src/tools/gen-marching-cubes-mesh.py | 304 ++++++++++++++++++ 5 files changed, 346 insertions(+), 19 deletions(-) create mode 100755 src/tools/gen-marching-cubes-mesh.py diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index 1683185a0b..f17f1a0784 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -52,8 +52,17 @@ 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(conduit::blueprint::mesh::is_multi_domain(bpMesh)) + { + m_singleDomainMesh.reset(); + } + else + { + m_singleDomainMesh.reset(); + m_singleDomainMesh.append().set_external(bpMesh); + mdMesh = &m_singleDomainMesh; + } m_topologyName = topologyName; m_maskFieldName = maskField; @@ -65,7 +74,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) { @@ -79,7 +88,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) diff --git a/src/axom/quest/MarchingCubes.hpp b/src/axom/quest/MarchingCubes.hpp index e3ff3413e7..45695e90c4 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -175,7 +175,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. @@ -428,7 +428,7 @@ class MarchingCubes MarchingCubesDataParallelism m_dataParallelism {MarchingCubesDataParallelism::byPolicy}; //! @brief Number of domains. - axom::IndexType m_domainCount; + axom::IndexType m_domainCount {0}; /*! * @brief Single-domain implementations. @@ -436,6 +436,15 @@ 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; diff --git a/src/axom/quest/tests/quest_marching_cubes_bump.cpp b/src/axom/quest/tests/quest_marching_cubes_bump.cpp index 38dfb41da7..174fc96ab9 100644 --- a/src/axom/quest/tests/quest_marching_cubes_bump.cpp +++ b/src/axom/quest/tests/quest_marching_cubes_bump.cpp @@ -542,14 +542,9 @@ void runAndVerify3D(conduit::Node& mesh, mc.setUseBumpBackend(true); mc.setRobustnessPolicy(robustness); - // MarchingCubes' public input contract is multi-domain. - // Keep the wrapped node alive through computeIsocontour(), - // since the single-domain objects cache pointers into it. - conduit::Node mdMesh; - mdMesh.append().set(mesh); - conduit::Node execMdMesh; - copyBlueprintToPolicy(execMdMesh, mdMesh, policy, allocatorID); - mc.setMesh(execMdMesh, "mesh", maskFieldName); + conduit::Node execMesh; + copyBlueprintToPolicy(execMesh, mesh, policy, allocatorID); + mc.setMesh(execMesh, "mesh", maskFieldName); if(!maskFieldName.empty()) { mc.setMaskValue(maskVal); @@ -798,15 +793,13 @@ void test_robustness_seam(RuntimePolicy policy) auto facetCountFor = [&](quest::MarchingCubesRobustnessPolicy rp) { conduit::Node mesh; buildStructured3D(mesh, 16, f, "fcn"); - conduit::Node mdMesh; - mdMesh.append().set(mesh); const int allocatorID = axom::policyToDefaultAllocatorID(policy); - conduit::Node execMdMesh; - copyBlueprintToPolicy(execMdMesh, mdMesh, policy, allocatorID); + conduit::Node execMesh; + copyBlueprintToPolicy(execMesh, mesh, policy, allocatorID); quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); mc.setUseBumpBackend(true); mc.setRobustnessPolicy(rp); - mc.setMesh(execMdMesh, "mesh"); + mc.setMesh(execMesh, "mesh"); mc.setFunctionField("fcn"); mc.computeIsocontour(0.0); return mc.getContourCellCount(); diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index e8782b9992..e8adcb9409 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -234,6 +234,18 @@ 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-marching-cubes-mesh.py + 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-marching-cubes-mesh.py b/src/tools/gen-marching-cubes-mesh.py new file mode 100755 index 0000000000..470dd98355 --- /dev/null +++ b/src/tools/gen-marching-cubes-mesh.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 + +# 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 single-domain structured or unstructured Blueprint mesh for +# quest::MarchingCubes testing. +# +# The generated Blueprint hierarchy is: +# +# ├── state +# │ └─• domain_id == 0 +# ├── topologies +# │ └── +# │ ├─• coordset == +# │ ├─• type == "structured" or "unstructured" +# │ └── elements +# │ ├─• dims/{i,j,[k]} (structured cell dimensions) +# │ ├─• shape (unstructured "quad" or "hex") +# │ └─• connectivity (unstructured flat int64 connectivity) +# ├── coordsets +# │ └── +# │ ├─• type == "explicit" +# │ └── values (i-fastest node ordering) +# │ ├─• x (float64, node_count) +# │ ├─• y (float64, node_count) +# │ └─• [z] (float64, node_count, present in 3D) +# └── fields +# └── +# ├─• topology == +# ├─• association == "vertex" +# └─• values (float64 signed distance samples) + +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\n' + 'Conduit must be configured with python and hdf5.\n' + 'Alternatively, use the build directory convenience script:\n' + '/path/to/axom_build_dir/bin/run_python_with_axom.sh') + exit(-1) + +import numpy as np +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter + + +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 single-domain MarchingCubes Blueprint mesh.', + formatter_class=ArgumentDefaultsHelpFormatter) + 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=('20', '20'), + help='Logical size of mesh (cells), space- or comma-separated') + ps.add_argument('-o', '--output', type=str, default='mcmesh', help='Output file base name') + ps.add_argument('--topology', + choices=('structured', 'unstructured'), + default='structured', + help='Topology representation to write') + ps.add_argument('--field', + choices=('sphere', 'plane'), + default='sphere', + help='Vertex field to sample') + ps.add_argument( + '--center', + nargs='+', + default=None, + help='Sphere center or point on plane, space- or comma-separated. Defaults to mesh center') + ps.add_argument( + '--radius', + type=float, + default=None, + help='Sphere/circle radius. Defaults to one quarter of the shortest mesh extent') + ps.add_argument( + '--normal', + nargs='+', + default=None, + help='Plane normal, space- or comma-separated. Defaults to +z in 3D or +y in 2D') + ps.add_argument('--fieldName', type=str, default='fcn', help='Output vertex field name') + ps.add_argument('--topologyName', type=str, default='mesh', help='Output topology name') + ps.add_argument('--coordsetName', type=str, default='coords', help='Output coordset name') + ps.add_argument('--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) + opts.ml = parse_component_list(opts.ml, float) + opts.mu = parse_component_list(opts.mu, float) + opts.ms = parse_component_list(opts.ms, int) + if opts.center is not None: + opts.center = parse_component_list(opts.center, float) + if opts.normal is not None: + opts.normal = parse_component_list(opts.normal, float) + return opts + + +def validated_mesh_options(opts): + dim = len(opts.ms) + if dim not in (2, 3) or len(opts.ml) != dim or len(opts.mu) != dim: + raise RuntimeError('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') + + mesh_size = np.array(opts.ms, dtype=np.int64) + mesh_lower = np.array(opts.ml, dtype=np.float64) + mesh_upper = np.array(opts.mu, dtype=np.float64) + 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') + + center = np.array(opts.center if opts.center is not None else 0.5 * (mesh_lower + mesh_upper), + dtype=np.float64) + if len(center) != dim: + raise RuntimeError(f'center must have {dim} components') + + radius = opts.radius if opts.radius is not None else 0.25 * np.min(mesh_extent) + + normal = np.array(opts.normal if opts.normal is not None else ((0., 1.) if dim == 2 else + (0., 0., 1.)), + dtype=np.float64) + if len(normal) != dim: + raise RuntimeError(f'normal must have {dim} components') + normal_norm = np.linalg.norm(normal) + if normal_norm == 0.0: + raise RuntimeError('normal must be nonzero') + normal = normal / normal_norm + + return { + 'dim': dim, + 'mesh_size': mesh_size, + 'mesh_lower': mesh_lower, + 'mesh_extent': mesh_extent, + 'center': center, + 'radius': radius, + 'normal': normal, + } + + +def sample_field(pt, field_kind, center, radius, normal): + if field_kind == 'sphere': + return np.linalg.norm(pt - center) - radius + return np.dot(pt - center, normal) + + +def node_index(mesh_size, i, j, k=0): + ni = mesh_size[0] + 1 + nj = mesh_size[1] + 1 + return i + j * ni + k * ni * nj + + +def generate_coordset(mesh, opts, context): + dim = context['dim'] + mesh_size = context['mesh_size'] + mesh_lower = context['mesh_lower'] + mesh_extent = context['mesh_extent'] + + node_counts = mesh_size + 1 + num_nodes = int(np.prod(node_counts)) + coords = np.empty((num_nodes, dim), dtype=np.float64) + + idx = 0 + if dim == 2: + for j in range(node_counts[1]): + for i in range(node_counts[0]): + logical = np.array((i, j), dtype=np.float64) / mesh_size + coords[idx, :] = mesh_lower + logical * mesh_extent + idx += 1 + else: + for k in range(node_counts[2]): + for j in range(node_counts[1]): + for i in range(node_counts[0]): + logical = np.array((i, j, k), dtype=np.float64) / mesh_size + coords[idx, :] = mesh_lower + logical * mesh_extent + idx += 1 + + coordset = mesh[f'coordsets/{opts.coordsetName}'] + coordset['type'] = 'explicit' + coordset['values/x'].set(coords[:, 0]) + coordset['values/y'].set(coords[:, 1]) + if dim == 3: + coordset['values/z'].set(coords[:, 2]) + + return coords + + +def generate_topology(mesh, opts, context): + dim = context['dim'] + mesh_size = context['mesh_size'] + + topo = mesh[f'topologies/{opts.topologyName}'] + topo['coordset'] = opts.coordsetName + if opts.topology == 'structured': + topo['type'] = 'structured' + topo['elements/dims/i'] = int(mesh_size[0]) + topo['elements/dims/j'] = int(mesh_size[1]) + if dim == 3: + topo['elements/dims/k'] = int(mesh_size[2]) + return + + topo['type'] = 'unstructured' + topo['elements/shape'] = 'quad' if dim == 2 else 'hex' + connectivity = [] + if dim == 2: + for j in range(mesh_size[1]): + for i in range(mesh_size[0]): + connectivity.extend([ + node_index(mesh_size, i, j), + node_index(mesh_size, i + 1, j), + node_index(mesh_size, i + 1, j + 1), + node_index(mesh_size, i, j + 1), + ]) + else: + for k in range(mesh_size[2]): + for j in range(mesh_size[1]): + for i in range(mesh_size[0]): + connectivity.extend([ + node_index(mesh_size, i, j, k), + node_index(mesh_size, i + 1, j, k), + node_index(mesh_size, i + 1, j + 1, k), + node_index(mesh_size, i, j + 1, k), + node_index(mesh_size, i, j, k + 1), + node_index(mesh_size, i + 1, j, k + 1), + node_index(mesh_size, i + 1, j + 1, k + 1), + node_index(mesh_size, i, j + 1, k + 1), + ]) + topo['elements/connectivity'].set(np.array(connectivity, dtype=np.int64)) + + +def generate_fields(mesh, opts, context, coords): + values = np.empty(coords.shape[0], dtype=np.float64) + for idx, pt in enumerate(coords): + values[idx] = sample_field(pt, opts.field, context['center'], context['radius'], + context['normal']) + + field = mesh[f'fields/{opts.fieldName}'] + field['topology'] = opts.topologyName + field['association'] = 'vertex' + field['values'].set(values) + + +def generate_mesh(opts): + context = validated_mesh_options(opts) + mesh = conduit.Node() + + coords = generate_coordset(mesh, opts, context) + generate_topology(mesh, opts, context) + generate_fields(mesh, opts, context, coords) + mesh['state/domain_id'] = 0 + + return mesh + + +def main(): + opts = parse_args() + mesh = generate_mesh(opts) + + info = conduit.Node() + if not conduit.blueprint.mesh.verify(mesh, info): + print("Mesh failed blueprint verification. Info:") + print(info) + return 2 + + if opts.verbose: + print(mesh) + + conduit.relay.io.blueprint.save_mesh(mesh, opts.output, "hdf5") + print(f'Wrote mesh {opts.output}') + return 0 + + +if __name__ == '__main__': + exit(main()) From 6f39126dbdc4aad279e135f3ff19cda4fef6768c Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Tue, 14 Jul 2026 15:28:51 -0700 Subject: [PATCH 11/36] Refactors python script that generates multidomain blueprint meshes for MC --- src/tools/gen-multidom-structured-mesh.py | 439 +++++++++++++--------- 1 file changed, 270 insertions(+), 169 deletions(-) diff --git a/src/tools/gen-multidom-structured-mesh.py b/src/tools/gen-multidom-structured-mesh.py index b41dc79f3a..90c052782d 100755 --- a/src/tools/gen-multidom-structured-mesh.py +++ b/src/tools/gen-multidom-structured-mesh.py @@ -1,203 +1,304 @@ #!/usr/bin/env python3 -# gen-multidom-structured-mesh.py -# Write a simple multidomain structured blueprint mesh for testing. - +# 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 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. +# +# The generated Blueprint hierarchy is: +# +# ├── (or list children when --useList is set) +# │ ├── topologies +# │ │ └── mesh +# │ │ ├─• type == "structured" +# │ │ ├─• coordset == "coords" +# │ │ └── elements +# │ │ └── dims +# │ │ ├─• i +# │ │ ├─• j +# │ │ └─• [k] +# │ ├── coordsets +# │ │ └── coords +# │ │ ├─• type == "explicit" +# │ │ └── values (i-fastest node ordering, with ghost padding for --strided) +# │ │ ├─• x +# │ │ ├─• y +# │ │ └─• [z] +# │ └── fields +# │ └── field +# │ ├─• association == "element" +# │ ├─• 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}\nMake sure your PYTHONPATH includes /path/to/conduit/install/python-modules\n' + 'Conduit must be configured with python and hdf5.\n' + 'Alternatively, you can use the convenience script\n' + '/path/to/axom_build_dir/bin/run_python_with_axom.sh\n' + 'that includes Conduit in PYTHONPATH.') 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 structured 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('-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] + + return { + '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) - coordArrayLens = domLens + 1 + npnl + npnr - #print(f'coordArrayLens={coordArrayLens}') + assert (dom['topologies/mesh/type'] == 'structured') + assert (len(start_coord) >= ndim) + assert (len(domain_physical_size) >= ndim) + + 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] - 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 + 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 -domType = 'structured' +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' -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) +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'] -domPhysicalSize = (meshUpper - meshLower) / domCounts[:dim] -cellPhysicalSize = (meshUpper - meshLower) / meshSize + dom = domain_node(md_mesh, opts, di, dj, dk) -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}') + 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) -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}') + 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) + + +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']}") + + 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, "hdf5") + print(f'Wrote mesh {opts.output}') + return 0 + + +if __name__ == '__main__': + exit(main()) From 4895862ab2264fe8151827f1fc4a4471c618f0bb Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Tue, 14 Jul 2026 18:00:47 -0700 Subject: [PATCH 12/36] quest: Fixes indexing striding for MC example Work for bump/legacy, structured/unstructured, single/multi domain. --- src/axom/quest/MarchingCubes.cpp | 8 +- .../quest/detail/MarchingCubesBumpImpl.hpp | 69 +++ src/axom/quest/examples/CMakeLists.txt | 8 +- .../examples/quest_marching_cubes_example.cpp | 396 +++++++++++++++++- .../quest/tests/quest_marching_cubes_bump.cpp | 9 +- 5 files changed, 462 insertions(+), 28 deletions(-) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index f17f1a0784..c24638b543 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -53,7 +53,13 @@ void MarchingCubes::setMesh(const conduit::Node& bpMesh, const std::string& maskField) { const conduit::Node* mdMesh = &bpMesh; - if(conduit::blueprint::mesh::is_multi_domain(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(); } diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 30e77b7f98..30d1ba2bc8 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -50,6 +50,8 @@ // bump extraction + views #include "axom/bump/extraction/CutField.hpp" +#include "axom/bump/extraction/FieldIntersector.hpp" +#include "axom/bump/SelectedZones.hpp" #include "axom/bump/views/dispatch_coordset.hpp" #include "axom/bump/views/dispatch_topology.hpp" #include "axom/bump/views/Shapes.hpp" @@ -204,6 +206,11 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase 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); @@ -508,6 +515,46 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase } } + template + bool hasCrossingZones(const TopologyView& topologyView, + const CoordsetView& coordsetView, + const conduit::Node& n_topo, + const conduit::Node& n_coords, + const conduit::Node& n_fields, + const conduit::Node& n_options) const + { + 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::for_all( + selectedZonesView.size(), + AXOM_LAMBDA(axom::IndexType selectedIndex) { + const auto zoneIndex = selectedZonesView[selectedIndex]; + const auto zone = topologyView.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}; + crossingCount += (caseNumber != 0 && caseNumber != allPositive) ? 1 : 0; + }); + + return crossingCount.get() > 0; + } + /*! * @brief Instantiate CutField for (DIM, ExecSpace, this domain's view types) * and run it, storing the Blueprint output. @@ -547,6 +594,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase // 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) { @@ -580,12 +628,29 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase iso.setAllocatorID(m_allocatorID); axom::Array selectedZones; addMaskSelectedZonesOption(topologyView, n_options, selectedZones); + if(!hasCrossingZones(topologyView, + coordsetView, + n_topo, + n_coords, + m_dom->fetch_existing("fields"), + n_options)) + { + m_facetCount = 0; + return; + } + conduit::Node execOptions; axom::bump::utilities::copy(execOptions, n_options, m_allocatorID); iso.execute(*m_dom, execOptions, n_out); + extracted = true; }); }); + if(!extracted) + { + 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. @@ -660,6 +725,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase void fillLegacyOutputBuffers() { SLIC_ASSERT(m_output != nullptr); + if(m_facetCount == 0) + { + return; + } // Build the legacy field-stride remap only when the user asked for the legacy numbering AND the input is structured // (unstructured has no canonical field stride order; we leave the remap empty -> pass-through). diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index ab789f9d50..5092ed0036 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -580,10 +580,10 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) endforeach() endforeach() - # Exercise the example's bump-backend CLI path without duplicating the - # full legacy matrix. Use one 2D and one 3D structured mesh; the - # dedicated quest_marching_cubes_bump test covers unstructured input. - set(_bump_meshes "mdmesh.2x1" "mdmesh.2x2x1") + # 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") diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 5279aa7a55..45075dabc6 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -356,16 +356,29 @@ struct BlueprintStructuredMesh public: explicit BlueprintStructuredMesh(const std::string& meshFile, const std::string& topologyName, + bool compactStridedStructured = false, bool verboseOutput = false) : _topologyName(topologyName) , _topologyPath("topologies/" + topologyName) + , _compactStridedStructured(compactStridedStructured) { readBlueprintMesh(meshFile); - for(int d = 0; d < _mdMesh.number_of_children(); ++d) + + if(verboseOutput) { - auto dl = domainLengths(d); - SLIC_INFO_IF(verboseOutput, 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(); } @@ -412,6 +425,7 @@ struct BlueprintStructuredMesh 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 '{}'", @@ -420,7 +434,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()); } } @@ -434,13 +448,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 @@ -457,13 +476,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 @@ -479,6 +503,27 @@ struct BlueprintStructuredMesh int dimension() const { return _ndims; } + 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"); + } + + bool useFlatFields(axom::IndexType domId) const + { + return (_domCount == 1 && !isStridedStructured(domId)) || isUnstructured(domId) || + domain(domId).has_path("fields/fcn") || (_compactedStridedStructured && isStructured(domId)); + } + /*! * @return largest mesh spacing. * @@ -514,11 +559,20 @@ struct BlueprintStructuredMesh 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; @@ -543,6 +597,109 @@ 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; + } + + 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 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(); + const std::string shape = topo.fetch_existing("elements/shape").as_string(); + + const axom::IndexType cornersPerCell = shape == "hex" ? 8 : shape == "quad" ? 4 : 0; + SLIC_ASSERT_MSG(cornersPerCell != 0, + axom::fmt::format("Unsupported unstructured shape '{}'.", shape)); + + const int edgePairsHex[12][2] = + {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}}; + const int edgePairsQuad[4][2] = {{0, 1}, {1, 2}, {2, 3}, {3, 0}}; + + double maxLen = 0.0; + const axom::IndexType numCells = + static_cast(conn.number_of_elements()) / cornersPerCell; + for(axom::IndexType cell = 0; cell < numCells; ++cell) + { + const int edgeCount = shape == "hex" ? 12 : 4; + for(int e = 0; e < edgeCount; ++e) + { + const int aLocal = shape == "hex" ? edgePairsHex[e][0] : edgePairsQuad[e][0]; + const int bLocal = shape == "hex" ? edgePairsHex[e][1] : edgePairsQuad[e][1]; + const axom::IndexType a = static_cast(conn[cell * cornersPerCell + aLocal]); + const axom::IndexType b = static_cast(conn[cell * cornersPerCell + bLocal]); + 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; + } + /// Checks whether the blueprint is valid and prints diagnostics bool isValid() const { @@ -572,19 +729,140 @@ struct BlueprintStructuredMesh conduit::Node _mdMesh; axom::IndexType _domCount; bool _coordsAreStrided = false; + bool _compactedStridedStructured = false; const std::string _topologyName; const std::string _topologyPath; + bool _compactStridedStructured = false; std::string _coordsetPath; double _maxSpacing = -1.0; + 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; + } + + void compactStridedStructuredDomains() + { + bool compactedAny = false; + for(axom::IndexType domId = 0; domId < _domCount; ++domId) + { + if(!isStructured(domId)) + { + continue; + } + + conduit::Node& dom = domain(domId); + conduit::Node& dimsNode = dom.fetch_existing(_topologyPath + "/elements/dims"); + const bool hasOffsets = dimsNode.has_child("offsets"); + const bool hasStrides = dimsNode.has_child("strides"); + if(!hasOffsets && !hasStrides) + { + continue; + } + SLIC_ASSERT_MSG(hasOffsets && hasStrides, + "Expected strided structured topology to define both offsets and strides."); + + axom::StackArray nodeShape {{1, 1, 1}}; + axom::StackArray offsets {{0, 0, 0}}; + axom::StackArray strides {{1, 1, 1}}; + for(int dim = 0; dim < _ndims; ++dim) + { + nodeShape[dim] = dimValue(dimsNode, dim) + 1; + offsets[dim] = dimValue(dimsNode.fetch_existing("offsets"), dim); + strides[dim] = dimValue(dimsNode.fetch_existing("strides"), dim); + } + + const axom::IndexType compactNodeCount = nodeShape[0] * nodeShape[1] * nodeShape[2]; + const conduit::Node& coordValues = dom.fetch_existing(_coordsetPath + "/values"); + + auto compactComponent = [&](const std::string& componentName) { + std::vector compactValues(static_cast(compactNodeCount)); + const auto source = coordValues.fetch_existing(componentName).as_double_accessor(); + 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 sourceIdx = (i + offsets[0]) * strides[0] + + (j + offsets[1]) * strides[1] + (k + offsets[2]) * strides[2]; + const axom::IndexType destIdx = i + nodeShape[0] * (j + nodeShape[1] * k); + compactValues[static_cast(destIdx)] = source[sourceIdx]; + } + } + } + return compactValues; + }; + + std::vector xs = compactComponent("x"); + std::vector ys = compactComponent("y"); + std::vector zs; + if(_ndims == 3) + { + zs = compactComponent("z"); + } + + conduit::Node& compactCoordValues = dom.fetch_existing(_coordsetPath + "/values"); + compactCoordValues["x"].set(xs); + compactCoordValues["y"].set(ys); + if(_ndims == 3) + { + compactCoordValues["z"].set(zs); + } + + dimsNode.remove("offsets"); + dimsNode.remove("strides"); + if(dom.has_child("fields")) + { + dom.remove("fields"); + } + compactedAny = true; + } + + if(compactedAny) + { + _coordsAreStrided = false; + _compactedStridedStructured = true; + } + } + //! @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); _mdMesh.reset(); - loadBlueprintMesh(meshFilename, _mdMesh); - SLIC_ASSERT(conduit::blueprint::mesh::is_multi_domain(_mdMesh)); + if(loadedMesh.has_path(_topologyPath)) + { + _mdMesh.append().set(loadedMesh); + } + else 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) @@ -594,8 +872,13 @@ 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"); + _coordsAreStrided = false; + for(axom::IndexType domId = 0; domId < _domCount; ++domId) + { + _coordsAreStrided = _coordsAreStrided || + (isStructured(domId) && + domain(domId).fetch_existing(_topologyPath + "/elements/dims").has_child("strides")); + } const conduit::Node coordsetNode = _mdMesh[0].fetch_existing(_coordsetPath); _ndims = conduit::blueprint::mesh::coordset::dims(coordsetNode); } @@ -604,6 +887,11 @@ struct BlueprintStructuredMesh #endif SLIC_ASSERT(_ndims > 0); + if(_compactStridedStructured && _coordsAreStrided) + { + compactStridedStructuredDomains(); + } + SLIC_ASSERT(isValid()); } }; // BlueprintStructuredMesh @@ -1016,6 +1304,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. @@ -1028,6 +1322,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 = @@ -1040,6 +1339,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, @@ -1109,6 +1438,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(); @@ -1132,6 +1467,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) @@ -1810,7 +2163,10 @@ int main(int argc, char** argv) // Load computational mesh. //--------------------------------------------------------------------------- AXOM_ANNOTATE_BEGIN("load mesh"); - BlueprintStructuredMesh computationalMesh(params.meshFile, "mesh", params.isVerbose()); + BlueprintStructuredMesh computationalMesh(params.meshFile, + "mesh", + params.useBumpBackend, + params.isVerbose()); AXOM_ANNOTATE_END("load mesh"); SLIC_INFO_IF(params.isVerbose(), diff --git a/src/axom/quest/tests/quest_marching_cubes_bump.cpp b/src/axom/quest/tests/quest_marching_cubes_bump.cpp index 174fc96ab9..c10de76a51 100644 --- a/src/axom/quest/tests/quest_marching_cubes_bump.cpp +++ b/src/axom/quest/tests/quest_marching_cubes_bump.cpp @@ -42,13 +42,14 @@ #endif #include "axom/core.hpp" -#include "axom/bump/utilities/conduit_memory.hpp" +#include "axom/slic.hpp" #include "axom/primal.hpp" -#include "axom/quest/MarchingCubes.hpp" -#include "axom/quest/util/mesh_helpers.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" @@ -935,6 +936,8 @@ TEST(quest_marching_cubes_bump, edge_manifold_helper_selftest) int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); + axom::slic::SimpleLogger logger; + int result = RUN_ALL_TESTS(); return result; } From 2d5d67fdfdd01a6d8b361ed89e0fa84acc2d3cfc Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Tue, 14 Jul 2026 19:14:49 -0700 Subject: [PATCH 13/36] quest: Optimization for bump-based MC -- only extract from non-empty cells After checking the labels, we know which zones contributes cells to the extracted surface. Store those in a list to avoid extra work. --- .../quest/detail/MarchingCubesBumpImpl.hpp | 75 ++++++++++++++----- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 30d1ba2bc8..1c008d221b 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -516,13 +516,15 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase } template - bool hasCrossingZones(const TopologyView& topologyView, - const CoordsetView& coordsetView, - const conduit::Node& n_topo, - const conduit::Node& n_coords, - const conduit::Node& n_fields, - const conduit::Node& n_options) const + 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(), @@ -541,18 +543,46 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase 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 = topologyView.zone(zoneIndex); + 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}; - crossingCount += (caseNumber != 0 && caseNumber != allPositive) ? 1 : 0; + const axom::IndexType crosses = (caseNumber != 0 && caseNumber != allPositive) ? 1 : 0; + crossingFlagsView[selectedIndex] = crosses; + crossingCount += crosses; }); - return crossingCount.get() > 0; + 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; } /*! @@ -587,6 +617,9 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase // Ask bump to record each output facet's originating input zone, which we // map onto the legacy "parent cell id" output. n_options["originalElementsField"] = "originalElements"; + // 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; @@ -628,12 +661,13 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase iso.setAllocatorID(m_allocatorID); axom::Array selectedZones; addMaskSelectedZonesOption(topologyView, n_options, selectedZones); - if(!hasCrossingZones(topologyView, - coordsetView, - n_topo, - n_coords, - m_dom->fetch_existing("fields"), - n_options)) + if(!attachCrossingSelectedZonesOption(topologyView, + coordsetView, + n_topo, + n_coords, + m_dom->fetch_existing("fields"), + n_options, + selectedZones)) { m_facetCount = 0; return; @@ -641,7 +675,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase conduit::Node execOptions; axom::bump::utilities::copy(execOptions, n_options, m_allocatorID); - iso.execute(*m_dom, execOptions, n_out); + { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::CutField::execute"); + iso.execute(*m_dom, execOptions, n_out); + } extracted = true; }); }); @@ -654,7 +691,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase // 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. - m_facetCount = computeTriangulatedFacetCount(n_out); + { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::computeTriangulatedFacetCount"); + m_facetCount = computeTriangulatedFacetCount(n_out); + } // For the opt-in legacyFieldOrder numbering on structured input, capture the logical cell dims // and the function field's stride order so the output adaptor can remap bump's i-fastest zone ids back to the legacy ordering. @@ -724,6 +764,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase */ void fillLegacyOutputBuffers() { + AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::fillLegacyOutputBuffers"); SLIC_ASSERT(m_output != nullptr); if(m_facetCount == 0) { From 4fa3e05fdf66f53ef1a67f5a7f416938be36a9da Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Tue, 14 Jul 2026 20:06:41 -0700 Subject: [PATCH 14/36] quest: Optimization for bump-backed MC Adds faster path for edge labelings on structured meshes. Also reserves the hash map size in bump::Unique. --- src/axom/bump/Unique.hpp | 19 +-- .../quest/detail/MarchingCubesBumpImpl.hpp | 115 ++++++++++++++++-- 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/src/axom/bump/Unique.hpp b/src/axom/bump/Unique.hpp index 2fb2cbfa66..aa81c11f7c 100644 --- a/src/axom/bump/Unique.hpp +++ b/src/axom/bump/Unique.hpp @@ -33,7 +33,7 @@ namespace detail * \param container The container (usually a view) whose data will be printed. */ template -void printContainer(const std::string& name, const ContainerType& container) +void printContainer(const std::string &name, const ContainerType &container) { using value_type = typename ContainerType::value_type; using printed_type = @@ -67,7 +67,7 @@ void printContainer(const std::string& name, const ContainerType& container) * \param container The container whose data will be printed. */ template -void printMap(const std::string& name, const MapType& container, bool printKey) +void printMap(const std::string &name, const MapType &container, bool printKey) { std::cout << name << "=["; for(auto it = container.begin(); it != container.end(); it++) @@ -116,8 +116,8 @@ struct Unique * \note key_orig_view is passed by value so it does not require a local copy to capture it. */ static void execute(const axom::ArrayView keys_orig_view, - axom::Array& skeys, - axom::Array& sindices, + axom::Array &skeys, + axom::Array &sindices, int allocator_id = axom::execution_space::allocatorID()) { const int allocatorID = allocator_id; @@ -211,14 +211,15 @@ struct Unique * \param[out] sindices An array of indices that indicate where in the original view the keys came from. * */ - static void execute(const axom::ArrayView& keys_orig_view, - axom::Array& skeys, - axom::Array& sindices, + static void execute(const axom::ArrayView &keys_orig_view, + axom::Array &skeys, + axom::Array &sindices, int allocator_id = axom::execution_space::allocatorID()) { // 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]; @@ -235,8 +236,8 @@ struct Unique // Sort the vector by the keys. std::sort(unique_vector.begin(), unique_vector.end(), - [](const std::pair& a, - const std::pair& b) { return a.first < b.first; }); + [](const std::pair &a, + const std::pair &b) { return a.first < b.first; }); // Allocate the output arrays and populate them const axom::IndexType newsize = unique_vector.size(); diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 1c008d221b..1cde86180e 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -585,6 +585,100 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase return crossingCountValue > 0; } + bool attachStructuredCrossingSelectedZonesOption(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(); + axom::Array crossingFlags(nZones, nZones, m_allocatorID); + auto crossingFlagsView = crossingFlags.view(); + + const double contourVal = m_contourVal; + const int maskVal = m_maskVal; + axom::ReduceSum crossingCount(0); + axom::for_all( + nZones, + AXOM_LAMBDA(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 = fcnView(idx[0], idx[1]) > contourVal; + const bool p1 = fcnView(idx[0] + 1, idx[1]) > contourVal; + const bool p2 = fcnView(idx[0] + 1, idx[1] + 1) > contourVal; + const bool p3 = fcnView(idx[0], idx[1] + 1) > contourVal; + hasPositive = p0 || p1 || p2 || p3; + hasNonPositive = !p0 || !p1 || !p2 || !p3; + } + else + { + const bool p0 = fcnView(idx[0], idx[1], idx[2]) > contourVal; + const bool p1 = fcnView(idx[0] + 1, idx[1], idx[2]) > contourVal; + const bool p2 = fcnView(idx[0], idx[1] + 1, idx[2]) > contourVal; + const bool p3 = fcnView(idx[0] + 1, idx[1] + 1, idx[2]) > contourVal; + const bool p4 = fcnView(idx[0], idx[1], idx[2] + 1) > contourVal; + const bool p5 = fcnView(idx[0] + 1, idx[1], idx[2] + 1) > contourVal; + const bool p6 = fcnView(idx[0], idx[1] + 1, idx[2] + 1) > contourVal; + const bool p7 = fcnView(idx[0] + 1, idx[1] + 1, idx[2] + 1) > contourVal; + 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. @@ -660,14 +754,19 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase Cut iso(topologyView, coordsetView); iso.setAllocatorID(m_allocatorID); axom::Array selectedZones; - addMaskSelectedZonesOption(topologyView, n_options, selectedZones); - if(!attachCrossingSelectedZonesOption(topologyView, - coordsetView, - n_topo, - n_coords, - m_dom->fetch_existing("fields"), - n_options, - selectedZones)) + const bool hasCrossingZones = m_isStructured + ? attachStructuredCrossingSelectedZonesOption(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; From efdfa4f30c070f4d22d6f89ac94633adbe3bafbb Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Tue, 14 Jul 2026 20:47:19 -0700 Subject: [PATCH 15/36] quest: More speedups for bump-backed MC on structured meshes w/ SEQ policy --- .../quest/detail/MarchingCubesBumpImpl.hpp | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 1cde86180e..0496731c29 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -64,6 +64,7 @@ #include #include +#include #include namespace axom::quest::detail::marching_cubes @@ -601,6 +602,66 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase 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) + { + crossingZones = axom::Array(0, 0, m_allocatorID); + crossingZones.reserve(nZones); + + for(axom::IndexType zoneIndex = 0; zoneIndex < nZones; ++zoneIndex) + { + const auto idx = topoMap.toMultiIndex(zoneIndex); + bool useZone = maskView.empty(); + if(!useZone) + { + if constexpr(DIM == 2) + { + useZone = (maskView(idx[0], idx[1]) == m_maskVal); + } + else + { + useZone = (maskView(idx[0], idx[1], idx[2]) == m_maskVal); + } + } + + bool hasPositive = false; + bool hasNonPositive = false; + if(useZone) + { + if constexpr(DIM == 2) + { + const bool p0 = fcnView(idx[0], idx[1]) > m_contourVal; + const bool p1 = fcnView(idx[0] + 1, idx[1]) > m_contourVal; + const bool p2 = fcnView(idx[0] + 1, idx[1] + 1) > m_contourVal; + const bool p3 = fcnView(idx[0], idx[1] + 1) > m_contourVal; + hasPositive = p0 || p1 || p2 || p3; + hasNonPositive = !p0 || !p1 || !p2 || !p3; + } + else + { + const bool p0 = fcnView(idx[0], idx[1], idx[2]) > m_contourVal; + const bool p1 = fcnView(idx[0] + 1, idx[1], idx[2]) > m_contourVal; + const bool p2 = fcnView(idx[0], idx[1] + 1, idx[2]) > m_contourVal; + const bool p3 = fcnView(idx[0] + 1, idx[1] + 1, idx[2]) > m_contourVal; + const bool p4 = fcnView(idx[0], idx[1], idx[2] + 1) > m_contourVal; + const bool p5 = fcnView(idx[0] + 1, idx[1], idx[2] + 1) > m_contourVal; + const bool p6 = fcnView(idx[0], idx[1] + 1, idx[2] + 1) > m_contourVal; + const bool p7 = fcnView(idx[0] + 1, idx[1] + 1, idx[2] + 1) > m_contourVal; + hasPositive = p0 || p1 || p2 || p3 || p4 || p5 || p6 || p7; + hasNonPositive = !p0 || !p1 || !p2 || !p3 || !p4 || !p5 || !p6 || !p7; + } + } + + if(hasPositive && hasNonPositive) + { + crossingZones.push_back(zoneIndex); + } + } + + attachSelectedZonesOption(n_options, crossingZones); + return !crossingZones.empty(); + } + axom::Array crossingFlags(nZones, nZones, m_allocatorID); auto crossingFlagsView = crossingFlags.view(); From 0097268391a1c22bb4cc5564da7daea16a4f2f1e Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Wed, 15 Jul 2026 11:07:43 -0700 Subject: [PATCH 16/36] Adds check to MC example that mesh and function dimensionality agree --- .../examples/quest_marching_cubes_example.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 45075dabc6..b31041d918 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -181,8 +181,7 @@ struct Input 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("-s,--fields-file", fieldsFile) @@ -271,8 +270,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()) @@ -2162,6 +2160,7 @@ int main(int argc, char** argv) //--------------------------------------------------------------------------- // Load computational mesh. //--------------------------------------------------------------------------- + AXOM_ANNOTATE_BEGIN("load mesh"); BlueprintStructuredMesh computationalMesh(params.meshFile, "mesh", @@ -2169,6 +2168,14 @@ int main(int argc, char** argv) 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(), From 5074a349163e56bbde3ff137d94f21754dd41ac1 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Wed, 15 Jul 2026 15:55:18 -0700 Subject: [PATCH 17/36] quest: Fixes cuda build of MC code/examples --- .../quest/detail/MarchingCubesBumpAdaptor.hpp | 482 ++++++++++-------- .../quest/detail/MarchingCubesBumpImpl.hpp | 37 +- 2 files changed, 302 insertions(+), 217 deletions(-) diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index ee5e586316..dcad01cc62 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -74,6 +74,7 @@ #include "conduit_node.hpp" #include +#include #include namespace axom::quest::detail::marching_cubes @@ -95,6 +96,25 @@ AXOM_HOST_DEVICE inline axom::IndexType facetsPerZone(axom::IndexType nCorners) 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, @@ -122,22 +142,130 @@ void duplicateElementValuesForTriangulation(conduit::Node& n_values, newValues.set(conduit::DataType(n_values.dtype().id(), outputZoneCount)); bpviews::nodeToArrayViewSame(n_values, newValues, [&](auto inValues, auto outValues) { - 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]; - } - }); + 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. @@ -173,103 +301,15 @@ void triangulateBlueprintMesh(conduit::Node& n_output, int allocatorID) "sizes, and offsets to use the same integer type."); auto triangulateViews = [&](auto sizesView, auto offsetsView, auto connView) { - using ConnectivityType = typename decltype(connView)::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); - n_elems["shapes"].move(newShapes); - n_elems["shape_map"].reset(); - n_elems["shape_map"][bpviews::TriTraits::name()] = bpviews::Tri_ShapeID; + triangulateBlueprintMeshViews(n_output, + n_conn, + n_sizes, + n_offsets, + topologyName, + sizesView, + offsetsView, + connView, + allocatorID); }; #if defined(_WIN32) @@ -281,6 +321,122 @@ void triangulateBlueprintMesh(conduit::Node& n_output, int allocatorID) #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, + axom::ArrayView fieldStrideRemap) +{ + 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(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]; + if(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. + const int allocatorID = axom::execution_space::allocatorID(); + 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); + + // Capture raw views for the kernel. + const bool doRemap = !fieldStrideRemap.empty(); + + // --- 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. + axom::IndexType parentId = static_cast(origView[z]); + if(doRemap) + { + parentId = fieldStrideRemap[parentId]; + } + + // 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. @@ -346,105 +502,17 @@ void adaptCutFieldOutput(const conduit::Node& n_output, "offsets, and originalElements to use the same integer type."); auto adaptViews = [&](auto sizesView, auto offsetsView, auto connView, auto origView) { - 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(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]; - if(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. - const int allocatorID = axom::execution_space::allocatorID(); - 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); - - // Capture raw views for the kernel. - const bool doRemap = !fieldStrideRemap.empty(); - - // --- 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. - axom::IndexType parentId = static_cast(origView[z]); - if(doRemap) - { - parentId = fieldStrideRemap[parentId]; - } - - // 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; - } - }); + adaptCutFieldOutputViews(n_coords, + sizesView, + offsetsView, + connView, + origView, + facetNodeIds, + facetNodeCoords, + facetParentIds, + facetIndexOffset, + nodeIndexOffset, + fieldStrideRemap); }; #if defined(_WIN32) diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 0496731c29..a57167e37f 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -42,6 +42,7 @@ #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" @@ -52,6 +53,7 @@ #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" @@ -69,6 +71,20 @@ 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. * @@ -254,7 +270,9 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase m_facetCount = 0; } +#if !defined(__CUDACC__) private: +#endif /*! @brief Dispatch a coordset view restricted to this implementation's DIM. */ template static void dispatchCoordset(const conduit::Node& n_coords, FuncType&& func) @@ -489,7 +507,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase buildSelectedZonesFromMask( nZones, - AXOM_LAMBDA(axom::IndexType zoneIndex) { + [topoMap, maskView, maskVal] AXOM_HOST_DEVICE(axom::IndexType zoneIndex) { const auto zoneIdx = topoMap.toMultiIndex(zoneIndex); if constexpr(DIM == 2) { @@ -510,7 +528,9 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase "MarchingCubes mask field has fewer values than topology zones."); buildSelectedZonesFromMask( nZones, - AXOM_LAMBDA(axom::IndexType zoneIndex) { return maskView[zoneIndex] == maskVal; }, + [maskView, maskVal] AXOM_HOST_DEVICE(axom::IndexType zoneIndex) { + return maskView[zoneIndex] == maskVal; + }, n_options, selectedZones); } @@ -670,7 +690,8 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase axom::ReduceSum crossingCount(0); axom::for_all( nZones, - AXOM_LAMBDA(axom::IndexType zoneIndex) { + [topoMap, maskView, fcnView, contourVal, maskVal, crossingFlagsView, crossingCount] AXOM_HOST_DEVICE( + axom::IndexType zoneIndex) { const auto idx = topoMap.toMultiIndex(zoneIndex); bool useZone = maskView.empty(); if(!useZone) @@ -900,14 +921,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase if(n_elems.has_child("sizes")) { const conduit::Node& n_sizes = n_elems.fetch_existing("sizes"); - const auto sizes = n_sizes.as_index_t_accessor(); - const conduit::index_t n = sizes.number_of_elements(); axom::IndexType facets = 0; - for(conduit::index_t i = 0; i < n; ++i) - { - const auto p = static_cast(sizes[i]); - facets += (DIM == 3) ? (p >= 3 ? p - 2 : 0) : 1; - } + axom::bump::views::nodeToArrayView(n_sizes, [&](auto sizes) { + facets = computeTriangulatedFacetCountView(sizes); + }); return facets; } From b9093ddd45debce3e6cbff47ae97484ccc2d09fb Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Thu, 16 Jul 2026 01:09:03 -0700 Subject: [PATCH 18/36] quest: More bugfix for MC on cuda --- src/axom/quest/MeshViewUtil.hpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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"); From bfd8287526ab22392202c74d6950213439313e66 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Thu, 27 Aug 2026 19:11:08 -0700 Subject: [PATCH 19/36] Bump: Fix strided structured topology dispatch predicate --- src/axom/bump/tests/bump_views.cpp | 38 +++++++++++--- .../views/dispatch_structured_topology.hpp | 52 +++++++++---------- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/src/axom/bump/tests/bump_views.cpp b/src/axom/bump/tests/bump_views.cpp index 1cb52228ae..71a395aa62 100644 --- a/src/axom/bump/tests/bump_views.cpp +++ b/src/axom/bump/tests/bump_views.cpp @@ -56,7 +56,7 @@ TEST(bump_views, shape2conduitName) //------------------------------------------------------------------------------ template -void compareShapes(const ShapeType& shape1, const VariableShapeType& shape2) +void compareShapes(const ShapeType &shape1, const VariableShapeType &shape2) { using ConnType = typename ShapeType::ConnectivityType; @@ -436,7 +436,7 @@ struct test_structured_topology_view_rectilinear AXOM_LAMBDA(axom::IndexType zoneIndex) { const auto zone = topoView.zone(zoneIndex); axom::IndexType m = -1; - for(const auto& id : zone.getIds()) + for(const auto &id : zone.getIds()) { m = axom::utilities::max(static_cast(id), m); } @@ -457,7 +457,7 @@ struct test_structured_topology_view_rectilinear } } - static void create(conduit::Node& mesh) + static void create(conduit::Node &mesh) { std::vector dims {4, 4}; axom::blueprint::testing::data::braid("rectilinear", dims, mesh); @@ -499,7 +499,7 @@ struct test_strided_structured axom::bump::views::dispatch_explicit_coordset(hostMesh["coordsets/coords"], [&](auto coordsetView) { axom::bump::views::dispatch_structured_topology( hostMesh["topologies/mesh"], - [&](const std::string& AXOM_UNUSED_PARAM(shape), auto topoView) { + [&](const std::string &AXOM_UNUSED_PARAM(shape), auto topoView) { execute(coordsetView, topoView); }); }); @@ -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 @@ -589,7 +615,7 @@ struct test_braid2d_mat struct NoMixedFields { }; - static void test(const std::string& type, const std::string& mattype, const std::string& name) + static void test(const std::string &type, const std::string &mattype, const std::string &name) { namespace utils = axom::bump::utilities; const int allocatorID = axom::execution_space::allocatorID(); @@ -915,7 +941,7 @@ TEST(bump_views, matset_material_dominant_hip) #endif //------------------------------------------------------------------------------ -int main(int argc, char* argv[]) +int main(int argc, char *argv[]) { ::testing::InitGoogleTest(&argc, argv); return TestApp.execute(argc, argv); diff --git a/src/axom/bump/views/dispatch_structured_topology.hpp b/src/axom/bump/views/dispatch_structured_topology.hpp index a329a170e0..2876a982f4 100644 --- a/src/axom/bump/views/dispatch_structured_topology.hpp +++ b/src/axom/bump/views/dispatch_structured_topology.hpp @@ -47,7 +47,7 @@ struct make_strided_structured_topology<3> * \param topo The node containing the topology. * \return The indexing */ - static Indexing indexing(const conduit::Node& topo) + static Indexing indexing(const conduit::Node &topo) { namespace utils = axom::bump::utilities; verify(topo, "topology"); @@ -91,7 +91,7 @@ struct make_strided_structured_topology<3> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } }; /*! @@ -109,7 +109,7 @@ struct make_strided_structured_topology<2> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node& topo) + static Indexing indexing(const conduit::Node &topo) { namespace utils = axom::bump::utilities; verify(topo, "topology"); @@ -146,7 +146,7 @@ struct make_strided_structured_topology<2> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } }; /*! @@ -164,7 +164,7 @@ struct make_strided_structured_topology<1> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node& topo) + static Indexing indexing(const conduit::Node &topo) { namespace utils = axom::bump::utilities; verify(topo, "topology"); @@ -189,7 +189,7 @@ struct make_strided_structured_topology<1> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } }; /*! @@ -214,7 +214,7 @@ struct make_structured_topology<3> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node& topo) + static Indexing indexing(const conduit::Node &topo) { verify(topo, "topology"); LogicalIndex zoneDims; @@ -230,7 +230,7 @@ struct make_structured_topology<3> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } }; /*! @@ -248,7 +248,7 @@ struct make_structured_topology<2> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node& topo) + static Indexing indexing(const conduit::Node &topo) { verify(topo, "topology"); LogicalIndex zoneDims; @@ -262,7 +262,7 @@ struct make_structured_topology<2> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } }; /*! @@ -280,7 +280,7 @@ struct make_structured_topology<1> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node& topo) + static Indexing indexing(const conduit::Node &topo) { verify(topo, "topology"); LogicalIndex zoneDims; @@ -294,7 +294,7 @@ struct make_structured_topology<1> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } }; //------------------------------------------------------------------------------ @@ -306,8 +306,8 @@ namespace internal template struct dispatch_only_structured_topology { - static void execute(const conduit::Node& AXOM_UNUSED_PARAM(topo), - FuncType&& AXOM_UNUSED_PARAM(func)) + static void execute(const conduit::Node &AXOM_UNUSED_PARAM(topo), + FuncType &&AXOM_UNUSED_PARAM(func)) { } }; @@ -324,7 +324,7 @@ struct dispatch_only_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node& topo, FuncType&& func) + static void execute(const conduit::Node &topo, FuncType &&func) { const std::string offsetsKey("elements/dims/offsets"); const std::string stridesKey("elements/dims/strides"); @@ -355,7 +355,7 @@ struct dispatch_only_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node& topo, FuncType&& func) + static void execute(const conduit::Node &topo, FuncType &&func) { const std::string offsetsKey("elements/dims/offsets"); const std::string stridesKey("elements/dims/strides"); @@ -386,7 +386,7 @@ struct dispatch_only_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node& topo, FuncType&& func) + static void execute(const conduit::Node &topo, FuncType &&func) { const std::string offsetsKey("elements/dims/offsets"); const std::string stridesKey("elements/dims/strides"); @@ -411,8 +411,8 @@ struct dispatch_only_structured_topology template struct dispatch_any_structured_topology { - static void execute(const conduit::Node& AXOM_UNUSED_PARAM(topo), - FuncType&& AXOM_UNUSED_PARAM(func)) + static void execute(const conduit::Node &AXOM_UNUSED_PARAM(topo), + FuncType &&AXOM_UNUSED_PARAM(func)) { } }; @@ -429,9 +429,9 @@ struct dispatch_any_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node& topo, FuncType&& func) + 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"); @@ -468,9 +468,9 @@ struct dispatch_any_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node& topo, FuncType&& func) + 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)) @@ -506,7 +506,7 @@ struct dispatch_any_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node& topo, FuncType&& func) + static void execute(const conduit::Node &topo, FuncType &&func) { const std::string offsetsKey("offsets"), stridesKey("strides"); const std::string type = topo.fetch_existing("type").as_string(); @@ -536,7 +536,7 @@ struct dispatch_any_structured_topology * \param func The function to invoke using the view. It should accept a string with the shape name and an auto parameter for the view. */ template -void dispatch_structured_topology(const conduit::Node& topo, FuncType&& func) +void dispatch_structured_topology(const conduit::Node &topo, FuncType &&func) { verify(topo, "topology"); int ndims = 1; @@ -577,7 +577,7 @@ void dispatch_structured_topology(const conduit::Node& topo, FuncType&& func) * \note We try to initialize the topoView for each dimension and share the dispatch. */ template -void dispatch_structured_topologies(const conduit::Node& topo, FuncType&& func) +void dispatch_structured_topologies(const conduit::Node &topo, FuncType &&func) { verify(topo, "topology"); const auto ndims = conduit::blueprint::mesh::utils::topology::dims(topo); From dd84b5aa1875deb98b054c5925c988b226826160 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Thu, 27 Aug 2026 20:04:48 -0700 Subject: [PATCH 20/36] Quest: Reconcile and optimize bump-based Marching Cubes corner classification - Reconcile: Use `<=` for vertex labels, matching native MC - Optimize: Use a flat index for regular grid instead of divisions/mods --- .../quest/detail/MarchingCubesBumpImpl.hpp | 199 +++++++++++++----- 1 file changed, 144 insertions(+), 55 deletions(-) diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index a57167e37f..b5f54256be 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -64,6 +64,9 @@ #include "conduit_node.hpp" #include "conduit_blueprint.hpp" +#include +#include +#include #include #include #include @@ -273,6 +276,17 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase #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) @@ -606,7 +620,16 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase return crossingCountValue > 0; } - bool attachStructuredCrossingSelectedZonesOption(conduit::Node& n_options, + /*! + * @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"); @@ -625,57 +648,105 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase 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); - - for(axom::IndexType zoneIndex = 0; zoneIndex < nZones; ++zoneIndex) { - const auto idx = topoMap.toMultiIndex(zoneIndex); - bool useZone = maskView.empty(); - if(!useZone) - { - if constexpr(DIM == 2) - { - useZone = (maskView(idx[0], idx[1]) == m_maskVal); - } - else + // 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) { - useZone = (maskView(idx[0], idx[1], idx[2]) == m_maskVal); + 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; + } + } } - } + }; - bool hasPositive = false; - bool hasNonPositive = false; - if(useZone) + const axom::IndexType nk = (DIM == 3) ? cellShape[DIM - 1] : 1; + fillPlane(0, 0); + + for(axom::IndexType k = 0; k < nk; ++k) { - if constexpr(DIM == 2) + if constexpr(DIM == 3) { - const bool p0 = fcnView(idx[0], idx[1]) > m_contourVal; - const bool p1 = fcnView(idx[0] + 1, idx[1]) > m_contourVal; - const bool p2 = fcnView(idx[0] + 1, idx[1] + 1) > m_contourVal; - const bool p3 = fcnView(idx[0], idx[1] + 1) > m_contourVal; - hasPositive = p0 || p1 || p2 || p3; - hasNonPositive = !p0 || !p1 || !p2 || !p3; + // Plane k is already in slot (k % 2); fill k+1 into the other slot. + fillPlane((k + 1) % 2, k + 1); } - else + 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 bool p0 = fcnView(idx[0], idx[1], idx[2]) > m_contourVal; - const bool p1 = fcnView(idx[0] + 1, idx[1], idx[2]) > m_contourVal; - const bool p2 = fcnView(idx[0], idx[1] + 1, idx[2]) > m_contourVal; - const bool p3 = fcnView(idx[0] + 1, idx[1] + 1, idx[2]) > m_contourVal; - const bool p4 = fcnView(idx[0], idx[1], idx[2] + 1) > m_contourVal; - const bool p5 = fcnView(idx[0] + 1, idx[1], idx[2] + 1) > m_contourVal; - const bool p6 = fcnView(idx[0], idx[1] + 1, idx[2] + 1) > m_contourVal; - const bool p7 = fcnView(idx[0] + 1, idx[1] + 1, idx[2] + 1) > m_contourVal; - hasPositive = p0 || p1 || p2 || p3 || p4 || p5 || p6 || p7; - hasNonPositive = !p0 || !p1 || !p2 || !p3 || !p4 || !p5 || !p6 || !p7; + 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)); + } + } + } } } - - if(hasPositive && hasNonPositive) - { - crossingZones.push_back(zoneIndex); - } } attachSelectedZonesOption(n_options, crossingZones); @@ -685,12 +756,14 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase axom::Array crossingFlags(nZones, nZones, m_allocatorID); auto crossingFlagsView = crossingFlags.view(); - const double contourVal = m_contourVal; + // 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, contourVal, maskVal, crossingFlagsView, crossingCount] AXOM_HOST_DEVICE( + [topoMap, maskView, fcnView, isoVal, maskVal, crossingFlagsView, crossingCount] AXOM_HOST_DEVICE( axom::IndexType zoneIndex) { const auto idx = topoMap.toMultiIndex(zoneIndex); bool useZone = maskView.empty(); @@ -712,23 +785,27 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase { if constexpr(DIM == 2) { - const bool p0 = fcnView(idx[0], idx[1]) > contourVal; - const bool p1 = fcnView(idx[0] + 1, idx[1]) > contourVal; - const bool p2 = fcnView(idx[0] + 1, idx[1] + 1) > contourVal; - const bool p3 = fcnView(idx[0], idx[1] + 1) > contourVal; + 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 = fcnView(idx[0], idx[1], idx[2]) > contourVal; - const bool p1 = fcnView(idx[0] + 1, idx[1], idx[2]) > contourVal; - const bool p2 = fcnView(idx[0], idx[1] + 1, idx[2]) > contourVal; - const bool p3 = fcnView(idx[0] + 1, idx[1] + 1, idx[2]) > contourVal; - const bool p4 = fcnView(idx[0], idx[1], idx[2] + 1) > contourVal; - const bool p5 = fcnView(idx[0] + 1, idx[1], idx[2] + 1) > contourVal; - const bool p6 = fcnView(idx[0], idx[1] + 1, idx[2] + 1) > contourVal; - const bool p7 = fcnView(idx[0] + 1, idx[1] + 1, idx[2] + 1) > contourVal; + 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; } @@ -835,9 +912,21 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase 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_isStructured - ? attachStructuredCrossingSelectedZonesOption(n_options, selectedZones) + ? attachStructuredCrossingSelectedZonesOption(isoForBump, + n_options, + selectedZones) : [&]() { addMaskSelectedZonesOption(topologyView, n_options, selectedZones); return attachCrossingSelectedZonesOption(topologyView, From 5c018d63d9322d032e5ee23b3e16be92ed1d4269 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Thu, 27 Aug 2026 20:59:14 -0700 Subject: [PATCH 21/36] Quest: Validate Marching Cubes bump input and route to the appropriate topology Adds input guards for float64 being treated as float32, for different striding permutations and for mask-sets on unstructured data. --- src/axom/quest/MarchingCubes.cpp | 10 +- .../quest/detail/MarchingCubesBumpImpl.hpp | 215 ++++++++++++++++-- .../detail/MarchingCubesSingleDomain.cpp | 4 + 3 files changed, 205 insertions(+), 24 deletions(-) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index c24638b543..c9d3c80a4c 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -65,9 +65,13 @@ void MarchingCubes::setMesh(const conduit::Node& bpMesh, } else { - m_singleDomainMesh.reset(); - m_singleDomainMesh.append().set_external(bpMesh); - mdMesh = &m_singleDomainMesh; + // 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; diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index b5f54256be..874e955351 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -141,7 +141,36 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase const conduit::Node& n_topo = dom.fetch_existing(axom::fmt::format("topologies/{}", topologyName)); const std::string topoType = n_topo.fetch_existing("type").as_string(); - m_isStructured = (topoType != "unstructured"); + + // 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(); @@ -162,7 +191,147 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase } } - void setFunctionField(const std::string& fcnFieldName) override { m_fcnFieldName = fcnFieldName; } + /*! + * @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; } @@ -172,10 +341,9 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase * @brief Honor the requested parent-cell-id numbering. * * blueprintZoneId (default): use bump's originalElements directly. - * legacyFieldOrder: for structured input, remap the Blueprint zone index (which bump orders i-fastest, - * independent of memory layout) to the legacy flat index in the function field's stride order. - * For unstructured input this mode is ignored (no canonical "field stride order" exists) - * and the Blueprint zone id is used. + * legacyFieldOrder: for structured+explicit input, remap the Blueprint zone + * index to the legacy flat index in the function field's stride order. + * Other topology types use the Blueprint zone id. */ void setParentCellIdMode(MarchingCubesParentCellIdMode mode) override { @@ -513,8 +681,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase // (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_isStructured) + 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); @@ -537,6 +707,13 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase } 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."); @@ -923,7 +1100,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase n_options["value"] = static_cast(isoForBump); axom::Array selectedZones; - const bool hasCrossingZones = m_isStructured + const bool hasCrossingZones = m_useMeshViewUtilPath ? attachStructuredCrossingSelectedZonesOption(isoForBump, n_options, selectedZones) @@ -968,29 +1145,24 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase // For the opt-in legacyFieldOrder numbering on structured input, capture the logical cell dims // and the function field's stride order so the output adaptor can remap bump's i-fastest zone ids back to the legacy ordering. - if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_isStructured) + // The legacy field-stride numbering is defined by the function field's MDMapping, + // which MeshViewUtil supplies -- so it is available only on structured+explicit meshes. + // For uniform/rectilinear input the request falls back to blueprintZoneId. + if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_useMeshViewUtilPath) { captureStructuredMetadata(); } } /*! - * @brief Populate m_cellDims and m_fieldSlowestDirs from the structured domain, - * for the legacyFieldOrder parent-id remap. - * - * Uses MeshViewUtil to read the logical cell shape and the function field's strides, - * from which an MDMapping yields the slowest->fastest permutation. - * - * NOTE: This path is exercised only when a caller explicitly opts into legacyFieldOrder on structured input; - * it is the part of the bump backend most in need of build/test validation (MeshViewUtil templating x ExecSpace). + * @brief Populate metadata for the legacyFieldOrder parent-id remap. */ void captureStructuredMetadata() { axom::quest::MeshViewUtil mvu(*m_dom, m_topologyName); m_cellDims = mvu.getCellShape(); const auto fcnView = mvu.template getConstFieldView(m_fcnFieldName, false); - // Build an MDMapping from the field strides to extract the stride order. - axom::MDMapping fcnMap(fcnView.strides()); + const axom::MDMapping fcnMap(fcnView.strides()); m_fieldSlowestDirs = fcnMap.slowestDirs(); } @@ -1040,7 +1212,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase // Build the legacy field-stride remap only when the user asked for the legacy numbering AND the input is structured // (unstructured has no canonical field stride order; we leave the remap empty -> pass-through). axom::Array remapHost(0, 0); - if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_isStructured) + if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_useMeshViewUtilPath) { remapHost = buildFieldStrideRemap(m_cellDims, m_fieldSlowestDirs); } @@ -1086,7 +1258,8 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase //! @name Structured metadata, captured only for the legacyFieldOrder remap. //! @{ - bool m_isStructured {false}; + //! @brief Whether the MeshViewUtil fast paths apply (structured + explicit only). + bool m_useMeshViewUtilPath {false}; axom::StackArray m_cellDims {}; axom::StackArray m_fieldSlowestDirs {}; //! @} diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.cpp b/src/axom/quest/detail/MarchingCubesSingleDomain.cpp index ee465ecbf8..8e2b3b31c8 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.cpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.cpp @@ -144,6 +144,10 @@ std::unique_ptr make_impl_leaf( 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 From 071c9b398ff492ba44076cfa8164d8da89a0208c Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Thu, 27 Aug 2026 21:18:11 -0700 Subject: [PATCH 22/36] Quest: Fix bump-based MC output and parent zone naming - Fixes an issue where m_output was adding in cases that did not have any crossings (e.g. isovalue outside of range) - Also fixes assumption that MC always produces triangles (rather than quads/polygons) --- src/axom/quest/MarchingCubes.cpp | 4 ++ .../quest/detail/MarchingCubesBumpAdaptor.hpp | 48 +++++++++++--- .../quest/detail/MarchingCubesBumpImpl.hpp | 63 ++++++++++++++----- 3 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index c9d3c80a4c..77a2e6948d 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -185,6 +185,10 @@ 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(); diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index dcad01cc62..b72a677724 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -79,6 +79,28 @@ 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. * @@ -285,6 +307,10 @@ void triangulateBlueprintMesh(conduit::Node& n_output, int allocatorID) 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(); @@ -332,7 +358,8 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, axom::ArrayView facetParentIds, axom::IndexType facetIndexOffset, axom::IndexType nodeIndexOffset, - axom::ArrayView fieldStrideRemap) + axom::ArrayView fieldStrideRemap, + int objectAllocatorID) { namespace bputils = axom::bump::utilities; @@ -365,7 +392,8 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, // --- 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. - const int allocatorID = axom::execution_space::allocatorID(); + // 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( @@ -453,9 +481,10 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, * @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 fieldStrideRemap If non-null, a precomputed per-input-zone map from - * bump's i-fastest Blueprint zone id to the legacy field-stride flat id. When - * null, originalElements ids are written through unchanged. + * @param fieldStrideRemap If non-null, a precomputed per-input-zone map from bump's + * i-fastest Blueprint zone id to the legacy field-stride flat id. + * When null, originalElements ids are written through unchanged. + * @param objectAllocatorID Allocator used for temporary arrays. * * @pre All output views and \a n_output live in ExecSpace's memory space. */ @@ -467,7 +496,8 @@ void adaptCutFieldOutput(const conduit::Node& n_output, axom::IndexType facetIndexOffset, axom::IndexType nodeIndexOffset, axom::IndexType thisDomainFacetCount, - axom::ArrayView fieldStrideRemap) + axom::ArrayView fieldStrideRemap, + int objectAllocatorID) { namespace bputils = axom::bump::utilities; namespace bpviews = axom::bump::views; @@ -493,7 +523,8 @@ void adaptCutFieldOutput(const conduit::Node& n_output, 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("fields/originalElements/values"); + 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() || @@ -512,7 +543,8 @@ void adaptCutFieldOutput(const conduit::Node& n_output, facetParentIds, facetIndexOffset, nodeIndexOffset, - fieldStrideRemap); + fieldStrideRemap, + objectAllocatorID); }; #if defined(_WIN32) diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 874e955351..ee81164407 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -385,7 +385,11 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase * 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 { runExtraction(); } + void scanCrossings() override + { + m_extractionRan = true; + runExtraction(); + } //! @brief Copy cached bump output into the parent-allocated output buffers. void computeFacets() override { fillLegacyOutputBuffers(); } @@ -410,13 +414,23 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase n_coords.fetch_existing("values/x").dtype().number_of_elements()); } - bool hasContourMeshBlueprint() const override { return m_output != nullptr; } + /*! + * @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_output == nullptr, + 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) { @@ -426,19 +440,24 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase void relinquishContourMeshBlueprint(conduit::Node& bpMesh) override { - SLIC_ERROR_IF(m_output == nullptr, + SLIC_ERROR_IF(!m_extractionRan, "MarchingCubes bump backend has no Blueprint contour output. " "Call computeIsocontour() before requesting it."); bpMesh.reset(); - bpMesh.swap(*m_output); - m_output.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__) @@ -1046,7 +1065,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase 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"] = "originalElements"; + 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()); @@ -1125,6 +1144,14 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase { 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; }); @@ -1132,6 +1159,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase 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; } @@ -1189,11 +1220,11 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase return facets; } - // Fixed-shape output (e.g. all-tri or all-segment): - // infer count from connectivity length / corners-per-element. - const conduit::Node& n_conn = n_elems.fetch_existing("connectivity"); - const auto cornersPerElem = (DIM == 3) ? 3 : 2; // tri or segment - return static_cast(n_conn.dtype().number_of_elements() / cornersPerElem); + 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; } /*! @@ -1203,11 +1234,11 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase void fillLegacyOutputBuffers() { AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::fillLegacyOutputBuffers"); - SLIC_ASSERT(m_output != nullptr); if(m_facetCount == 0) { return; } + SLIC_ASSERT(m_output != nullptr); // Build the legacy field-stride remap only when the user asked for the legacy numbering AND the input is structured // (unstructured has no canonical field stride order; we leave the remap empty -> pass-through). @@ -1233,7 +1264,8 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase m_facetIndexOffset, m_nodeIndexOffset, m_facetCount, - remapView); + remapView, + m_allocatorID); } //! @brief Return the (single) topology name present in a bump output node. @@ -1269,6 +1301,9 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase //! @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 From fa8bb6bf97f2425ee84db31fcec4a09af4a66121 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Thu, 27 Aug 2026 21:22:13 -0700 Subject: [PATCH 23/36] Quest: Documents that MC computeIsocontour does not clear existing counts --- src/axom/quest/MarchingCubes.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index 77a2e6948d..e8c2ca10e6 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -133,6 +133,13 @@ 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()); From 95d307678b62b822b69a2de687053aaa91a7ca4a Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Thu, 27 Aug 2026 21:37:10 -0700 Subject: [PATCH 24/36] Quest: Removes unnecessary parent cell numbering from native to bump It was not being used. --- src/axom/quest/MarchingCubes.cpp | 1 - src/axom/quest/MarchingCubes.hpp | 43 ----------- .../quest/detail/MarchingCubesBumpAdaptor.hpp | 75 +------------------ .../quest/detail/MarchingCubesBumpImpl.hpp | 60 --------------- .../detail/MarchingCubesSingleDomain.hpp | 19 ----- src/axom/quest/examples/CMakeLists.txt | 37 +++++---- .../examples/quest_marching_cubes_example.cpp | 15 ---- 7 files changed, 19 insertions(+), 231 deletions(-) diff --git a/src/axom/quest/MarchingCubes.cpp b/src/axom/quest/MarchingCubes.cpp index e8c2ca10e6..1fd65342aa 100644 --- a/src/axom/quest/MarchingCubes.cpp +++ b/src/axom/quest/MarchingCubes.cpp @@ -149,7 +149,6 @@ void MarchingCubes::computeIsocontour(double contourVal) auto& single = *m_singles[d]; single.setContourValue(contourVal); single.setMaskValue(m_maskVal); - single.setParentCellIdMode(m_parentCellIdMode); single.setRobustnessPolicy(m_robustnessPolicy); single.markCrossings(); single.scanCrossings(); diff --git a/src/axom/quest/MarchingCubes.hpp b/src/axom/quest/MarchingCubes.hpp index 45695e90c4..5a1391ce6a 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -53,32 +53,6 @@ enum class MarchingCubesDataParallelism fullParallel = 2 }; -/*! - * @brief Enum controlling the meaning of the parent-cell ids reported for generated contour facets - * (see MarchingCubes::getContourFacetParents and MarchingCubes::populateContourMesh). - * - * The legacy marching cubes implementation numbered parent cells by their flat index - * in the same row- or column-major ordering as the input scalar function array - * (i.e. following the function field's stride order). - * - * The bump-backed implementation natively numbers cells by their Blueprint zone index. - * For structured input these two numberings coincide only when the field is stored i-fastest; - * otherwise they differ by a stride-order permutation. - * - * This enum lets callers choose which numbering they receive: - * - \c blueprintZoneId (default): report the Blueprint zone index. This is the natural, - * mesh-type-agnostic identifier and the only meaningful choice for unstructured input. - * - \c legacyFieldOrder: reproduce the legacy numbering (flat index in the function field's stride order). - * Provided so existing structured-mesh callers that depend on the historical meaning are unaffected. - * - * @note This option only applies to structured input; for unstructured input the Blueprint zone id is always used. - */ -enum class MarchingCubesParentCellIdMode -{ - blueprintZoneId = 0, - legacyFieldOrder = 1 -}; - /*! * @brief Enum selecting the isosurface case-table / intersector robustness used by the bump backend * @@ -207,20 +181,6 @@ class MarchingCubes */ void setMaskValue(int maskVal) { m_maskVal = maskVal; } - /*! - * @brief Set how parent-cell ids are numbered for generated contour facets. - * @param [in] mode A value from MarchingCubesParentCellIdMode. - * - * The default is MarchingCubesParentCellIdMode::blueprintZoneId. - * See that enum for the meaning of each mode and for why the two modes can differ - * for structured input. Has no effect unless parent-cell ids are requested - * (via getContourFacetParents() or the cellIdField of populateContourMesh()). - * - * @note The legacyFieldOrder mode only affects structured input; - * unstructured input always reports the Blueprint zone id. - */ - void setParentCellIdMode(MarchingCubesParentCellIdMode mode) { m_parentCellIdMode = mode; } - /*! * @brief Select the bump::extraction::CutField backend * (vs. the legacy structured-only marching cubes kernel). @@ -453,9 +413,6 @@ class MarchingCubes int m_maskVal {1}; - //! @brief How to number parent-cell ids of generated facets. - MarchingCubesParentCellIdMode m_parentCellIdMode {MarchingCubesParentCellIdMode::blueprintZoneId}; - //! @brief Whether to use the bump CutField backend (opt-in; default legacy). bool m_useBumpBackend {false}; diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index b72a677724..2b4a9cb801 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -41,8 +41,7 @@ * 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], optionally remapped to - * the legacy field-stride flat order (structured input + legacyFieldOrder). + * 3. Parent id per facet := originalElements[srcZone]. */ #pragma once @@ -62,7 +61,6 @@ #include "axom/core/memory_management.hpp" #include "axom/core/Array.hpp" #include "axom/core/ArrayView.hpp" -#include "axom/core/MDMapping.hpp" #include "axom/core/numerics/floating_point_limits.hpp" #include "axom/slic/interface/slic_macros.hpp" @@ -358,7 +356,6 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, axom::ArrayView facetParentIds, axom::IndexType facetIndexOffset, axom::IndexType nodeIndexOffset, - axom::ArrayView fieldStrideRemap, int objectAllocatorID) { namespace bputils = axom::bump::utilities; @@ -406,9 +403,6 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, auto zoneFacetOffsetsView = zoneFacetOffsets.view(); axom::exclusive_scan(zoneFacetCountsView, zoneFacetOffsetsView); - // Capture raw views for the kernel. - const bool doRemap = !fieldStrideRemap.empty(); - // --- The fan-triangulation kernel ------------------------------------- // One thread per bump zone. Each zone writes facetsPerZone facets; // each facet reuses bump's welded coordset vertex ids. @@ -424,11 +418,7 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, const axom::IndexType connStart = static_cast(offsetsView[z]); // Parent-cell id for every facet of this zone. - axom::IndexType parentId = static_cast(origView[z]); - if(doRemap) - { - parentId = fieldStrideRemap[parentId]; - } + 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]; @@ -481,9 +471,6 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, * @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 fieldStrideRemap If non-null, a precomputed per-input-zone map from bump's - * i-fastest Blueprint zone id to the legacy field-stride flat id. - * When null, originalElements ids are written through unchanged. * @param objectAllocatorID Allocator used for temporary arrays. * * @pre All output views and \a n_output live in ExecSpace's memory space. @@ -496,7 +483,6 @@ void adaptCutFieldOutput(const conduit::Node& n_output, axom::IndexType facetIndexOffset, axom::IndexType nodeIndexOffset, axom::IndexType thisDomainFacetCount, - axom::ArrayView fieldStrideRemap, int objectAllocatorID) { namespace bputils = axom::bump::utilities; @@ -543,7 +529,6 @@ void adaptCutFieldOutput(const conduit::Node& n_output, facetParentIds, facetIndexOffset, nodeIndexOffset, - fieldStrideRemap, objectAllocatorID); }; @@ -557,60 +542,4 @@ void adaptCutFieldOutput(const conduit::Node& n_output, #endif } -/*! - * @brief Build the per-input-zone remap from bump's i-fastest Blueprint zone id - * to the legacy field-stride flat id, for structured input. - * - * bump's StructuredIndexing numbers zones flat = i + j*nx + k*nx*ny (i-fastest), independent of memory layout. - * The legacy parent-cell id is the flat index in the function field's stride order. - * This routine, given the per-dimension cell counts \a cellDims (logical, in i,j,k order) and the function field's - * \a fieldSlowestDirs (the slowest-to-fastest permutation from the field's MDMapping), produces remap[bumpZoneId] = legacyZoneId. - * - * Returns an empty Array when the two orderings coincide (i-fastest field), so callers can skip remapping entirely. - * - * @note Built in host memory then copied to ExecSpace memory by the caller. - */ -template -axom::Array buildFieldStrideRemap( - const axom::StackArray& cellDims, - const axom::StackArray& fieldSlowestDirs) -{ - // Identity stride order is "i fastest" == slowestDirs {DIM-1, ..., 1, 0}. - bool isIFastest = true; - for(int d = 0; d < DIM; ++d) - { - if(fieldSlowestDirs[d] != static_cast(DIM - 1 - d)) - { - isIFastest = false; - break; - } - } - if(isIFastest) - { - return axom::Array(0, 0); // no remap needed - } - - axom::IndexType numZones = 1; - for(int d = 0; d < DIM; ++d) - { - numZones *= cellDims[d]; - } - - // bump mapping: i-fastest. - axom::MDMapping bumpMap(cellDims, axom::ArrayStrideOrder::COLUMN); - // legacy mapping: field stride order via slowestDirs. - axom::MDMapping legacyMap; - legacyMap.initializeShape(cellDims, fieldSlowestDirs); - - axom::Array remap(numZones, numZones); - auto remapView = remap.view(); - // Host loop: enumerate logical multi-indices, map each to both flat ids. - for(axom::IndexType bumpId = 0; bumpId < numZones; ++bumpId) - { - const auto multi = bumpMap.toMultiIndex(bumpId); - remapView[bumpId] = legacyMap.toFlatIndex(multi); - } - return remap; -} - } // namespace axom::quest::detail::marching_cubes diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index ee81164407..5da9c3a794 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -337,19 +337,6 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase void setMaskValue(int maskVal) override { m_maskVal = maskVal; } - /*! - * @brief Honor the requested parent-cell-id numbering. - * - * blueprintZoneId (default): use bump's originalElements directly. - * legacyFieldOrder: for structured+explicit input, remap the Blueprint zone - * index to the legacy flat index in the function field's stride order. - * Other topology types use the Blueprint zone id. - */ - void setParentCellIdMode(MarchingCubesParentCellIdMode mode) override - { - m_parentCellIdMode = mode; - } - /*! * @brief Record the requested robustness policy (Phase 6 seam). * @@ -1173,28 +1160,6 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase AXOM_ANNOTATE_SCOPE("MarchingCubesBumpImpl::computeTriangulatedFacetCount"); m_facetCount = computeTriangulatedFacetCount(n_out); } - - // For the opt-in legacyFieldOrder numbering on structured input, capture the logical cell dims - // and the function field's stride order so the output adaptor can remap bump's i-fastest zone ids back to the legacy ordering. - // The legacy field-stride numbering is defined by the function field's MDMapping, - // which MeshViewUtil supplies -- so it is available only on structured+explicit meshes. - // For uniform/rectilinear input the request falls back to blueprintZoneId. - if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_useMeshViewUtilPath) - { - captureStructuredMetadata(); - } - } - - /*! - * @brief Populate metadata for the legacyFieldOrder parent-id remap. - */ - void captureStructuredMetadata() - { - axom::quest::MeshViewUtil mvu(*m_dom, m_topologyName); - m_cellDims = mvu.getCellShape(); - const auto fcnView = mvu.template getConstFieldView(m_fcnFieldName, false); - const axom::MDMapping fcnMap(fcnView.strides()); - m_fieldSlowestDirs = fcnMap.slowestDirs(); } /*! @@ -1240,23 +1205,6 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase } SLIC_ASSERT(m_output != nullptr); - // Build the legacy field-stride remap only when the user asked for the legacy numbering AND the input is structured - // (unstructured has no canonical field stride order; we leave the remap empty -> pass-through). - axom::Array remapHost(0, 0); - if(m_parentCellIdMode == MarchingCubesParentCellIdMode::legacyFieldOrder && m_useMeshViewUtilPath) - { - remapHost = buildFieldStrideRemap(m_cellDims, m_fieldSlowestDirs); - } - - // Move the (possibly empty) remap into ExecSpace memory for the kernel. - axom::Array remapDevice; - axom::ArrayView remapView; - if(!remapHost.empty()) - { - remapDevice = axom::Array(remapHost, m_allocatorID); - remapView = remapDevice.view(); - } - adaptCutFieldOutput(*m_output, m_facetNodeIds, m_facetNodeCoords, @@ -1264,7 +1212,6 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase m_facetIndexOffset, m_nodeIndexOffset, m_facetCount, - remapView, m_allocatorID); } @@ -1284,17 +1231,10 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase std::string m_fcnFieldName; std::string m_maskFieldName; - //! @brief How to number parent-cell ids of generated facets. - MarchingCubesParentCellIdMode m_parentCellIdMode {MarchingCubesParentCellIdMode::blueprintZoneId}; MarchingCubesRobustnessPolicy m_robustnessPolicy {MarchingCubesRobustnessPolicy::standard}; - //! @name Structured metadata, captured only for the legacyFieldOrder remap. - //! @{ //! @brief Whether the MeshViewUtil fast paths apply (structured + explicit only). bool m_useMeshViewUtilPath {false}; - axom::StackArray m_cellDims {}; - axom::StackArray m_fieldSlowestDirs {}; - //! @} //! @brief Cached bump CutField output (Blueprint mesh). std::unique_ptr m_output; diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp index 64e700ad27..66f45e6f17 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp @@ -109,15 +109,6 @@ class MarchingCubesSingleDomain } } - void setParentCellIdMode(MarchingCubesParentCellIdMode mode) - { - m_parentCellIdMode = mode; - if(m_impl) - { - m_impl->setParentCellIdMode(m_parentCellIdMode); - } - } - void setRobustnessPolicy(MarchingCubesRobustnessPolicy policy) { m_robustnessPolicy = policy; @@ -169,15 +160,6 @@ class MarchingCubesSingleDomain virtual void setContourValue(double contourVal) = 0; virtual void setMaskValue(int maskVal) = 0; - /*! - * @brief Set how parent-cell ids of generated facets are numbered. - * - * Default is a no-op so backends that only ever produce the legacy numbering - * (the structured-only MarchingCubesImpl) need not implement it. - * The bump backend overrides this to honor both numbering modes. - */ - virtual void setParentCellIdMode(MarchingCubesParentCellIdMode) { } - /*! * @brief Set the isosurface robustness policy (bump backend only). * @@ -306,7 +288,6 @@ class MarchingCubesSingleDomain double m_contourVal {0.0}; int m_maskVal {1}; - MarchingCubesParentCellIdMode m_parentCellIdMode {MarchingCubesParentCellIdMode::blueprintZoneId}; MarchingCubesRobustnessPolicy m_robustnessPolicy {MarchingCubesRobustnessPolicy::standard}; std::unique_ptr m_impl; diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index 5092ed0036..78beabd965 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -605,26 +605,23 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) set(_scale 3 3 1.5) endif() - foreach(_parent_mode blueprintZoneId legacyFieldOrder) - set(_test "quest_marching_cubes_bump_run_${_ndim}D_${_pol}_${_parent_mode}_${_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 - --parentCellIdMode ${_parent_mode} - --check-results - NUM_MPI_TASKS ${_nranks} - NUM_OMP_THREADS ${_num_threads}) - set_tests_properties(${_test} PROPERTIES - PASS_REGULAR_EXPRESSION "Contour mesh has .* cells") - endforeach() + 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() diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index b31041d918..6c801d0148 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -107,9 +107,6 @@ struct Input quest::MarchingCubesDataParallelism dataParallelism = quest::MarchingCubesDataParallelism::byPolicy; - quest::MarchingCubesParentCellIdMode parentCellIdMode = - quest::MarchingCubesParentCellIdMode::blueprintZoneId; - // Use the bump CutField backend (supports unstructured quad/hex) vs legacy. bool useBumpBackend {false}; @@ -134,10 +131,6 @@ struct Input {"hybridParallel", quest::MarchingCubesDataParallelism::hybridParallel}, {"fullParallel", quest::MarchingCubesDataParallelism::fullParallel}}; - const std::map s_validParentCellIdModes { - {"blueprintZoneId", quest::MarchingCubesParentCellIdMode::blueprintZoneId}, - {"legacyFieldOrder", quest::MarchingCubesParentCellIdMode::legacyFieldOrder}}; - const std::map s_validRobustnessPolicies { {"standard", quest::MarchingCubesRobustnessPolicy::standard}, {"robust", quest::MarchingCubesRobustnessPolicy::robust}}; @@ -159,13 +152,6 @@ struct Input ->capture_default_str() ->transform(axom::CLI::CheckedTransformer(s_validImplChoices)); - app.add_option("--parentCellIdMode", parentCellIdMode) - ->description( - "How to number parent-cell ids of generated facets: " - "'blueprintZoneId' (default) or 'legacyFieldOrder' (structured only)") - ->capture_default_str() - ->transform(axom::CLI::CheckedTransformer(s_validParentCellIdModes)); - app.add_flag("--useBumpBackend", useBumpBackend) ->description( "Use the bump CutField backend (adds unstructured quad/hex support) " @@ -1136,7 +1122,6 @@ struct ContourTestBase s_allocatorId, m_params.dataParallelism); mcPtr->setUseBumpBackend(m_params.useBumpBackend); - mcPtr->setParentCellIdMode(m_params.parentCellIdMode); mcPtr->setRobustnessPolicy(m_params.robustnessPolicy); mcPtr->setMesh(computationalMesh.asConduitNode(), "mesh", "mask"); initializationTimer.stop(); From 137dda0433d54e57f8492ec50b5581b72e17e3d8 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Sun, 30 Aug 2026 20:20:18 -0700 Subject: [PATCH 25/36] Quest: Adds `if constexpr` in some bump MC functions --- src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index 2b4a9cb801..098378c8a1 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -108,7 +108,7 @@ constexpr const char* kPublicOriginalElementsField = "originalElements"; template AXOM_HOST_DEVICE inline axom::IndexType facetsPerZone(axom::IndexType nCorners) { - if(DIM == 3) + if constexpr(DIM == 3) { return nCorners >= 3 ? (nCorners - 2) : 0; } @@ -297,7 +297,7 @@ void triangulateBlueprintMeshViews(conduit::Node& n_output, template void triangulateBlueprintMesh(conduit::Node& n_output, int allocatorID) { - if(DIM != 3) + if constexpr(DIM != 3) { return; } @@ -366,7 +366,7 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, auto yView = bputils::make_array_view(n_y); // z only in 3D. axom::ArrayView zView; - if(DIM == 3) + if constexpr(DIM == 3) { const conduit::Node& n_z = n_coords.fetch_existing("values/z"); zView = bputils::make_array_view(n_z); @@ -380,7 +380,7 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, AXOM_LAMBDA(axom::IndexType n) { facetNodeCoords(nodeIndexOffset + n, 0) = xView[n]; facetNodeCoords(nodeIndexOffset + n, 1) = yView[n]; - if(DIM == 3) + if constexpr(DIM == 3) { facetNodeCoords(nodeIndexOffset + n, 2) = zView[n]; } From 3250704157e24fa3cc068a0b401fcac5f77ebdcd Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Sun, 30 Aug 2026 21:38:33 -0700 Subject: [PATCH 26/36] Quest: Fixes verification checks that prevented the MC example to run on unstructured meshes Also, simplifies the flat-field check and adds an output for the polygonal MC (before triangulating the cases). --- .../examples/quest_marching_cubes_example.cpp | 411 +++++++++++------- 1 file changed, 257 insertions(+), 154 deletions(-) diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index 6c801d0148..cd75968dce 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -30,6 +30,7 @@ #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/quest/MarchingCubes.hpp" #include "axom/quest/MeshViewUtil.hpp" @@ -57,6 +58,7 @@ #include #include #include +#include #include #include #include @@ -67,6 +69,7 @@ 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; @@ -86,6 +89,8 @@ 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; @@ -170,6 +175,12 @@ struct Input "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(); @@ -340,11 +351,9 @@ struct BlueprintStructuredMesh public: explicit BlueprintStructuredMesh(const std::string& meshFile, const std::string& topologyName, - bool compactStridedStructured = false, bool verboseOutput = false) : _topologyName(topologyName) , _topologyPath("topologies/" + topologyName) - , _compactStridedStructured(compactStridedStructured) { readBlueprintMesh(meshFile); @@ -502,10 +511,30 @@ struct BlueprintStructuredMesh domain(domId).fetch_existing(_topologyPath + "/elements/dims").has_child("strides"); } - bool useFlatFields(axom::IndexType domId) const + /*! + * @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; } + + //! @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 { - return (_domCount == 1 && !isStridedStructured(domId)) || isUnstructured(domId) || - domain(domId).has_path("fields/fcn") || (_compactedStridedStructured && isStructured(domId)); + 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]); + } } /*! @@ -643,38 +672,34 @@ struct BlueprintStructuredMesh return maxLen; } - double maxUnstructuredEdgeLength(const conduit::Node& dom) const + /*! + * @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 conduit::Node& topo = dom.fetch_existing(_topologyPath); - 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(); const auto conn = topo.fetch_existing("elements/connectivity").as_index_t_accessor(); - const std::string shape = topo.fetch_existing("elements/shape").as_string(); - - const axom::IndexType cornersPerCell = shape == "hex" ? 8 : shape == "quad" ? 4 : 0; - SLIC_ASSERT_MSG(cornersPerCell != 0, - axom::fmt::format("Unsupported unstructured shape '{}'.", shape)); - - const int edgePairsHex[12][2] = - {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}}; - const int edgePairsQuad[4][2] = {{0, 1}, {1, 2}, {2, 3}, {3, 0}}; - double maxLen = 0.0; + 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) { - const int edgeCount = shape == "hex" ? 12 : 4; for(int e = 0; e < edgeCount; ++e) { - const int aLocal = shape == "hex" ? edgePairsHex[e][0] : edgePairsQuad[e][0]; - const int bLocal = shape == "hex" ? edgePairsHex[e][1] : edgePairsQuad[e][1]; - const axom::IndexType a = static_cast(conn[cell * cornersPerCell + aLocal]); - const axom::IndexType b = static_cast(conn[cell * cornersPerCell + bLocal]); + 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; @@ -684,6 +709,24 @@ struct BlueprintStructuredMesh 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 { @@ -712,11 +755,8 @@ struct BlueprintStructuredMesh int _ndims {-1}; conduit::Node _mdMesh; axom::IndexType _domCount; - bool _coordsAreStrided = false; - bool _compactedStridedStructured = false; const std::string _topologyName; const std::string _topologyPath; - bool _compactStridedStructured = false; std::string _coordsetPath; double _maxSpacing = -1.0; @@ -742,91 +782,6 @@ struct BlueprintStructuredMesh return defaultValue; } - void compactStridedStructuredDomains() - { - bool compactedAny = false; - for(axom::IndexType domId = 0; domId < _domCount; ++domId) - { - if(!isStructured(domId)) - { - continue; - } - - conduit::Node& dom = domain(domId); - conduit::Node& dimsNode = dom.fetch_existing(_topologyPath + "/elements/dims"); - const bool hasOffsets = dimsNode.has_child("offsets"); - const bool hasStrides = dimsNode.has_child("strides"); - if(!hasOffsets && !hasStrides) - { - continue; - } - SLIC_ASSERT_MSG(hasOffsets && hasStrides, - "Expected strided structured topology to define both offsets and strides."); - - axom::StackArray nodeShape {{1, 1, 1}}; - axom::StackArray offsets {{0, 0, 0}}; - axom::StackArray strides {{1, 1, 1}}; - for(int dim = 0; dim < _ndims; ++dim) - { - nodeShape[dim] = dimValue(dimsNode, dim) + 1; - offsets[dim] = dimValue(dimsNode.fetch_existing("offsets"), dim); - strides[dim] = dimValue(dimsNode.fetch_existing("strides"), dim); - } - - const axom::IndexType compactNodeCount = nodeShape[0] * nodeShape[1] * nodeShape[2]; - const conduit::Node& coordValues = dom.fetch_existing(_coordsetPath + "/values"); - - auto compactComponent = [&](const std::string& componentName) { - std::vector compactValues(static_cast(compactNodeCount)); - const auto source = coordValues.fetch_existing(componentName).as_double_accessor(); - 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 sourceIdx = (i + offsets[0]) * strides[0] + - (j + offsets[1]) * strides[1] + (k + offsets[2]) * strides[2]; - const axom::IndexType destIdx = i + nodeShape[0] * (j + nodeShape[1] * k); - compactValues[static_cast(destIdx)] = source[sourceIdx]; - } - } - } - return compactValues; - }; - - std::vector xs = compactComponent("x"); - std::vector ys = compactComponent("y"); - std::vector zs; - if(_ndims == 3) - { - zs = compactComponent("z"); - } - - conduit::Node& compactCoordValues = dom.fetch_existing(_coordsetPath + "/values"); - compactCoordValues["x"].set(xs); - compactCoordValues["y"].set(ys); - if(_ndims == 3) - { - compactCoordValues["z"].set(zs); - } - - dimsNode.remove("offsets"); - dimsNode.remove("strides"); - if(dom.has_child("fields")) - { - dom.remove("fields"); - } - compactedAny = true; - } - - if(compactedAny) - { - _coordsAreStrided = false; - _compactedStridedStructured = true; - } - } - //! @brief Read a blueprint mesh into conduit::Node _mdMesh. void readBlueprintMesh(const std::string& meshFilename) { @@ -834,12 +789,12 @@ struct BlueprintStructuredMesh 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(); - if(loadedMesh.has_path(_topologyPath)) - { - _mdMesh.append().set(loadedMesh); - } - else if(conduit::blueprint::mesh::is_multi_domain(loadedMesh)) + if(conduit::blueprint::mesh::is_multi_domain(loadedMesh)) { _mdMesh.swap(loadedMesh); } @@ -856,13 +811,6 @@ struct BlueprintStructuredMesh _coordsetPath = axom::fmt::format("coordsets/{}", coordsetName); SLIC_ASSERT(_mdMesh[0].has_path(_coordsetPath)); - _coordsAreStrided = false; - for(axom::IndexType domId = 0; domId < _domCount; ++domId) - { - _coordsAreStrided = _coordsAreStrided || - (isStructured(domId) && - domain(domId).fetch_existing(_topologyPath + "/elements/dims").has_child("strides")); - } const conduit::Node coordsetNode = _mdMesh[0].fetch_existing(_coordsetPath); _ndims = conduit::blueprint::mesh::coordset::dims(coordsetNode); } @@ -871,11 +819,6 @@ struct BlueprintStructuredMesh #endif SLIC_ASSERT(_ndims > 0); - if(_compactStridedStructured && _coordsAreStrided) - { - compactStridedStructuredDomains(); - } - SLIC_ASSERT(isValid()); } }; // BlueprintStructuredMesh @@ -1226,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; @@ -1599,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 @@ -1620,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, @@ -1649,18 +1623,56 @@ 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)) { - lower[d] = coordsViews[d][parentCellIdx]; - upper[d] = coordsViews[d][upperIdx]; + 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 + { + 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 = geometryTolerance(computationalMesh); axom::primal::BoundingBox big(parentCellBox); big.expand(tol); @@ -1710,6 +1722,95 @@ struct ContourTestBase return errCount; } + /*! + * @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. @@ -1751,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) @@ -1774,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; @@ -1806,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) { @@ -1829,9 +1933,11 @@ struct ContourTestBase bool touchesContour = (minFcnValue <= m_params.contourVal && maxFcnValue >= m_params.contourVal); - // If the min or max values in the cell is close to the contour value, - // touchesContour and hasCont can go either way. So give it a pass. - if(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; } @@ -2147,10 +2253,7 @@ int main(int argc, char** argv) //--------------------------------------------------------------------------- AXOM_ANNOTATE_BEGIN("load mesh"); - BlueprintStructuredMesh computationalMesh(params.meshFile, - "mesh", - params.useBumpBackend, - params.isVerbose()); + BlueprintStructuredMesh computationalMesh(params.meshFile, "mesh", params.isVerbose()); AXOM_ANNOTATE_END("load mesh"); SLIC_ERROR_IF( From 8d7bcdef95df1c54367a5e43449261b047305623 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Sun, 30 Aug 2026 22:40:14 -0700 Subject: [PATCH 27/36] Consolidates unstructured MC Python generator script into getn-multidom-structured-mesh script --- src/tools/CMakeLists.txt | 4 +- src/tools/gen-marching-cubes-mesh.py | 304 ---------------------- src/tools/gen-multidom-structured-mesh.py | 179 +++++++++++-- 3 files changed, 158 insertions(+), 329 deletions(-) delete mode 100755 src/tools/gen-marching-cubes-mesh.py diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index e8adcb9409..17734bd014 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -237,9 +237,7 @@ if(NANOBIND_FOUND) #-------------------------------------------------------------------------- # Python utilities for generating Blueprint mesh inputs. #-------------------------------------------------------------------------- - foreach(_mesh_gen_script - gen-marching-cubes-mesh.py - gen-multidom-structured-mesh.py) + 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}" diff --git a/src/tools/gen-marching-cubes-mesh.py b/src/tools/gen-marching-cubes-mesh.py deleted file mode 100755 index 470dd98355..0000000000 --- a/src/tools/gen-marching-cubes-mesh.py +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env python3 - -# 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 single-domain structured or unstructured Blueprint mesh for -# quest::MarchingCubes testing. -# -# The generated Blueprint hierarchy is: -# -# ├── state -# │ └─• domain_id == 0 -# ├── topologies -# │ └── -# │ ├─• coordset == -# │ ├─• type == "structured" or "unstructured" -# │ └── elements -# │ ├─• dims/{i,j,[k]} (structured cell dimensions) -# │ ├─• shape (unstructured "quad" or "hex") -# │ └─• connectivity (unstructured flat int64 connectivity) -# ├── coordsets -# │ └── -# │ ├─• type == "explicit" -# │ └── values (i-fastest node ordering) -# │ ├─• x (float64, node_count) -# │ ├─• y (float64, node_count) -# │ └─• [z] (float64, node_count, present in 3D) -# └── fields -# └── -# ├─• topology == -# ├─• association == "vertex" -# └─• values (float64 signed distance samples) - -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\n' - 'Conduit must be configured with python and hdf5.\n' - 'Alternatively, use the build directory convenience script:\n' - '/path/to/axom_build_dir/bin/run_python_with_axom.sh') - exit(-1) - -import numpy as np -from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter - - -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 single-domain MarchingCubes Blueprint mesh.', - formatter_class=ArgumentDefaultsHelpFormatter) - 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=('20', '20'), - help='Logical size of mesh (cells), space- or comma-separated') - ps.add_argument('-o', '--output', type=str, default='mcmesh', help='Output file base name') - ps.add_argument('--topology', - choices=('structured', 'unstructured'), - default='structured', - help='Topology representation to write') - ps.add_argument('--field', - choices=('sphere', 'plane'), - default='sphere', - help='Vertex field to sample') - ps.add_argument( - '--center', - nargs='+', - default=None, - help='Sphere center or point on plane, space- or comma-separated. Defaults to mesh center') - ps.add_argument( - '--radius', - type=float, - default=None, - help='Sphere/circle radius. Defaults to one quarter of the shortest mesh extent') - ps.add_argument( - '--normal', - nargs='+', - default=None, - help='Plane normal, space- or comma-separated. Defaults to +z in 3D or +y in 2D') - ps.add_argument('--fieldName', type=str, default='fcn', help='Output vertex field name') - ps.add_argument('--topologyName', type=str, default='mesh', help='Output topology name') - ps.add_argument('--coordsetName', type=str, default='coords', help='Output coordset name') - ps.add_argument('--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) - opts.ml = parse_component_list(opts.ml, float) - opts.mu = parse_component_list(opts.mu, float) - opts.ms = parse_component_list(opts.ms, int) - if opts.center is not None: - opts.center = parse_component_list(opts.center, float) - if opts.normal is not None: - opts.normal = parse_component_list(opts.normal, float) - return opts - - -def validated_mesh_options(opts): - dim = len(opts.ms) - if dim not in (2, 3) or len(opts.ml) != dim or len(opts.mu) != dim: - raise RuntimeError('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') - - mesh_size = np.array(opts.ms, dtype=np.int64) - mesh_lower = np.array(opts.ml, dtype=np.float64) - mesh_upper = np.array(opts.mu, dtype=np.float64) - 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') - - center = np.array(opts.center if opts.center is not None else 0.5 * (mesh_lower + mesh_upper), - dtype=np.float64) - if len(center) != dim: - raise RuntimeError(f'center must have {dim} components') - - radius = opts.radius if opts.radius is not None else 0.25 * np.min(mesh_extent) - - normal = np.array(opts.normal if opts.normal is not None else ((0., 1.) if dim == 2 else - (0., 0., 1.)), - dtype=np.float64) - if len(normal) != dim: - raise RuntimeError(f'normal must have {dim} components') - normal_norm = np.linalg.norm(normal) - if normal_norm == 0.0: - raise RuntimeError('normal must be nonzero') - normal = normal / normal_norm - - return { - 'dim': dim, - 'mesh_size': mesh_size, - 'mesh_lower': mesh_lower, - 'mesh_extent': mesh_extent, - 'center': center, - 'radius': radius, - 'normal': normal, - } - - -def sample_field(pt, field_kind, center, radius, normal): - if field_kind == 'sphere': - return np.linalg.norm(pt - center) - radius - return np.dot(pt - center, normal) - - -def node_index(mesh_size, i, j, k=0): - ni = mesh_size[0] + 1 - nj = mesh_size[1] + 1 - return i + j * ni + k * ni * nj - - -def generate_coordset(mesh, opts, context): - dim = context['dim'] - mesh_size = context['mesh_size'] - mesh_lower = context['mesh_lower'] - mesh_extent = context['mesh_extent'] - - node_counts = mesh_size + 1 - num_nodes = int(np.prod(node_counts)) - coords = np.empty((num_nodes, dim), dtype=np.float64) - - idx = 0 - if dim == 2: - for j in range(node_counts[1]): - for i in range(node_counts[0]): - logical = np.array((i, j), dtype=np.float64) / mesh_size - coords[idx, :] = mesh_lower + logical * mesh_extent - idx += 1 - else: - for k in range(node_counts[2]): - for j in range(node_counts[1]): - for i in range(node_counts[0]): - logical = np.array((i, j, k), dtype=np.float64) / mesh_size - coords[idx, :] = mesh_lower + logical * mesh_extent - idx += 1 - - coordset = mesh[f'coordsets/{opts.coordsetName}'] - coordset['type'] = 'explicit' - coordset['values/x'].set(coords[:, 0]) - coordset['values/y'].set(coords[:, 1]) - if dim == 3: - coordset['values/z'].set(coords[:, 2]) - - return coords - - -def generate_topology(mesh, opts, context): - dim = context['dim'] - mesh_size = context['mesh_size'] - - topo = mesh[f'topologies/{opts.topologyName}'] - topo['coordset'] = opts.coordsetName - if opts.topology == 'structured': - topo['type'] = 'structured' - topo['elements/dims/i'] = int(mesh_size[0]) - topo['elements/dims/j'] = int(mesh_size[1]) - if dim == 3: - topo['elements/dims/k'] = int(mesh_size[2]) - return - - topo['type'] = 'unstructured' - topo['elements/shape'] = 'quad' if dim == 2 else 'hex' - connectivity = [] - if dim == 2: - for j in range(mesh_size[1]): - for i in range(mesh_size[0]): - connectivity.extend([ - node_index(mesh_size, i, j), - node_index(mesh_size, i + 1, j), - node_index(mesh_size, i + 1, j + 1), - node_index(mesh_size, i, j + 1), - ]) - else: - for k in range(mesh_size[2]): - for j in range(mesh_size[1]): - for i in range(mesh_size[0]): - connectivity.extend([ - node_index(mesh_size, i, j, k), - node_index(mesh_size, i + 1, j, k), - node_index(mesh_size, i + 1, j + 1, k), - node_index(mesh_size, i, j + 1, k), - node_index(mesh_size, i, j, k + 1), - node_index(mesh_size, i + 1, j, k + 1), - node_index(mesh_size, i + 1, j + 1, k + 1), - node_index(mesh_size, i, j + 1, k + 1), - ]) - topo['elements/connectivity'].set(np.array(connectivity, dtype=np.int64)) - - -def generate_fields(mesh, opts, context, coords): - values = np.empty(coords.shape[0], dtype=np.float64) - for idx, pt in enumerate(coords): - values[idx] = sample_field(pt, opts.field, context['center'], context['radius'], - context['normal']) - - field = mesh[f'fields/{opts.fieldName}'] - field['topology'] = opts.topologyName - field['association'] = 'vertex' - field['values'].set(values) - - -def generate_mesh(opts): - context = validated_mesh_options(opts) - mesh = conduit.Node() - - coords = generate_coordset(mesh, opts, context) - generate_topology(mesh, opts, context) - generate_fields(mesh, opts, context, coords) - mesh['state/domain_id'] = 0 - - return mesh - - -def main(): - opts = parse_args() - mesh = generate_mesh(opts) - - info = conduit.Node() - if not conduit.blueprint.mesh.verify(mesh, info): - print("Mesh failed blueprint verification. Info:") - print(info) - return 2 - - if opts.verbose: - print(mesh) - - conduit.relay.io.blueprint.save_mesh(mesh, opts.output, "hdf5") - print(f'Wrote mesh {opts.output}') - return 0 - - -if __name__ == '__main__': - exit(main()) diff --git a/src/tools/gen-multidom-structured-mesh.py b/src/tools/gen-multidom-structured-mesh.py index 90c052782d..0fe4c49419 100755 --- a/src/tools/gen-multidom-structured-mesh.py +++ b/src/tools/gen-multidom-structured-mesh.py @@ -6,35 +6,39 @@ # # SPDX-License-Identifier: (BSD-3-Clause) -# Write a simple multidomain structured Blueprint mesh for testing. +# Write a multidomain 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. +# 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: # -# ├── (or list children when --useList is set) +# ├── Map entry, or list child with --useList # │ ├── topologies # │ │ └── mesh -# │ │ ├─• type == "structured" +# │ │ ├─• type == "structured" or "unstructured" # │ │ ├─• coordset == "coords" # │ │ └── elements -# │ │ └── dims -# │ │ ├─• i -# │ │ ├─• j -# │ │ └─• [k] +# │ │ ├── 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, with ghost padding for --strided) +# │ │ └── values i-fastest node ordering, ghost padded for --strided # │ │ ├─• x # │ │ ├─• y # │ │ └─• [z] # │ └── fields -# │ └── field -# │ ├─• association == "element" +# │ ├── field Conduit's example field +# │ │ ├─• association == "element" +# │ │ ├─• topology == "mesh" +# │ │ └─• values +# │ └── Only with --field. Nodal for MarchingCubes +# │ ├─• association == "vertex" # │ ├─• topology == "mesh" # │ └─• values # └── ... @@ -44,11 +48,12 @@ import conduit.blueprint import conduit.relay except ModuleNotFoundError as e: - print(f'{e}\nMake sure your PYTHONPATH includes /path/to/conduit/install/python-modules\n' - 'Conduit must be configured with python and hdf5.\n' - 'Alternatively, you can use the convenience script\n' - '/path/to/axom_build_dir/bin/run_python_with_axom.sh\n' - 'that 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 @@ -67,7 +72,7 @@ def parse_component_list(values, cast): def parse_args(): - ps = ArgumentParser(description='Write a multidomain structured Blueprint mesh.', + 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', @@ -96,6 +101,37 @@ def parse_args(): 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() @@ -140,7 +176,43 @@ def validated_mesh_options(opts): 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, @@ -236,6 +308,64 @@ def generate_coordset(dom, context, start_coord, end_coord): 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 @@ -261,6 +391,11 @@ def generate_domain(md_mesh, opts, context, di, dj, dk): 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): @@ -291,11 +426,11 @@ def main(): info = conduit.Node() if not conduit.blueprint.mesh.verify(md_mesh, info): - print("Mesh failed blueprint verification. Info:") + print("Mesh failed blueprint verification. Info:") print(info) return 2 - conduit.relay.io.blueprint.save_mesh(md_mesh, opts.output, "hdf5") + conduit.relay.io.blueprint.save_mesh(md_mesh, opts.output, opts.protocol) print(f'Wrote mesh {opts.output}') return 0 From f7f3fadb72f0823d9f29d58beb4a3947448f3da1 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Sun, 30 Aug 2026 22:43:18 -0700 Subject: [PATCH 28/36] Quest: Increases test coverage for unstructured bump-based MC --- src/axom/quest/examples/CMakeLists.txt | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index 78beabd965..b94a1175f8 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -629,6 +629,58 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) unset(_test) endif() + #-------------------------------------------------------------------------- + # Unstructured coverage for the bump backend. + # + # The axom_data meshes are structured, so they do not hit the unstructured + # quad and hex path that the bump backend adds. Generate a small unstructured + # hex mesh at test time instead of adding a binary file to the data repo. + # + # Use a CTest fixture so we build the mesh once and reuse it across policies. + #-------------------------------------------------------------------------- + if(AXOM_ENABLE_TESTS AND NOT WIN32 AND Python_EXECUTABLE AND CONDUIT_PYTHON_MODULE_DIR) + axom_python_test_environment(_mc_py_env) + set(_mc_gen_mesh "${CMAKE_CURRENT_BINARY_DIR}/mc_uhex") + + axom_add_test( + NAME quest_marching_cubes_gen_unstructured_mesh + COMMAND ${Python_EXECUTABLE} + ${PROJECT_SOURCE_DIR}/tools/gen-multidom-structured-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 + -o ${_mc_gen_mesh} + NUM_MPI_TASKS 1 + ) + set_tests_properties(quest_marching_cubes_gen_unstructured_mesh + PROPERTIES FIXTURES_SETUP mc_unstructured_mesh + ENVIRONMENT "${_mc_py_env}" + PROCESSORS 1) + + 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_gen_mesh}.root + --center 0.5 0.5 0.5 + --contourVal 0.25 + --blueprint-contour-file ${_test}.contour + NUM_MPI_TASKS 1 + ) + set_tests_properties(${_test} + PROPERTIES FIXTURES_REQUIRED mc_unstructured_mesh + PROCESSORS 1 + PASS_REGULAR_EXPRESSION "Contour mesh has") + endforeach() + + unset(_test) + unset(_mc_py_env) + unset(_mc_gen_mesh) + endif() + endif() # Point in cell example ------------------------------------------------------- From f435f456b8cc09c7120667823e7ab8c7a56e4a68 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 31 Aug 2026 00:25:47 -0700 Subject: [PATCH 29/36] Quest: Moves unstructured hex example to axom_data ... instead of generating it as part of the test suite. --- src/axom/quest/examples/CMakeLists.txt | 49 ++++++++------------------ 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index b94a1175f8..e05c99ba9a 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -534,10 +534,10 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) # 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) @@ -631,32 +631,13 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) #-------------------------------------------------------------------------- # Unstructured coverage for the bump backend. - # - # The axom_data meshes are structured, so they do not hit the unstructured - # quad and hex path that the bump backend adds. Generate a small unstructured - # hex mesh at test time instead of adding a binary file to the data repo. - # - # Use a CTest fixture so we build the mesh once and reuse it across policies. + # 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 NOT WIN32 AND Python_EXECUTABLE AND CONDUIT_PYTHON_MODULE_DIR) - axom_python_test_environment(_mc_py_env) - set(_mc_gen_mesh "${CMAKE_CURRENT_BINARY_DIR}/mc_uhex") - - axom_add_test( - NAME quest_marching_cubes_gen_unstructured_mesh - COMMAND ${Python_EXECUTABLE} - ${PROJECT_SOURCE_DIR}/tools/gen-multidom-structured-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 - -o ${_mc_gen_mesh} - NUM_MPI_TASKS 1 - ) - set_tests_properties(quest_marching_cubes_gen_unstructured_mesh - PROPERTIES FIXTURES_SETUP mc_unstructured_mesh - ENVIRONMENT "${_mc_py_env}" - PROCESSORS 1) - + 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( @@ -664,21 +645,19 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) COMMAND quest_marching_cubes_ex --policy ${_pol} --useBumpBackend - --mesh-file ${_mc_gen_mesh}.root + --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 FIXTURES_REQUIRED mc_unstructured_mesh - PROCESSORS 1 + PROPERTIES PROCESSORS 1 + REQUIRED_FILES "${_mc_unstructured_mesh}" PASS_REGULAR_EXPRESSION "Contour mesh has") endforeach() - unset(_test) - unset(_mc_py_env) - unset(_mc_gen_mesh) + unset(_mc_unstructured_mesh) endif() endif() From 84a558b601673880dba22804eaa27d848dcce234 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 31 Aug 2026 00:45:28 -0700 Subject: [PATCH 30/36] Quest: Adds a test to compare native and bump-based MC on structured meshes --- src/axom/quest/tests/CMakeLists.txt | 18 + .../quest_marching_cubes_equivalence.cpp | 1562 +++++++++++++++++ 2 files changed, 1580 insertions(+) create mode 100644 src/axom/quest/tests/quest_marching_cubes_equivalence.cpp diff --git a/src/axom/quest/tests/CMakeLists.txt b/src/axom/quest/tests/CMakeLists.txt index 0c3369eab3..802ea9c77f 100644 --- a/src/axom/quest/tests/CMakeLists.txt +++ b/src/axom/quest/tests/CMakeLists.txt @@ -333,6 +333,24 @@ if(CONDUIT_FOUND AND RAJA_FOUND AND AXOM_ENABLE_BUMP AND AXOM_ENABLE_SIDRE) 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() 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..f14c0d510a --- /dev/null +++ b/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp @@ -0,0 +1,1562 @@ +// 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/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(); } + +//--------------------------------------------------------------------------- +// 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); + + mc.setMesh(mesh, "mesh"); + mc.setFunctionField(fieldName); + mc.computeIsocontour(contourVal); + + if(useBump && bumpBlueprint != nullptr) + { + mc.populateContourMeshBlueprint(*bumpBlueprint); + } + + 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(); +} From ad6757db8f5be681a1b4babfbbbb426714851384 Mon Sep 17 00:00:00 2001 From: format-robot Date: Mon, 31 Aug 2026 11:31:32 -0700 Subject: [PATCH 31/36] make style after rebasing --- data | 2 +- src/axom/bump/Unique.hpp | 18 +++---- src/axom/bump/tests/bump_views.cpp | 14 +++--- .../views/dispatch_structured_topology.hpp | 48 +++++++++---------- src/axom/quest/MarchingCubes.hpp | 4 +- .../quest/detail/MarchingCubesBumpImpl.hpp | 8 ++-- src/axom/quest/detail/MarchingCubesImpl.hpp | 2 +- .../detail/MarchingCubesSingleDomain.hpp | 2 +- .../docs/sphinx/isosurface_detection.rst | 4 +- src/axom/quest/examples/CMakeLists.txt | 2 +- .../examples/quest_marching_cubes_example.cpp | 2 +- 11 files changed, 53 insertions(+), 53 deletions(-) diff --git a/data b/data index 8ac544afdc..c446495931 160000 --- a/data +++ b/data @@ -1 +1 @@ -Subproject commit 8ac544afdc0d75e9cfe0681f9eaa8f2150534dea +Subproject commit c446495931576ffda8017633f683118f791c66f0 diff --git a/src/axom/bump/Unique.hpp b/src/axom/bump/Unique.hpp index aa81c11f7c..65eb1e99f5 100644 --- a/src/axom/bump/Unique.hpp +++ b/src/axom/bump/Unique.hpp @@ -33,7 +33,7 @@ namespace detail * \param container The container (usually a view) whose data will be printed. */ template -void printContainer(const std::string &name, const ContainerType &container) +void printContainer(const std::string& name, const ContainerType& container) { using value_type = typename ContainerType::value_type; using printed_type = @@ -67,7 +67,7 @@ void printContainer(const std::string &name, const ContainerType &container) * \param container The container whose data will be printed. */ template -void printMap(const std::string &name, const MapType &container, bool printKey) +void printMap(const std::string& name, const MapType& container, bool printKey) { std::cout << name << "=["; for(auto it = container.begin(); it != container.end(); it++) @@ -116,8 +116,8 @@ struct Unique * \note key_orig_view is passed by value so it does not require a local copy to capture it. */ static void execute(const axom::ArrayView keys_orig_view, - axom::Array &skeys, - axom::Array &sindices, + axom::Array& skeys, + axom::Array& sindices, int allocator_id = axom::execution_space::allocatorID()) { const int allocatorID = allocator_id; @@ -211,9 +211,9 @@ struct Unique * \param[out] sindices An array of indices that indicate where in the original view the keys came from. * */ - static void execute(const axom::ArrayView &keys_orig_view, - axom::Array &skeys, - axom::Array &sindices, + static void execute(const axom::ArrayView& keys_orig_view, + axom::Array& skeys, + axom::Array& sindices, int allocator_id = axom::execution_space::allocatorID()) { // Make unique values and store the indices. @@ -236,8 +236,8 @@ struct Unique // Sort the vector by the keys. std::sort(unique_vector.begin(), unique_vector.end(), - [](const std::pair &a, - const std::pair &b) { return a.first < b.first; }); + [](const std::pair& a, + const std::pair& b) { return a.first < b.first; }); // Allocate the output arrays and populate them const axom::IndexType newsize = unique_vector.size(); diff --git a/src/axom/bump/tests/bump_views.cpp b/src/axom/bump/tests/bump_views.cpp index 71a395aa62..f44e7631bc 100644 --- a/src/axom/bump/tests/bump_views.cpp +++ b/src/axom/bump/tests/bump_views.cpp @@ -56,7 +56,7 @@ TEST(bump_views, shape2conduitName) //------------------------------------------------------------------------------ template -void compareShapes(const ShapeType &shape1, const VariableShapeType &shape2) +void compareShapes(const ShapeType& shape1, const VariableShapeType& shape2) { using ConnType = typename ShapeType::ConnectivityType; @@ -436,7 +436,7 @@ struct test_structured_topology_view_rectilinear AXOM_LAMBDA(axom::IndexType zoneIndex) { const auto zone = topoView.zone(zoneIndex); axom::IndexType m = -1; - for(const auto &id : zone.getIds()) + for(const auto& id : zone.getIds()) { m = axom::utilities::max(static_cast(id), m); } @@ -457,7 +457,7 @@ struct test_structured_topology_view_rectilinear } } - static void create(conduit::Node &mesh) + static void create(conduit::Node& mesh) { std::vector dims {4, 4}; axom::blueprint::testing::data::braid("rectilinear", dims, mesh); @@ -499,7 +499,7 @@ struct test_strided_structured axom::bump::views::dispatch_explicit_coordset(hostMesh["coordsets/coords"], [&](auto coordsetView) { axom::bump::views::dispatch_structured_topology( hostMesh["topologies/mesh"], - [&](const std::string &AXOM_UNUSED_PARAM(shape), auto topoView) { + [&](const std::string& AXOM_UNUSED_PARAM(shape), auto topoView) { execute(coordsetView, topoView); }); }); @@ -592,7 +592,7 @@ void test_strided_structured_any_dispatch() bool supports_strided_structured = false; views::dispatch_structured_topologies( hostMesh["topologies/mesh"], - [&](const std::string &, auto topoView) { + [&](const std::string&, auto topoView) { callback_invoked = true; supports_strided_structured = views::view_traits::supports_strided_structured(); @@ -615,7 +615,7 @@ struct test_braid2d_mat struct NoMixedFields { }; - static void test(const std::string &type, const std::string &mattype, const std::string &name) + static void test(const std::string& type, const std::string& mattype, const std::string& name) { namespace utils = axom::bump::utilities; const int allocatorID = axom::execution_space::allocatorID(); @@ -941,7 +941,7 @@ TEST(bump_views, matset_material_dominant_hip) #endif //------------------------------------------------------------------------------ -int main(int argc, char *argv[]) +int main(int argc, char* argv[]) { ::testing::InitGoogleTest(&argc, argv); return TestApp.execute(argc, argv); diff --git a/src/axom/bump/views/dispatch_structured_topology.hpp b/src/axom/bump/views/dispatch_structured_topology.hpp index 2876a982f4..cb247efff7 100644 --- a/src/axom/bump/views/dispatch_structured_topology.hpp +++ b/src/axom/bump/views/dispatch_structured_topology.hpp @@ -47,7 +47,7 @@ struct make_strided_structured_topology<3> * \param topo The node containing the topology. * \return The indexing */ - static Indexing indexing(const conduit::Node &topo) + static Indexing indexing(const conduit::Node& topo) { namespace utils = axom::bump::utilities; verify(topo, "topology"); @@ -91,7 +91,7 @@ struct make_strided_structured_topology<3> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } }; /*! @@ -109,7 +109,7 @@ struct make_strided_structured_topology<2> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node &topo) + static Indexing indexing(const conduit::Node& topo) { namespace utils = axom::bump::utilities; verify(topo, "topology"); @@ -146,7 +146,7 @@ struct make_strided_structured_topology<2> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } }; /*! @@ -164,7 +164,7 @@ struct make_strided_structured_topology<1> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node &topo) + static Indexing indexing(const conduit::Node& topo) { namespace utils = axom::bump::utilities; verify(topo, "topology"); @@ -189,7 +189,7 @@ struct make_strided_structured_topology<1> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } }; /*! @@ -214,7 +214,7 @@ struct make_structured_topology<3> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node &topo) + static Indexing indexing(const conduit::Node& topo) { verify(topo, "topology"); LogicalIndex zoneDims; @@ -230,7 +230,7 @@ struct make_structured_topology<3> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } }; /*! @@ -248,7 +248,7 @@ struct make_structured_topology<2> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node &topo) + static Indexing indexing(const conduit::Node& topo) { verify(topo, "topology"); LogicalIndex zoneDims; @@ -262,7 +262,7 @@ struct make_structured_topology<2> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } }; /*! @@ -280,7 +280,7 @@ struct make_structured_topology<1> * \param topo The node containing the topology. * \return The indexing. */ - static Indexing indexing(const conduit::Node &topo) + static Indexing indexing(const conduit::Node& topo) { verify(topo, "topology"); LogicalIndex zoneDims; @@ -294,7 +294,7 @@ struct make_structured_topology<1> * \param topo The node containing the topology. * \return The topology view. */ - static TopoView view(const conduit::Node &topo) { return TopoView(indexing(topo)); } + static TopoView view(const conduit::Node& topo) { return TopoView(indexing(topo)); } }; //------------------------------------------------------------------------------ @@ -306,8 +306,8 @@ namespace internal template struct dispatch_only_structured_topology { - static void execute(const conduit::Node &AXOM_UNUSED_PARAM(topo), - FuncType &&AXOM_UNUSED_PARAM(func)) + static void execute(const conduit::Node& AXOM_UNUSED_PARAM(topo), + FuncType&& AXOM_UNUSED_PARAM(func)) { } }; @@ -324,7 +324,7 @@ struct dispatch_only_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node &topo, FuncType &&func) + static void execute(const conduit::Node& topo, FuncType&& func) { const std::string offsetsKey("elements/dims/offsets"); const std::string stridesKey("elements/dims/strides"); @@ -355,7 +355,7 @@ struct dispatch_only_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node &topo, FuncType &&func) + static void execute(const conduit::Node& topo, FuncType&& func) { const std::string offsetsKey("elements/dims/offsets"); const std::string stridesKey("elements/dims/strides"); @@ -386,7 +386,7 @@ struct dispatch_only_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node &topo, FuncType &&func) + static void execute(const conduit::Node& topo, FuncType&& func) { const std::string offsetsKey("elements/dims/offsets"); const std::string stridesKey("elements/dims/strides"); @@ -411,8 +411,8 @@ struct dispatch_only_structured_topology template struct dispatch_any_structured_topology { - static void execute(const conduit::Node &AXOM_UNUSED_PARAM(topo), - FuncType &&AXOM_UNUSED_PARAM(func)) + static void execute(const conduit::Node& AXOM_UNUSED_PARAM(topo), + FuncType&& AXOM_UNUSED_PARAM(func)) { } }; @@ -429,7 +429,7 @@ struct dispatch_any_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node &topo, FuncType &&func) + static void execute(const conduit::Node& topo, FuncType&& func) { const std::string offsetsKey("elements/dims/offsets"), stridesKey("elements/dims/strides"); const std::string type = topo.fetch_existing("type").as_string(); @@ -468,7 +468,7 @@ struct dispatch_any_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node &topo, FuncType &&func) + static void execute(const conduit::Node& topo, FuncType&& func) { const std::string offsetsKey("elements/dims/offsets"), stridesKey("elements/dims/strides"); const std::string type = topo.fetch_existing("type").as_string(); @@ -506,7 +506,7 @@ struct dispatch_any_structured_topology * \param topo The node that contains the topology. * \param func The kernel to be invoked. */ - static void execute(const conduit::Node &topo, FuncType &&func) + static void execute(const conduit::Node& topo, FuncType&& func) { const std::string offsetsKey("offsets"), stridesKey("strides"); const std::string type = topo.fetch_existing("type").as_string(); @@ -536,7 +536,7 @@ struct dispatch_any_structured_topology * \param func The function to invoke using the view. It should accept a string with the shape name and an auto parameter for the view. */ template -void dispatch_structured_topology(const conduit::Node &topo, FuncType &&func) +void dispatch_structured_topology(const conduit::Node& topo, FuncType&& func) { verify(topo, "topology"); int ndims = 1; @@ -577,7 +577,7 @@ void dispatch_structured_topology(const conduit::Node &topo, FuncType &&func) * \note We try to initialize the topoView for each dimension and share the dispatch. */ template -void dispatch_structured_topologies(const conduit::Node &topo, FuncType &&func) +void dispatch_structured_topologies(const conduit::Node& topo, FuncType&& func) { verify(topo, "topology"); const auto ndims = conduit::blueprint::mesh::utils::topology::dims(topo); diff --git a/src/axom/quest/MarchingCubes.hpp b/src/axom/quest/MarchingCubes.hpp index 5a1391ce6a..ff63c6c23c 100644 --- a/src/axom/quest/MarchingCubes.hpp +++ b/src/axom/quest/MarchingCubes.hpp @@ -64,7 +64,7 @@ enum class MarchingCubesDataParallelism * 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 + * @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. @@ -182,7 +182,7 @@ class MarchingCubes void setMaskValue(int maskVal) { m_maskVal = maskVal; } /*! - * @brief Select the bump::extraction::CutField backend + * @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 diff --git a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp index 5da9c3a794..3dfdae0ddd 100644 --- a/src/axom/quest/detail/MarchingCubesBumpImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpImpl.hpp @@ -22,7 +22,7 @@ * 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, + * 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. @@ -368,8 +368,8 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase * * 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. + * + * 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 @@ -834,7 +834,7 @@ class MarchingCubesBumpImpl : public MarchingCubesSingleDomain::ImplBase /* Iterate the logical index space directly rather than deriving it from a flat zone index. - topoMap.toMultiIndex(zoneIndex) costs DIM integer divisions per zone, + 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. diff --git a/src/axom/quest/detail/MarchingCubesImpl.hpp b/src/axom/quest/detail/MarchingCubesImpl.hpp index d91bd98eda..c7c4bc361e 100644 --- a/src/axom/quest/detail/MarchingCubesImpl.hpp +++ b/src/axom/quest/detail/MarchingCubesImpl.hpp @@ -81,7 +81,7 @@ class MarchingCubesImpl : public MarchingCubesSingleDomain::ImplBase * @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, diff --git a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp index 66f45e6f17..1511299ad5 100644 --- a/src/axom/quest/detail/MarchingCubesSingleDomain.hpp +++ b/src/axom/quest/detail/MarchingCubesSingleDomain.hpp @@ -60,7 +60,7 @@ class MarchingCubesSingleDomain * \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 + * 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. diff --git a/src/axom/quest/docs/sphinx/isosurface_detection.rst b/src/axom/quest/docs/sphinx/isosurface_detection.rst index 00a6218fde..50ae612412 100644 --- a/src/axom/quest/docs/sphinx/isosurface_detection.rst +++ b/src/axom/quest/docs/sphinx/isosurface_detection.rst @@ -17,8 +17,8 @@ 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. -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:: diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index e05c99ba9a..76892a7788 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -634,7 +634,7 @@ if(CONDUIT_FOUND AND AXOM_ENABLE_BUMP) # 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 + # --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") diff --git a/src/axom/quest/examples/quest_marching_cubes_example.cpp b/src/axom/quest/examples/quest_marching_cubes_example.cpp index cd75968dce..9ac1cbdeac 100644 --- a/src/axom/quest/examples/quest_marching_cubes_example.cpp +++ b/src/axom/quest/examples/quest_marching_cubes_example.cpp @@ -539,7 +539,7 @@ struct BlueprintStructuredMesh /*! * @return largest mesh spacing. - * + * * Compute only once, because after that, coordinates data may be moved to devices. */ double maxSpacing() const From 5b728ac32eee8c8611d99280b9072246527a1d91 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 31 Aug 2026 11:46:22 -0700 Subject: [PATCH 32/36] Quest: Updates Marching Cubes user docs --- .../docs/sphinx/isosurface_detection.rst | 103 +++++++++++++++--- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/src/axom/quest/docs/sphinx/isosurface_detection.rst b/src/axom/quest/docs/sphinx/isosurface_detection.rst index 50ae612412..53deba7eb9 100644 --- a/src/axom/quest/docs/sphinx/isosurface_detection.rst +++ b/src/axom/quest/docs/sphinx/isosurface_detection.rst @@ -11,11 +11,10 @@ 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 composed of line segments in 2D and triangles in 3D. @@ -24,7 +23,7 @@ The isosurface mesh is composed of line segments in 2D and triangles in 3D. 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: From a1932be722a30a6de5f2491e657de76b5fa992e6 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 31 Aug 2026 19:42:42 -0700 Subject: [PATCH 33/36] Quest: Bugfix for cuda build -- lambda capture within `if constexpr` --- src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp index 098378c8a1..1840c50c36 100644 --- a/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp +++ b/src/axom/quest/detail/MarchingCubesBumpAdaptor.hpp @@ -380,6 +380,8 @@ void adaptCutFieldOutputViews(const conduit::Node& n_coords, 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]; From 6cb5b526536f6e78402353e7e6cb6b3154226a24 Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 31 Aug 2026 19:46:03 -0700 Subject: [PATCH 34/36] Quest: Fix hip memory space issue when copying to/from device --- .../quest_marching_cubes_equivalence.cpp | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp b/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp index f14c0d510a..7e1106013a 100644 --- a/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp +++ b/src/axom/quest/tests/quest_marching_cubes_equivalence.cpp @@ -49,6 +49,7 @@ #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" @@ -69,6 +70,39 @@ 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 //--------------------------------------------------------------------------- @@ -364,13 +398,17 @@ BackendResult runBackend(const conduit::Node& mesh, quest::MarchingCubes mc(policy, allocatorID, quest::MarchingCubesDataParallelism::byPolicy); mc.setUseBumpBackend(useBump); - mc.setMesh(mesh, "mesh"); + conduit::Node execMesh; + copyBlueprintToPolicy(execMesh, mesh, policy, allocatorID); + mc.setMesh(execMesh, "mesh"); mc.setFunctionField(fieldName); mc.computeIsocontour(contourVal); if(useBump && bumpBlueprint != nullptr) { - mc.populateContourMeshBlueprint(*bumpBlueprint); + conduit::Node bumpBlueprintExec; + mc.populateContourMeshBlueprint(bumpBlueprintExec); + copyBlueprintToHost(*bumpBlueprint, bumpBlueprintExec); } BackendResult r; From 7e930e92196d7977f6b0dfb8cd1790708626cc6e Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Fri, 10 Jul 2026 16:08:08 -0700 Subject: [PATCH 35/36] Updates RELEASE-NOTES --- RELEASE-NOTES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 From 5feb5767c54f65e26526f4e59fcb5e704eb0a1db Mon Sep 17 00:00:00 2001 From: Kenneth Weiss Date: Mon, 31 Aug 2026 20:06:50 -0700 Subject: [PATCH 36/36] Updates data submodule for new test data --- data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data b/data index c446495931..358ef2f012 160000 --- a/data +++ b/data @@ -1 +1 @@ -Subproject commit c446495931576ffda8017633f683118f791c66f0 +Subproject commit 358ef2f01209250c33e4851e4bf491217c5bf61e