From fd51eb7a36fb659eb8b61922d05d7b9010a24d1b Mon Sep 17 00:00:00 2001 From: Paul Gessinger Date: Thu, 3 Sep 2026 13:56:51 +0200 Subject: [PATCH 1/5] refactor(json): Dispatch surface material (de)serialization via TypeDispatcher The `ISurfaceMaterial` <-> JSON conversion in `Plugins/Json` was a hand written `dynamic_cast` chain on the write side and a shape-guessing reader on the read side. This replaces both with the dispatcher pattern already used by `SurfaceJsonConverter`: a new `Acts::SurfaceMaterialJsonConverter` holds a `TypeDispatcher` encoder and a `JsonKindDispatcher` decoder in a `Config`, with `Config::defaultConfig()` registering everything. The payload of every type that already round-tripped is unchanged, so existing material maps (e.g. `Tests/Data/material-map.json`) keep loading. What this buys: - The reader dispatches strictly on the type tag instead of inferring the type from the payload shape. A missing or unknown tag is an error. As a consequence a "binned" payload with a single bin now decodes as `BinnedSurfaceMaterial` rather than `HomogeneousSurfaceMaterial`. - `ProtoGridSurfaceMaterial` (the `MultiAxisSpec2D` proto material) is covered under the new "proto-grid" tag; it used to serialize to nothing. - Globally indexed grids read back with their slab store populated. The free-function reader refuses them outright, because the shared vector has no source at single-surface scope; the encoder here inlines it under `storage_vector`, and the next commit adds the document-wide store table that lets the sharing itself survive. - `mappingType` round-trips for every type that can carry one, and the `mapMaterial == false` short circuit is applied once in `fromJson` before dispatch. Grid payloads gain the `mapMaterial` flag, which the free-function writer never emitted. The whole grid material family needs exactly one encoder and one decoder: `GridSurfaceMaterial` is a single concrete class whose storage backend is a runtime `std::variant`, so the encoder branches with `std::visit` and never names a concrete axis type. The reader hands out a `std::unique_ptr` so ownership is explicit. `MaterialMapJsonConverter` correspondingly stores `std::shared_ptr` in its geometry hierarchy map instead of an owning raw pointer. The free `to_json`/`from_json` overloads for `surfaceMaterialPointer` stay as deprecated forwarders; all in-tree callers use the new class directly. Volume material conversion is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BSH9Jg53ECS7DmzTYJdsZs --- Plugins/Json/CMakeLists.txt | 1 + .../Json/MaterialJsonConverter.hpp | 5 + .../Json/MaterialMapJsonConverter.hpp | 5 +- .../Json/SurfaceMaterialJsonConverter.hpp | 98 +++++ Plugins/Json/src/MaterialJsonConverter.cpp | 335 +-------------- Plugins/Json/src/MaterialMapJsonConverter.cpp | 25 +- Plugins/Json/src/SurfaceJsonConverter.cpp | 19 +- .../Json/src/SurfaceMaterialJsonConverter.cpp | 401 ++++++++++++++++++ Tests/UnitTests/Plugins/Json/CMakeLists.txt | 1 + .../Json/MaterialJsonConverterTests.cpp | 62 ++- .../SurfaceMaterialJsonConverterTests.cpp | 367 ++++++++++++++++ 11 files changed, 933 insertions(+), 386 deletions(-) create mode 100644 Plugins/Json/include/ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp create mode 100644 Plugins/Json/src/SurfaceMaterialJsonConverter.cpp create mode 100644 Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp diff --git a/Plugins/Json/CMakeLists.txt b/Plugins/Json/CMakeLists.txt index 5455202af7e..bb14e4d26a9 100644 --- a/Plugins/Json/CMakeLists.txt +++ b/Plugins/Json/CMakeLists.txt @@ -12,6 +12,7 @@ acts_add_library( src/ProtoAxisJsonConverter.cpp src/SurfaceBoundsJsonConverter.cpp src/SurfaceJsonConverter.cpp + src/SurfaceMaterialJsonConverter.cpp src/UtilitiesJsonConverter.cpp src/VolumeBoundsJsonConverter.cpp src/VolumeJsonConverter.cpp diff --git a/Plugins/Json/include/ActsPlugins/Json/MaterialJsonConverter.hpp b/Plugins/Json/include/ActsPlugins/Json/MaterialJsonConverter.hpp index 52bd4547e67..d7caf2310cd 100644 --- a/Plugins/Json/include/ActsPlugins/Json/MaterialJsonConverter.hpp +++ b/Plugins/Json/include/ActsPlugins/Json/MaterialJsonConverter.hpp @@ -69,11 +69,16 @@ void from_json(const nlohmann::json& j, volumeMaterialPointer& material); /// Convert surfaceMaterialPointer to JSON /// @param j Destination JSON object /// @param material Source surfaceMaterialPointer to convert +/// @deprecated Use SurfaceMaterialJsonConverter::toJson instead +[[deprecated("use SurfaceMaterialJsonConverter")]] void to_json(nlohmann::json& j, const surfaceMaterialPointer& material); /// Convert JSON to surfaceMaterialPointer /// @param j Source JSON object /// @param material Destination surfaceMaterialPointer to populate +/// @note the caller takes ownership of the returned raw pointer +/// @deprecated Use SurfaceMaterialJsonConverter::fromJson instead +[[deprecated("use SurfaceMaterialJsonConverter")]] void from_json(const nlohmann::json& j, surfaceMaterialPointer& material); /// JSON serialization mapping for MappingType enum diff --git a/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp b/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp index 0211d071fa0..c055db48c41 100644 --- a/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp +++ b/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp @@ -154,8 +154,9 @@ class MaterialMapJsonConverter { /// Name of the surface hierarchy std::string m_surfaceName = "Material Surface Map"; /// Geometry hierarchy writer for surface material. - Acts::GeometryHierarchyMapJsonConverter + Acts::GeometryHierarchyMapJsonConverter< + std::shared_ptr, + Acts::IVolumeMaterialJsonDecorator> m_surfaceMaterialConverter; /// Geometry hierarchy writer for surface. Acts::GeometryHierarchyMapJsonConverter + +#include + +namespace Acts { + +/// @addtogroup json_plugin +/// @{ + +/// Static class performing the JSON conversion of surface material +/// +/// The encoding side is a @c TypeDispatcher registered on the concrete +/// (or, for the grid family, the abstract templated) material types, the +/// decoding side a @c JsonKindDispatcher keyed on the payload type tag. +class SurfaceMaterialJsonConverter { + public: + /// Encoder type for the surface material + using Encoder = TypeDispatcher; + + /// Decoder type for the surface material + using Decoder = JsonKindDispatcher>; + + /// Configuration struct + struct Config { + /// Encoder for the surface material + Encoder encoder{}; + + /// Decoder for the surface material, keyed on the payload type tag + Decoder decoder{jsonKey().typekey, "surface material"}; + + /// Default configuration construction + /// + /// @return default configuration + static Config defaultConfig(); + }; + + /// Delete the default constructor as the class is purely static + SurfaceMaterialJsonConverter() = delete; + + /// Access the shared default configuration + /// + /// @return the default configuration instance + static const Config& defaultConfig(); + + /// Convert surface material into its json payload + /// + /// @param material the material to be converted + /// @param config the converter configuration + /// + /// @return the json payload of the material, i.e. the value that goes + /// under the @c material key of a surface + static nlohmann::json toJson(const ISurfaceMaterial& material, + const Config& config = defaultConfig()); + + /// Convert a json payload back into surface material + /// + /// @param jMaterial the json payload of the material + /// @param config the converter configuration + /// + /// @return the decoded material, or a nullptr if the payload is flagged + /// as not participating in the material mapping + static std::unique_ptr fromJson( + const nlohmann::json& jMaterial, const Config& config = defaultConfig()); +}; + +/// Convert surface material into the @c material entry of a json object +/// +/// @param j Destination JSON object +/// @param material Source material, may be a nullptr +void to_json(nlohmann::json& j, + const std::shared_ptr& material); + +/// Read surface material from the @c material entry of a json object +/// +/// @param j Source JSON object +/// @param material Destination material +void from_json(const nlohmann::json& j, + std::shared_ptr& material); + +/// @} + +} // namespace Acts diff --git a/Plugins/Json/src/MaterialJsonConverter.cpp b/Plugins/Json/src/MaterialJsonConverter.cpp index e6bfe455495..5ad9a117b1c 100644 --- a/Plugins/Json/src/MaterialJsonConverter.cpp +++ b/Plugins/Json/src/MaterialJsonConverter.cpp @@ -9,207 +9,25 @@ #include "ActsPlugins/Json/MaterialJsonConverter.hpp" #include "Acts/Definitions/Algebra.hpp" -#include "Acts/Material/BinnedSurfaceMaterial.hpp" -#include "Acts/Material/GridSurfaceMaterial.hpp" -#include "Acts/Material/HomogeneousSurfaceMaterial.hpp" #include "Acts/Material/HomogeneousVolumeMaterial.hpp" #include "Acts/Material/ISurfaceMaterial.hpp" #include "Acts/Material/IVolumeMaterial.hpp" #include "Acts/Material/InterpolatedMaterialMap.hpp" #include "Acts/Material/MaterialGridHelper.hpp" #include "Acts/Material/MaterialSlab.hpp" -#include "Acts/Material/MergedMaterialMarker.hpp" -#include "Acts/Material/ProtoSurfaceMaterial.hpp" #include "Acts/Material/ProtoVolumeMaterial.hpp" #include "Acts/Surfaces/Surface.hpp" #include "Acts/Utilities/BinUtility.hpp" -#include "Acts/Utilities/IAxis.hpp" #include "ActsPlugins/Json/GeometryJsonKeys.hpp" -#include "ActsPlugins/Json/GridJsonConverter.hpp" +#include "ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp" #include "ActsPlugins/Json/UtilitiesJsonConverter.hpp" -#include -#include #include #include -#include -#include -#include #include #include #include -namespace { - -/// @brief Convert the axes of a resolved multi-axis to json -nlohmann::json axesToJson(const Acts::IMultiAxis2D& multiAxis) { - nlohmann::json jAxes; - for (std::size_t i = 0; i < multiAxis.getNAxes(); ++i) { - jAxes.push_back(Acts::AxisJsonConverter::toJson(multiAxis.getAxis(i))); - } - return jAxes; -} - -/// @brief Convert the per-bin payload of a resolved multi-axis to json, -/// shaped as [localBins, value] entries over the regular (non-overflow) bins -/// -/// @tparam value_at_t callable, global bin index -> json-convertible value -template -nlohmann::json gridDataToJson(const Acts::IMultiAxis2D& multiAxis, - value_at_t&& valueAt) { - nlohmann::json jData; - Acts::IMultiAxis2D::LocalBins nBins = multiAxis.getNBins(); - for (std::size_t i0 = 1; i0 <= nBins[0]; ++i0) { - for (std::size_t i1 = 1; i1 <= nBins[1]; ++i1) { - Acts::IMultiAxis2D::LocalBins lbin{i0, i1}; - std::size_t bin = multiAxis.getGlobalBinFromLocalBins(lbin); - std::array jLbin{i0, i1}; - jData.push_back(nlohmann::json::array({jLbin, valueAt(bin)})); - } - } - return jData; -} - -/// @brief Convert a @c GridSurfaceMaterial to json -/// -/// Works generically for all 3 storage backends (direct / locally indexed / -/// globally indexed) via @c GridSurfaceMaterial::storage(), so it does not -/// need to enumerate concrete axis type combinations, nor dynamic_cast to a -/// per-backend wrapper type. -/// -/// @param jMaterial the json object to write into -/// @param gridMaterial the grid surface material to convert -void writeGridSurfaceMaterial(nlohmann::json& jMaterial, - const Acts::GridSurfaceMaterial& gridMaterial) { - jMaterial[Acts::jsonKey().typekey] = "grid"; - - const Acts::IMultiAxis2D& multiAxis = gridMaterial.multiAxis(); - - nlohmann::json jGrid; - jGrid["axes"] = axesToJson(multiAxis); - - nlohmann::json jMaterialAccessor; - std::visit( - [&](const T& storage) { - if constexpr (std::is_same_v) { - jMaterialAccessor["type"] = "direct"; - jGrid["data"] = gridDataToJson(multiAxis, [&](std::size_t bin) { - return nlohmann::json(storage.at(bin)); - }); - } else if constexpr (std::is_same_v< - T, Acts::GridSurfaceMaterial::Indexed>) { - jMaterialAccessor["type"] = "indexed"; - nlohmann::json jMaterialData; - for (const auto& msl : storage.material) { - jMaterialData.push_back(msl); - } - jMaterialAccessor["storage_vector"] = jMaterialData; - jGrid["data"] = gridDataToJson(multiAxis, [&](std::size_t bin) { - return nlohmann::json(storage.indices.at(bin)); - }); - } else { - jMaterialAccessor["type"] = "globally_indexed"; - jGrid["data"] = gridDataToJson(multiAxis, [&](std::size_t bin) { - return nlohmann::json(storage.indices.at(bin)); - }); - } - }, - gridMaterial.storage()); - - jMaterialAccessor["grid"] = jGrid; - jMaterial["accessor"] = jMaterialAccessor; -} - -/// @brief Reconstruct a 2D grid payload from the json "data" array -/// -/// @tparam value_type the grid payload type (MaterialSlab or std::size_t) -/// @param jData the json "data" array, entries shaped as [localBins, value] -/// @param nBins0 the number of bins along axis 0 -/// @param nBins1 the number of bins along axis 1 -template -std::vector> readGridPayload2D( - const nlohmann::json& jData, std::size_t nBins0, std::size_t nBins1) { - std::vector> payload(nBins0, - std::vector(nBins1)); - for (const auto& jd : jData) { - std::array lbin = jd[0u]; - if (!jd[1u].is_null()) { - payload[lbin[0u] - 1u][lbin[1u] - 1u] = jd[1u].get(); - } - } - return payload; -} - -/// @brief Read the locally indexed material vector from the json accessor -/// -/// @param jMaterialAccessor the json "accessor" object -/// @return the material vector, in storage order -std::vector readStorageVector( - const nlohmann::json& jMaterialAccessor) { - std::vector materialVector; - for (const auto& msl : jMaterialAccessor["storage_vector"]) { - Acts::MaterialSlab mat = Acts::MaterialSlab::Nothing(); - from_json(msl, mat); - materialVector.push_back(mat); - } - return materialVector; -} - -/// @brief Reconstruct a @c GridSurfaceMaterial from json -/// -/// Works generically for arbitrary axis type combinations via -/// @c GridSurfaceMaterial's @c IAxis-based factory methods, so it is not -/// limited to a hardcoded set of equidistant bound/closed axis combinations. -/// The grid is always 2D, matching @c GridSurfaceMaterial's requirement. -/// -/// @param jMaterial the json object to read from -/// @return a newly allocated surface material, or nullptr if unsupported -/// @throws std::runtime_error for "globally_indexed" material: the shared -/// material vector has no source at single-surface json scope -Acts::ISurfaceMaterial* gridSurfaceMaterialFromJson(nlohmann::json& jMaterial) { - nlohmann::json jMaterialAccessor = jMaterial["accessor"]; - std::string accessorType = jMaterialAccessor["type"]; - - if (accessorType == "globally_indexed") { - throw std::runtime_error( - "MaterialJsonConverter: reading a globally indexed " - "GridSurfaceMaterial from json is not supported - the shared " - "material vector has no source at single-surface json scope."); - } - - nlohmann::json jGrid = jMaterialAccessor["grid"]; - nlohmann::json jGridAxes = jGrid["axes"]; - nlohmann::json jData = jGrid["data"]; - - std::vector> axes; - for (const auto& jAxis : jGridAxes) { - axes.push_back(Acts::AxisJsonConverter::fromJson(jAxis)); - } - - if (axes.size() != 2u) { - return nullptr; - } - - if (accessorType == "direct") { - auto payload = readGridPayload2D( - jData, axes[0]->getNBins(), axes[1]->getNBins()); - return Acts::GridSurfaceMaterial::createDirect(*axes[0], *axes[1], payload) - .release(); - } - if (accessorType == "indexed") { - auto payload = readGridPayload2D(jData, axes[0]->getNBins(), - axes[1]->getNBins()); - return Acts::GridSurfaceMaterial::createIndexed( - *axes[0], *axes[1], readStorageVector(jMaterialAccessor), - payload) - .release(); - } - - return nullptr; -} - -} // namespace - void Acts::to_json(nlohmann::json& j, const Material& t) { if (t.isVacuum()) { return; @@ -259,159 +77,22 @@ void Acts::from_json(const nlohmann::json& j, MaterialSlabMatrix& t) { } void Acts::to_json(nlohmann::json& j, const surfaceMaterialPointer& material) { - nlohmann::json jMaterial; - // A bin utility needs to be written - const Acts::BinUtility* bUtility = nullptr; - - // Marker material left behind by a lossy portal merge. It carries no actual - // material, so only the type tag is written. - if (dynamic_cast(material) != nullptr) { - jMaterial[Acts::jsonKey().typekey] = "merged-material-marker"; - // Flag as "mapped" so the reader does not discard it. - jMaterial[Acts::jsonKey().mapkey] = true; - j[Acts::jsonKey().materialkey] = jMaterial; + if (material == nullptr) { return; } - - // First: Check if we have a proto material - auto psMaterial = dynamic_cast(material); - if (psMaterial != nullptr) { - // Type is proto material - jMaterial[Acts::jsonKey().typekey] = "proto"; - // Set mapping type - nlohmann::json mapType(material->mappingType()); - jMaterial[Acts::jsonKey().maptype] = mapType; - // by default the protoMaterial is not used for mapping - jMaterial[Acts::jsonKey().mapkey] = false; - // write the bin utility - bUtility = &(psMaterial->binning()); - // Check in the number of bin is different from 1 - auto& binningData = bUtility->binningData(); - for (std::size_t ibin = 0; ibin < binningData.size(); ++ibin) { - if (binningData[ibin].bins() > 1) { - jMaterial[Acts::jsonKey().mapkey] = true; - break; - } - } - nlohmann::json jBin(*bUtility); - jMaterial[Acts::jsonKey().binkey] = jBin; - j[Acts::jsonKey().materialkey] = jMaterial; - return; - } - - // Second: check if we have a homogeneous material - auto hsMaterial = - dynamic_cast(material); - if (hsMaterial != nullptr) { - // type is homogeneous - jMaterial[Acts::jsonKey().typekey] = "homogeneous"; - // Set mapping type - nlohmann::json mapType(material->mappingType()); - jMaterial[Acts::jsonKey().maptype] = mapType; - // Material has been mapped - jMaterial[Acts::jsonKey().mapkey] = true; - nlohmann::json jmat(hsMaterial->materialSlab()); - jMaterial[Acts::jsonKey().datakey] = nlohmann::json::array({ - nlohmann::json::array({ - jmat, - }), - }); - j[Acts::jsonKey().materialkey] = jMaterial; - return; - } - - // Next option remaining: BinnedSurface material - auto bsMaterial = dynamic_cast(material); - if (bsMaterial != nullptr) { - // type is binned - jMaterial[Acts::jsonKey().typekey] = "binned"; - // Set mapping type - nlohmann::json mapType(material->mappingType()); - jMaterial[Acts::jsonKey().maptype] = mapType; - // Material has been mapped - jMaterial[Acts::jsonKey().mapkey] = true; - bUtility = &(bsMaterial->binUtility()); - // convert the data - // get the material matrix - nlohmann::json mmat = nlohmann::json::array(); - for (const auto& mpVector : bsMaterial->fullMaterial()) { - nlohmann::json mvec = nlohmann::json::array(); - for (const auto& mp : mpVector) { - nlohmann::json jmat(mp); - mvec.push_back(jmat); - } - mmat.push_back(std::move(mvec)); - } - jMaterial[Acts::jsonKey().datakey] = std::move(mmat); - // write the bin utility - nlohmann::json jBin(*bUtility); - jMaterial[Acts::jsonKey().binkey] = jBin; - j[Acts::jsonKey().materialkey] = jMaterial; - return; - } - - // Grid-based surface material: direct, indexed and globally indexed - // storage all share the same I/O, branching only on the storage variant - if (auto gridMaterial = - dynamic_cast(material); - gridMaterial != nullptr) { - writeGridSurfaceMaterial(jMaterial, *gridMaterial); - j[Acts::jsonKey().materialkey] = jMaterial; - return; - } - - // No material the json object is left empty. - return; + j[Acts::jsonKey().materialkey] = + SurfaceMaterialJsonConverter::toJson(*material); } void Acts::from_json(const nlohmann::json& j, surfaceMaterialPointer& material) { - if (j.find(Acts::jsonKey().materialkey) == j.end()) { - return; - } - nlohmann::json jMaterial = j[Acts::jsonKey().materialkey]; - // By default no material is return. material = nullptr; - if (jMaterial[Acts::jsonKey().mapkey] == false) { - return; - } - - // Marker material left behind by a lossy portal merge - if (jMaterial.contains(Acts::jsonKey().typekey) && - jMaterial[Acts::jsonKey().typekey] == "merged-material-marker") { - material = new Acts::MergedMaterialMarker(); - return; - } - - // Grid based material maps - if (jMaterial[Acts::jsonKey().typekey] == "grid") { - material = gridSurfaceMaterialFromJson(jMaterial); + if (j.find(Acts::jsonKey().materialkey) == j.end()) { return; } - - // The bin utility and material - Acts::BinUtility bUtility; - Acts::MaterialSlabMatrix mpMatrix; - Acts::MappingType mapType = Acts::MappingType::Default; - for (auto& [key, value] : jMaterial.items()) { - if (key == Acts::jsonKey().binkey && !value.empty()) { - from_json(value, bUtility); - } - if (key == Acts::jsonKey().datakey && !value.empty()) { - from_json(value, mpMatrix); - } - if (key == Acts::jsonKey().maptype && !value.empty()) { - from_json(value, mapType); - } - } - // Return the appropriate typr of material - if (mpMatrix.empty()) { - material = new Acts::ProtoSurfaceMaterial(bUtility, mapType); - } else if (bUtility.bins() == 1) { - material = new Acts::HomogeneousSurfaceMaterial(mpMatrix[0][0], 1, mapType); - } else { - material = new Acts::BinnedSurfaceMaterial(bUtility, mpMatrix, 1, mapType); - } + material = + SurfaceMaterialJsonConverter::fromJson(j[Acts::jsonKey().materialkey]) + .release(); } void Acts::to_json(nlohmann::json& j, const volumeMaterialPointer& material) { diff --git a/Plugins/Json/src/MaterialMapJsonConverter.cpp b/Plugins/Json/src/MaterialMapJsonConverter.cpp index 81bc1138f00..6b6173d0025 100644 --- a/Plugins/Json/src/MaterialMapJsonConverter.cpp +++ b/Plugins/Json/src/MaterialMapJsonConverter.cpp @@ -38,6 +38,7 @@ #include "ActsPlugins/Json/IVolumeMaterialJsonDecorator.hpp" #include "ActsPlugins/Json/MaterialJsonConverter.hpp" #include "ActsPlugins/Json/SurfaceJsonConverter.hpp" +#include "ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp" #include "ActsPlugins/Json/VolumeJsonConverter.hpp" #include @@ -74,9 +75,10 @@ inline void decorateJson( } } template <> -inline void decorateJson( +inline void decorateJson>( const IVolumeMaterialJsonDecorator* decorator, - const Acts::ISurfaceMaterial* src, nlohmann::json& dest) { + const std::shared_ptr& src, + nlohmann::json& dest) { if (decorator != nullptr && src != nullptr) { decorator->decorate(*src, dest); } @@ -259,13 +261,14 @@ nlohmann::json Acts::MaterialMapJsonConverter::materialMapsToJson( nlohmann::json materialVolume = m_volumeMaterialConverter.toJson(hierarchyVolumeMap, decorator); SurfaceMaterialMaps surfaceMap = maps.first; - std::vector> + std::vector< + std::pair>> mapSurfaceInit; for (const auto& [key, value] : surfaceMap) { - mapSurfaceInit.push_back({key, value.get()}); + mapSurfaceInit.push_back({key, value}); } - GeometryHierarchyMap hierarchySurfaceMap( - mapSurfaceInit); + GeometryHierarchyMap> + hierarchySurfaceMap(mapSurfaceInit); nlohmann::json materialSurface = m_surfaceMaterialConverter.toJson(hierarchySurfaceMap, decorator); nlohmann::json materialMap; @@ -287,13 +290,13 @@ Acts::MaterialMapJsonConverter::jsonToMaterialMaps( volumeMap.insert({hierarchyVolumeMap.idAt(i), std::move(volumePointer)}); } nlohmann::json materialSurface = materialmap["Surfaces"]; - GeometryHierarchyMap hierarchySurfaceMap = - m_surfaceMaterialConverter.fromJson(materialSurface); + GeometryHierarchyMap> + hierarchySurfaceMap = + m_surfaceMaterialConverter.fromJson(materialSurface); SurfaceMaterialMaps surfaceMap; for (std::size_t i = 0; i < hierarchySurfaceMap.size(); i++) { - std::shared_ptr surfacePointer( - hierarchySurfaceMap.valueAt(i)); - surfaceMap.insert({hierarchySurfaceMap.idAt(i), std::move(surfacePointer)}); + surfaceMap.insert( + {hierarchySurfaceMap.idAt(i), hierarchySurfaceMap.valueAt(i)}); } Acts::TrackingGeometryMaterial maps = {surfaceMap, volumeMap}; diff --git a/Plugins/Json/src/SurfaceJsonConverter.cpp b/Plugins/Json/src/SurfaceJsonConverter.cpp index 5cb5023248d..86374c6affe 100644 --- a/Plugins/Json/src/SurfaceJsonConverter.cpp +++ b/Plugins/Json/src/SurfaceJsonConverter.cpp @@ -31,8 +31,9 @@ #include "Acts/Surfaces/SurfaceBounds.hpp" #include "Acts/Surfaces/TrapezoidBounds.hpp" #include "ActsPlugins/Json/GeometryIdentifierJsonConverter.hpp" -#include "ActsPlugins/Json/MaterialJsonConverter.hpp" +#include "ActsPlugins/Json/GeometryJsonKeys.hpp" #include "ActsPlugins/Json/SurfaceBoundsJsonConverter.hpp" +#include "ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp" #include @@ -139,8 +140,8 @@ nlohmann::json surfaceToJsonT(const surface_t& surface, jSurface["geo_id"] = nlohmann::json(surface.geometryId()); jSurface["sensitive"] = surface.isSensitive(); if (surface.hasMaterial() && opt.writeMaterial) { - jSurface["material"] = - nlohmann::json(surface.surfaceMaterial())["material"]; + jSurface[Acts::jsonKey().materialkey] = + Acts::SurfaceMaterialJsonConverter::toJson(*surface.surfaceMaterial()); } jSurface["kind"] = getSurfaceKind(); return jSurface; @@ -184,7 +185,7 @@ std::shared_ptr surfaceFromJsonT(const nlohmann::json& j) { void Acts::to_json(nlohmann::json& j, const Acts::SurfaceAndMaterialWithContext& surface) { toJson(j, std::get<0>(surface), std::get<2>(surface)); - to_json(j, std::get<1>(surface).get()); + to_json(j, std::get<1>(surface)); } void Acts::to_json(nlohmann::json& j, const Acts::Surface& surface) { @@ -346,12 +347,10 @@ std::shared_ptr Acts::SurfaceJsonConverter::fromJson( } mutableSf->assignIsSensitive(j["sensitive"].get()); - if (j.find("material") != j.end() && !j["material"].empty()) { - const ISurfaceMaterial* surfaceMaterial = nullptr; - from_json(j, surfaceMaterial); - std::shared_ptr sharedSurfaceMaterial( - surfaceMaterial); - mutableSf->assignSurfaceMaterial(sharedSurfaceMaterial); + if (j.find(jsonKey().materialkey) != j.end() && + !j[jsonKey().materialkey].empty()) { + mutableSf->assignSurfaceMaterial( + SurfaceMaterialJsonConverter::fromJson(j[jsonKey().materialkey])); } return mutableSf; } diff --git a/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp b/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp new file mode 100644 index 00000000000..8af17aa8110 --- /dev/null +++ b/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp @@ -0,0 +1,401 @@ +// This file is part of the ACTS project. +// +// Copyright (C) 2016 CERN for the benefit of the ACTS project +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#include "ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp" + +#include "Acts/Material/BinnedSurfaceMaterial.hpp" +#include "Acts/Material/GridSurfaceMaterial.hpp" +#include "Acts/Material/HomogeneousSurfaceMaterial.hpp" +#include "Acts/Material/MaterialSlab.hpp" +#include "Acts/Material/MergedMaterialMarker.hpp" +#include "Acts/Material/ProtoSurfaceMaterial.hpp" +#include "Acts/Utilities/BinUtility.hpp" +#include "Acts/Utilities/IAxis.hpp" +#include "Acts/Utilities/IMultiAxis.hpp" +#include "ActsPlugins/Json/AxisSpecJsonConverter.hpp" +#include "ActsPlugins/Json/GridJsonConverter.hpp" +#include "ActsPlugins/Json/MaterialJsonConverter.hpp" +#include "ActsPlugins/Json/UtilitiesJsonConverter.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace Acts; + +/// Payload type tags, shared between the encoder and the decoder +constexpr const char* kHomogeneousTag = "homogeneous"; +constexpr const char* kBinnedTag = "binned"; +constexpr const char* kProtoTag = "proto"; +constexpr const char* kProtoGridTag = "proto-grid"; +constexpr const char* kMergedMarkerTag = "merged-material-marker"; +constexpr const char* kGridTag = "grid"; + +/// Grid accessor tags +constexpr const char* kDirectAccessorTag = "direct"; +constexpr const char* kIndexedAccessorTag = "indexed"; +constexpr const char* kGloballyIndexedAccessorTag = "globally_indexed"; + +nlohmann::json homogeneousToJson(const HomogeneousSurfaceMaterial& material) { + nlohmann::json jMaterial; + jMaterial[jsonKey().typekey] = kHomogeneousTag; + jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); + jMaterial[jsonKey().mapkey] = true; + nlohmann::json jSlab(material.materialSlab()); + jMaterial[jsonKey().datakey] = nlohmann::json::array({ + nlohmann::json::array({ + jSlab, + }), + }); + return jMaterial; +} + +nlohmann::json binnedToJson(const BinnedSurfaceMaterial& material) { + nlohmann::json jMaterial; + jMaterial[jsonKey().typekey] = kBinnedTag; + jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); + jMaterial[jsonKey().mapkey] = true; + + nlohmann::json jMatrix = nlohmann::json::array(); + for (const auto& mpVector : material.fullMaterial()) { + nlohmann::json jVector = nlohmann::json::array(); + for (const auto& mp : mpVector) { + jVector.push_back(nlohmann::json(mp)); + } + jMatrix.push_back(std::move(jVector)); + } + jMaterial[jsonKey().datakey] = std::move(jMatrix); + jMaterial[jsonKey().binkey] = nlohmann::json(material.binUtility()); + return jMaterial; +} + +nlohmann::json protoToJson(const ProtoSurfaceMaterial& material) { + nlohmann::json jMaterial; + jMaterial[jsonKey().typekey] = kProtoTag; + jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); + // A proto material without any actual binning is not mapped onto + jMaterial[jsonKey().mapkey] = false; + const BinUtility& bUtility = material.binning(); + for (const auto& bData : bUtility.binningData()) { + if (bData.bins() > 1) { + jMaterial[jsonKey().mapkey] = true; + break; + } + } + jMaterial[jsonKey().binkey] = nlohmann::json(bUtility); + return jMaterial; +} + +nlohmann::json protoGridToJson(const ProtoGridSurfaceMaterial& material) { + nlohmann::json jMaterial; + jMaterial[jsonKey().typekey] = kProtoGridTag; + jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); + jMaterial[jsonKey().mapkey] = true; + jMaterial["axis_specs"] = + MultiAxisSpecJsonConverter::toJson(material.binning()); + return jMaterial; +} + +nlohmann::json mergedMarkerToJson(const MergedMaterialMarker& /*material*/) { + nlohmann::json jMaterial; + jMaterial[jsonKey().typekey] = kMergedMarkerTag; + // Flag as "mapped" so the reader does not discard it + jMaterial[jsonKey().mapkey] = true; + return jMaterial; +} + +/// Convert the axes of a resolved multi-axis +nlohmann::json axesToJson(const IMultiAxis2D& multiAxis) { + nlohmann::json jAxes = nlohmann::json::array(); + for (std::size_t ia = 0u; ia < multiAxis.getNAxes(); ++ia) { + jAxes.push_back(AxisJsonConverter::toJson(multiAxis.getAxis(ia))); + } + return jAxes; +} + +/// Write the per bin payload as [local bins, value] entries over the regular +/// bins. The local bins are 1-based, the storage is addressed by global bin. +template +nlohmann::json gridDataToJson(const IMultiAxis2D& multiAxis, + value_at_t&& valueAt) { + nlohmann::json jData = nlohmann::json::array(); + IMultiAxis2D::LocalBins nBins = multiAxis.getNBins(); + for (std::size_t ib0 = 1u; ib0 <= nBins[0u]; ++ib0) { + for (std::size_t ib1 = 1u; ib1 <= nBins[1u]; ++ib1) { + IMultiAxis2D::LocalBins lBin{ib0, ib1}; + jData.push_back(nlohmann::json::array( + {std::array{ib0, ib1}, + valueAt(multiAxis.getGlobalBinFromLocalBins(lBin))})); + } + } + return jData; +} + +nlohmann::json slabsToJson(const std::vector& slabs) { + nlohmann::json jSlabs = nlohmann::json::array(); + for (const auto& msl : slabs) { + jSlabs.push_back(nlohmann::json(msl)); + } + return jSlabs; +} + +/// Encoder for the whole grid material family. The storage backend is +/// resolved at runtime through the storage variant, so the concrete axis +/// types never appear here. +nlohmann::json gridMaterialToJson(const GridSurfaceMaterial& material) { + nlohmann::json jMaterial; + jMaterial[jsonKey().typekey] = kGridTag; + jMaterial[jsonKey().mapkey] = true; + + const IMultiAxis2D& multiAxis = material.multiAxis(); + nlohmann::json jGrid; + jGrid["axes"] = axesToJson(multiAxis); + + nlohmann::json jAccessor; + std::visit( + [&](const storage_t& storage) { + if constexpr (std::is_same_v) { + jAccessor["type"] = kDirectAccessorTag; + jGrid["data"] = gridDataToJson(multiAxis, [&](std::size_t bin) { + return nlohmann::json(storage.at(bin)); + }); + } else { + constexpr bool isLocal = + std::is_same_v; + jAccessor["type"] = + isLocal ? kIndexedAccessorTag : kGloballyIndexedAccessorTag; + // The slab store travels with the payload, so that a globally + // indexed grid can be read back on its own + if constexpr (isLocal) { + jAccessor["storage_vector"] = slabsToJson(storage.material); + } else { + if (storage.material == nullptr) { + throw std::invalid_argument( + "SurfaceMaterialJsonConverter: globally indexed material " + "without a slab store"); + } + jAccessor["storage_vector"] = slabsToJson(*storage.material); + } + jGrid["data"] = gridDataToJson(multiAxis, [&](std::size_t bin) { + return nlohmann::json(storage.indices.at(bin)); + }); + } + }, + material.storage()); + + jAccessor["grid"] = std::move(jGrid); + jMaterial["accessor"] = std::move(jAccessor); + return jMaterial; +} + +MappingType readMappingType(const nlohmann::json& jMaterial) { + MappingType mappingType = MappingType::Default; + if (jMaterial.contains(jsonKey().maptype) && + !jMaterial.at(jsonKey().maptype).is_null()) { + from_json(jMaterial.at(jsonKey().maptype), mappingType); + } + return mappingType; +} + +std::unique_ptr homogeneousFromJson( + const nlohmann::json& jMaterial) { + MaterialSlabMatrix matrix; + from_json(jMaterial.at(jsonKey().datakey), matrix); + if (matrix.empty() || matrix[0].empty()) { + throw std::invalid_argument( + "SurfaceMaterialJsonConverter: homogeneous material without data"); + } + return std::make_unique( + matrix[0][0], 1., readMappingType(jMaterial)); +} + +std::unique_ptr binnedFromJson( + const nlohmann::json& jMaterial) { + BinUtility bUtility; + from_json(jMaterial.at(jsonKey().binkey), bUtility); + MaterialSlabMatrix matrix; + from_json(jMaterial.at(jsonKey().datakey), matrix); + return std::make_unique( + bUtility, std::move(matrix), 1., readMappingType(jMaterial)); +} + +std::unique_ptr protoFromJson( + const nlohmann::json& jMaterial) { + BinUtility bUtility; + if (jMaterial.contains(jsonKey().binkey) && + !jMaterial.at(jsonKey().binkey).is_null()) { + from_json(jMaterial.at(jsonKey().binkey), bUtility); + } + return std::make_unique( + bUtility, readMappingType(jMaterial)); +} + +std::unique_ptr protoGridFromJson( + const nlohmann::json& jMaterial) { + MultiAxisSpec spec = + MultiAxisSpecJsonConverter::fromJson(jMaterial.at("axis_specs")); + if (spec.size() != 2u) { + throw std::invalid_argument( + "SurfaceMaterialJsonConverter: proto grid material needs exactly two " + "axis specs"); + } + MultiAxisSpec2D spec2D{ + std::array{spec.axisSpec(0u), spec.axisSpec(1u)}}; + return std::make_unique( + spec2D, readMappingType(jMaterial)); +} + +std::unique_ptr mergedMarkerFromJson( + const nlohmann::json& /*jMaterial*/) { + return std::make_unique(); +} + +/// Read the 2D grid payload in column major order, i.e. [i0][i1], from the +/// 1-based local bins the writer emits +template +std::vector> gridPayload2D(const nlohmann::json& jGrid, + std::size_t nBins0, + std::size_t nBins1, + const value_t& fill) { + std::vector> payload(nBins0, + std::vector(nBins1, fill)); + for (const auto& jEntry : jGrid.at("data")) { + std::array lBin = jEntry.at(0u); + if (jEntry.at(1u).is_null() || lBin[0u] < 1u || lBin[0u] > nBins0 || + lBin[1u] < 1u || lBin[1u] > nBins1) { + continue; + } + payload[lBin[0u] - 1u][lBin[1u] - 1u] = + jEntry.at(1u).template get(); + } + return payload; +} + +std::vector slabsFromJson(const nlohmann::json& jSlabs) { + std::vector slabs; + slabs.reserve(jSlabs.size()); + for (const auto& jSlab : jSlabs) { + MaterialSlab slab = MaterialSlab::Nothing(); + from_json(jSlab, slab); + slabs.push_back(slab); + } + return slabs; +} + +std::unique_ptr gridFromJson( + const nlohmann::json& jMaterial) { + const nlohmann::json& jAccessor = jMaterial.at("accessor"); + const nlohmann::json& jGrid = jAccessor.at("grid"); + const nlohmann::json& jAxes = jGrid.at("axes"); + std::string accessorType = jAccessor.at("type").get(); + + if (jAxes.size() != 2u) { + throw std::invalid_argument( + "SurfaceMaterialJsonConverter: grid material needs exactly two axes"); + } + std::unique_ptr axis0 = AxisJsonConverter::fromJson(jAxes.at(0u)); + std::unique_ptr axis1 = AxisJsonConverter::fromJson(jAxes.at(1u)); + + if (accessorType == kDirectAccessorTag) { + return GridSurfaceMaterial::createDirect( + *axis0, *axis1, + gridPayload2D(jGrid, axis0->getNBins(), axis1->getNBins(), + MaterialSlab::Nothing())); + } + + auto indices = gridPayload2D(jGrid, axis0->getNBins(), + axis1->getNBins(), std::size_t{0u}); + if (accessorType == kIndexedAccessorTag) { + return GridSurfaceMaterial::createIndexed( + *axis0, *axis1, slabsFromJson(jAccessor.at("storage_vector")), indices); + } + if (accessorType == kGloballyIndexedAccessorTag) { + return GridSurfaceMaterial::createGloballyIndexed( + *axis0, *axis1, + std::make_shared>( + slabsFromJson(jAccessor.at("storage_vector"))), + indices); + } + throw std::invalid_argument( + "SurfaceMaterialJsonConverter: unsupported grid material accessor: " + + accessorType); +} + +} // namespace + +Acts::SurfaceMaterialJsonConverter::Config +Acts::SurfaceMaterialJsonConverter::Config::defaultConfig() { + Config cfg; + + cfg.encoder.registerFunction(homogeneousToJson); + cfg.encoder.registerFunction(binnedToJson); + cfg.encoder.registerFunction(protoToJson); + cfg.encoder.registerFunction(protoGridToJson); + cfg.encoder.registerFunction(mergedMarkerToJson); + // One concrete class covers the whole grid material family, the storage + // backend is a runtime variant rather than a template parameter + cfg.encoder.registerFunction(gridMaterialToJson); + + cfg.decoder.registerKind(kHomogeneousTag, homogeneousFromJson); + cfg.decoder.registerKind(kBinnedTag, binnedFromJson); + cfg.decoder.registerKind(kProtoTag, protoFromJson); + cfg.decoder.registerKind(kProtoGridTag, protoGridFromJson); + cfg.decoder.registerKind(kMergedMarkerTag, mergedMarkerFromJson); + cfg.decoder.registerKind(kGridTag, gridFromJson); + + return cfg; +} + +const Acts::SurfaceMaterialJsonConverter::Config& +Acts::SurfaceMaterialJsonConverter::defaultConfig() { + static const Config cfg = Config::defaultConfig(); + return cfg; +} + +nlohmann::json Acts::SurfaceMaterialJsonConverter::toJson( + const ISurfaceMaterial& material, const Config& config) { + return config.encoder(material); +} + +std::unique_ptr +Acts::SurfaceMaterialJsonConverter::fromJson(const nlohmann::json& jMaterial, + const Config& config) { + // Surfaces that are flagged out of the mapping carry no material + if (jMaterial.contains(jsonKey().mapkey) && + jMaterial.at(jsonKey().mapkey) == false) { + return nullptr; + } + return config.decoder(jMaterial); +} + +void Acts::to_json(nlohmann::json& j, + const std::shared_ptr& material) { + if (material == nullptr) { + return; + } + j[jsonKey().materialkey] = SurfaceMaterialJsonConverter::toJson(*material); +} + +void Acts::from_json(const nlohmann::json& j, + std::shared_ptr& material) { + material = nullptr; + if (!j.contains(jsonKey().materialkey) || + j.at(jsonKey().materialkey).is_null()) { + return; + } + material = + SurfaceMaterialJsonConverter::fromJson(j.at(jsonKey().materialkey)); +} diff --git a/Tests/UnitTests/Plugins/Json/CMakeLists.txt b/Tests/UnitTests/Plugins/Json/CMakeLists.txt index 4a0f1652200..a939a040dd9 100644 --- a/Tests/UnitTests/Plugins/Json/CMakeLists.txt +++ b/Tests/UnitTests/Plugins/Json/CMakeLists.txt @@ -13,6 +13,7 @@ add_unittest(ProtoAxisJsonConverter ProtoAxisJsonConverterTests.cpp) add_unittest(UtilitiesJsonConverter UtilitiesJsonConverterTests.cpp) add_unittest(SurfaceBoundsJsonConverter SurfaceBoundsJsonConverterTests.cpp) add_unittest(SurfaceJsonConverter SurfaceJsonConverterTests.cpp) +add_unittest(SurfaceMaterialJsonConverter SurfaceMaterialJsonConverterTests.cpp) add_unittest(VolumeBoundsJsonConverter VolumeBoundsJsonConverterTests.cpp) add_unittest(TrackParametersJsonConverter TrackParametersJsonConverterTests.cpp) add_unittest(JsonSurfacesReader JsonSurfacesReaderTests.cpp) diff --git a/Tests/UnitTests/Plugins/Json/MaterialJsonConverterTests.cpp b/Tests/UnitTests/Plugins/Json/MaterialJsonConverterTests.cpp index e062c2c6890..bf0e5eed9b5 100644 --- a/Tests/UnitTests/Plugins/Json/MaterialJsonConverterTests.cpp +++ b/Tests/UnitTests/Plugins/Json/MaterialJsonConverterTests.cpp @@ -15,7 +15,7 @@ #include "Acts/Utilities/AxisDefinitions.hpp" #include "Acts/Utilities/IAxis.hpp" #include "ActsPlugins/Json/GridJsonConverter.hpp" -#include "ActsPlugins/Json/MaterialJsonConverter.hpp" +#include "ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp" #include #include @@ -53,23 +53,22 @@ BOOST_AUTO_TEST_CASE(IndexedSurfaceMaterial2DTests) { auto ism = GridSurfaceMaterial::createIndexed(*axisZ, *axisPhi, material, indexPayload); - nlohmann::json jMaterial = ism.get(); + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(*ism); // Run a few tests - BOOST_REQUIRE(jMaterial.find("material") != jMaterial.end()); - BOOST_CHECK_EQUAL(jMaterial["material"]["type"], "grid"); - BOOST_CHECK_EQUAL(jMaterial["material"]["accessor"]["type"], "indexed"); - BOOST_CHECK(!jMaterial["material"].contains("global_to_grid_local")); - BOOST_CHECK(!jMaterial["material"].contains("bound_to_grid_local")); + BOOST_CHECK_EQUAL(jMaterial["type"], "grid"); + BOOST_CHECK_EQUAL(jMaterial["accessor"]["type"], "indexed"); + BOOST_CHECK(!jMaterial.contains("global_to_grid_local")); + BOOST_CHECK(!jMaterial.contains("bound_to_grid_local")); // Read it back in - const ISurfaceMaterial* ismRead = nullptr; - from_json(jMaterial, ismRead); + auto ismRead = SurfaceMaterialJsonConverter::fromJson(jMaterial); BOOST_REQUIRE(ismRead != nullptr); // Check if it's the right type - the reader always resolves "grid" json // into the concrete GridSurfaceMaterial class - const auto* ismReadTyped = dynamic_cast(ismRead); + const auto* ismReadTyped = + dynamic_cast(ismRead.get()); BOOST_REQUIRE(ismReadTyped != nullptr); Vector2 l0(-0.5, -std::numbers::pi * 0.75); @@ -90,8 +89,6 @@ BOOST_AUTO_TEST_CASE(IndexedSurfaceMaterial2DTests) { BOOST_CHECK_EQUAL(indexed.material[1].material().X0(), 1.); BOOST_CHECK_EQUAL(indexed.material[2].material().X0(), 11.); BOOST_CHECK_EQUAL(indexed.material[3].material().X0(), 21.); - - delete ismRead; } BOOST_AUTO_TEST_CASE(GridSurfaceMaterialDirectStorageRoundTrip) { @@ -118,23 +115,22 @@ BOOST_AUTO_TEST_CASE(GridSurfaceMaterialDirectStorageRoundTrip) { auto gsm = GridSurfaceMaterial::createDirect(*axisX, *axisY, material2x2); BOOST_REQUIRE(gsm != nullptr); - nlohmann::json jMaterial = gsm.get(); + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(*gsm); - BOOST_REQUIRE(jMaterial.find("material") != jMaterial.end()); - BOOST_CHECK_EQUAL(jMaterial["material"]["type"], "grid"); - BOOST_CHECK_EQUAL(jMaterial["material"]["accessor"]["type"], "direct"); - BOOST_REQUIRE(jMaterial["material"]["accessor"].contains("grid")); - BOOST_CHECK(jMaterial["material"]["accessor"]["grid"].contains("axes")); - BOOST_CHECK(jMaterial["material"]["accessor"]["grid"].contains("data")); - BOOST_CHECK(!jMaterial["material"].contains("global_to_grid_local")); - BOOST_CHECK(!jMaterial["material"].contains("bound_to_grid_local")); + BOOST_CHECK_EQUAL(jMaterial["type"], "grid"); + BOOST_CHECK_EQUAL(jMaterial["accessor"]["type"], "direct"); + BOOST_REQUIRE(jMaterial["accessor"].contains("grid")); + BOOST_CHECK(jMaterial["accessor"]["grid"].contains("axes")); + BOOST_CHECK(jMaterial["accessor"]["grid"].contains("data")); + BOOST_CHECK(!jMaterial.contains("global_to_grid_local")); + BOOST_CHECK(!jMaterial.contains("bound_to_grid_local")); // Read it back in - const ISurfaceMaterial* gsmRead = nullptr; - from_json(jMaterial, gsmRead); + auto gsmRead = SurfaceMaterialJsonConverter::fromJson(jMaterial); BOOST_REQUIRE(gsmRead != nullptr); - const auto* gsmReadTyped = dynamic_cast(gsmRead); + const auto* gsmReadTyped = + dynamic_cast(gsmRead.get()); BOOST_REQUIRE(gsmReadTyped != nullptr); BOOST_CHECK( @@ -145,26 +141,20 @@ BOOST_AUTO_TEST_CASE(GridSurfaceMaterialDirectStorageRoundTrip) { gsmReadTyped->materialSlab(Vector2{1.5, 0.5}).material().X0(), 11.); BOOST_CHECK_EQUAL( gsmReadTyped->materialSlab(Vector2{1.5, 1.5}).material().X0(), 21.); - - delete gsmRead; } BOOST_AUTO_TEST_CASE(MergedMaterialMarkerRoundTrip) { // The marker left behind by a lossy portal merge must survive a JSON // round-trip so it can be picked up by downstream tooling. - const ISurfaceMaterial* marker = new MergedMaterialMarker(); + MergedMaterialMarker marker; - nlohmann::json jMaterial = marker; - BOOST_REQUIRE(jMaterial.find("material") != jMaterial.end()); - BOOST_CHECK_EQUAL(jMaterial["material"]["type"], "merged-material-marker"); + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(marker); + BOOST_CHECK_EQUAL(jMaterial["type"], "merged-material-marker"); - const ISurfaceMaterial* markerRead = nullptr; - from_json(jMaterial, markerRead); + auto markerRead = SurfaceMaterialJsonConverter::fromJson(jMaterial); BOOST_REQUIRE(markerRead != nullptr); - BOOST_CHECK(dynamic_cast(markerRead) != nullptr); - - delete marker; - delete markerRead; + BOOST_CHECK(dynamic_cast(markerRead.get()) != + nullptr); } BOOST_AUTO_TEST_SUITE_END() diff --git a/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp b/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp new file mode 100644 index 00000000000..309ab6a3b18 --- /dev/null +++ b/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp @@ -0,0 +1,367 @@ +// This file is part of the ACTS project. +// +// Copyright (C) 2016 CERN for the benefit of the ACTS project +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#include + +#include "Acts/Material/BinnedSurfaceMaterial.hpp" +#include "Acts/Material/GridSurfaceMaterial.hpp" +#include "Acts/Material/HomogeneousSurfaceMaterial.hpp" +#include "Acts/Material/Material.hpp" +#include "Acts/Material/MaterialSlab.hpp" +#include "Acts/Material/MergedMaterialMarker.hpp" +#include "Acts/Material/ProtoSurfaceMaterial.hpp" +#include "Acts/Utilities/AxisDefinitions.hpp" +#include "Acts/Utilities/AxisSpec.hpp" +#include "Acts/Utilities/BinUtility.hpp" +#include "Acts/Utilities/IAxis.hpp" +#include "Acts/Utilities/MultiAxisSpec.hpp" +#include "ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp" +#include "ActsTests/CommonHelpers/FloatComparisons.hpp" + +#include +#include +#include +#include +#include + +#include + +using namespace Acts; + +namespace ActsTests { + +namespace { + +/// A few material slabs shared by the index based tests +std::vector testSlabs() { + std::vector material; + material.emplace_back(Material::Vacuum(), 0.0); + material.emplace_back(Material::fromMolarDensity(1.0, 2.0, 3.0, 4.0, 5.0), + 1.0); + material.emplace_back( + Material::fromMolarDensity(11.0, 12.0, 13.0, 14.0, 15.0), 2.0); + material.emplace_back( + Material::fromMolarDensity(21.0, 22.0, 23.0, 24.0, 25.0), 3.0); + return material; +} + +/// Grid material is always 2D now, these are the axes the tests bin on +std::unique_ptr testAxis0() { + return IAxis::createEquidistant(AxisBoundaryType::Bound, -1., 1., 2); +} + +std::unique_ptr testAxis1() { + return IAxis::createEquidistant(AxisBoundaryType::Closed, -std::numbers::pi, + std::numbers::pi, 4); +} + +/// Index payload in column major order, i.e. [i0][i1]. Index 0 is vacuum and +/// several bins share an entry, which is what the index storage is for. +std::vector> testIndexPayload() { + return {std::vector{1u, 1u, 0u, 2u}, + std::vector{0u, 3u, 3u, 0u}}; +} + +std::unique_ptr makeIndexed() { + auto axis0 = testAxis0(); + auto axis1 = testAxis1(); + return GridSurfaceMaterial::createIndexed(*axis0, *axis1, testSlabs(), + testIndexPayload()); +} + +std::unique_ptr makeGloballyIndexed( + std::shared_ptr> store = nullptr) { + if (store == nullptr) { + store = std::make_shared>(testSlabs()); + } + auto axis0 = testAxis0(); + auto axis1 = testAxis1(); + return GridSurfaceMaterial::createGloballyIndexed( + *axis0, *axis1, std::move(store), testIndexPayload()); +} + +std::unique_ptr makeDirect() { + auto slabs = testSlabs(); + std::vector> payload{ + {slabs[1], slabs[1], slabs[0], slabs[2]}, + {slabs[0], slabs[3], slabs[3], slabs[0]}}; + auto axis0 = testAxis0(); + auto axis1 = testAxis1(); + return GridSurfaceMaterial::createDirect(*axis0, *axis1, payload); +} + +/// The local points that address the four phi bins of the two z bins +std::vector testPoints() { + return {{-0.5, -std::numbers::pi * 0.75}, {-0.5, -std::numbers::pi / 4.}, + {-0.5, std::numbers::pi / 4.}, {-0.5, std::numbers::pi * 0.75}, + {0.5, -std::numbers::pi * 0.75}, {0.5, -std::numbers::pi / 4.}, + {0.5, std::numbers::pi / 4.}, {0.5, std::numbers::pi * 0.75}}; +} + +BinUtility testBinUtility2D() { + BinUtility bUtility(2, -1., 1., open, AxisDirection::AxisX); + bUtility += BinUtility(3, -3., 3., open, AxisDirection::AxisY); + return bUtility; +} + +/// The matrix is indexed [bin of the second binning][bin of the first] +MaterialSlabMatrix testMatrix2D() { + auto slabs = testSlabs(); + MaterialSlabMatrix matrix; + for (std::size_t i1 = 0; i1 < 3; ++i1) { + MaterialSlabVector row; + for (std::size_t i0 = 0; i0 < 2; ++i0) { + row.push_back(slabs[(i1 * 2 + i0) % slabs.size()]); + } + matrix.push_back(std::move(row)); + } + return matrix; +} + +/// Round trip a material through the converter +std::unique_ptr roundTrip( + const ISurfaceMaterial& material) { + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(material); + return SurfaceMaterialJsonConverter::fromJson(jMaterial); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(JsonSuite) + +BOOST_AUTO_TEST_CASE(HomogeneousSurfaceMaterialRoundTrip) { + HomogeneousSurfaceMaterial hsm( + MaterialSlab(Material::fromMolarDensity(1.0, 2.0, 3.0, 4.0, 5.0), 1.5), + 1., MappingType::PostMapping); + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(hsm); + BOOST_CHECK_EQUAL(jMaterial["type"], "homogeneous"); + + auto read = roundTrip(hsm); + BOOST_REQUIRE(read != nullptr); + const auto* typed = + dynamic_cast(read.get()); + BOOST_REQUIRE(typed != nullptr); + BOOST_CHECK(typed->mappingType() == MappingType::PostMapping); + CHECK_CLOSE_ABS(typed->materialSlab(Vector2{0., 0.}).thickness(), 1.5, 1e-5); +} + +BOOST_AUTO_TEST_CASE(BinnedSurfaceMaterialRoundTrip) { + BinnedSurfaceMaterial bsm(testBinUtility2D(), testMatrix2D(), 1., + MappingType::Sensor); + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(bsm); + BOOST_CHECK_EQUAL(jMaterial["type"], "binned"); + + auto read = roundTrip(bsm); + BOOST_REQUIRE(read != nullptr); + const auto* typed = dynamic_cast(read.get()); + BOOST_REQUIRE(typed != nullptr); + BOOST_CHECK(typed->mappingType() == MappingType::Sensor); + BOOST_CHECK(typed->binUtility() == bsm.binUtility()); + + for (double x : {-0.5, 0.5}) { + for (double y : {-2., 0., 2.}) { + Vector2 lp{x, y}; + CHECK_CLOSE_ABS(typed->materialSlab(lp).thickness(), + bsm.materialSlab(lp).thickness(), 1e-5); + } + } +} + +BOOST_AUTO_TEST_CASE(ProtoSurfaceMaterialRoundTrip) { + ProtoSurfaceMaterial psm(testBinUtility2D(), MappingType::PreMapping); + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(psm); + BOOST_CHECK_EQUAL(jMaterial["type"], "proto"); + BOOST_CHECK_EQUAL(jMaterial["mapMaterial"], true); + + auto read = roundTrip(psm); + BOOST_REQUIRE(read != nullptr); + const auto* typed = dynamic_cast(read.get()); + BOOST_REQUIRE(typed != nullptr); + BOOST_CHECK(typed->mappingType() == MappingType::PreMapping); + BOOST_CHECK(typed->binning() == psm.binning()); +} + +BOOST_AUTO_TEST_CASE(ProtoGridSurfaceMaterialRoundTrip) { + MultiAxisSpec2D spec{std::array{ + AxisSpec::Equidistant(4u, -1., 1., AxisBoundaryType::Bound, + AxisDirection::AxisX), + AxisSpec::DeferredVariable({0., 0.25, 1.}, AxisBoundaryType::Bound, + AxisDirection::AxisY)}}; + ProtoGridSurfaceMaterial pgsm(spec, MappingType::PostMapping); + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(pgsm); + BOOST_CHECK_EQUAL(jMaterial["type"], "proto-grid"); + + auto read = roundTrip(pgsm); + BOOST_REQUIRE(read != nullptr); + const auto* typed = dynamic_cast(read.get()); + BOOST_REQUIRE(typed != nullptr); + BOOST_CHECK(typed->mappingType() == MappingType::PostMapping); + BOOST_CHECK(typed->binning() == pgsm.binning()); +} + +BOOST_AUTO_TEST_CASE(MergedMaterialMarkerRoundTrip) { + MergedMaterialMarker marker; + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(marker); + BOOST_CHECK_EQUAL(jMaterial["type"], "merged-material-marker"); + + auto read = roundTrip(marker); + BOOST_REQUIRE(read != nullptr); + BOOST_CHECK(dynamic_cast(read.get()) != nullptr); +} + +BOOST_AUTO_TEST_CASE(IndexedGridMaterialRoundTrip) { + auto ism = makeIndexed(); + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(*ism); + BOOST_CHECK_EQUAL(jMaterial["type"], "grid"); + BOOST_CHECK_EQUAL(jMaterial["accessor"]["type"], "indexed"); + BOOST_CHECK_EQUAL(jMaterial["accessor"]["grid"]["axes"].size(), 2u); + // The delegates are gone with the multi-axis migration + BOOST_CHECK(!jMaterial.contains("bound_to_grid_local")); + BOOST_CHECK(!jMaterial.contains("global_to_grid_local")); + + auto read = roundTrip(*ism); + BOOST_REQUIRE(read != nullptr); + const auto* typed = dynamic_cast(read.get()); + BOOST_REQUIRE(typed != nullptr); + const auto& indexed = + std::get(typed->storage()); + BOOST_CHECK_EQUAL(indexed.material.size(), testSlabs().size()); + + for (const Vector2& lp : testPoints()) { + CHECK_CLOSE_ABS(typed->materialSlab(lp).thickness(), + ism->materialSlab(lp).thickness(), 1e-5); + } +} + +BOOST_AUTO_TEST_CASE(GloballyIndexedGridMaterialRoundTrip) { + auto gism = makeGloballyIndexed(); + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(*gism); + BOOST_CHECK_EQUAL(jMaterial["type"], "grid"); + BOOST_CHECK_EQUAL(jMaterial["accessor"]["type"], "globally_indexed"); + // The store travels with the payload, so this reads back standalone. The + // free-function reader on main refuses this case outright. + BOOST_CHECK(jMaterial["accessor"].contains("storage_vector")); + + auto read = roundTrip(*gism); + BOOST_REQUIRE(read != nullptr); + const auto* typed = dynamic_cast(read.get()); + BOOST_REQUIRE(typed != nullptr); + // The globally indexed storage must survive, and its store must be filled + const auto& global = + std::get(typed->storage()); + BOOST_REQUIRE(global.material != nullptr); + BOOST_CHECK_EQUAL(global.material->size(), testSlabs().size()); + + for (const Vector2& lp : testPoints()) { + CHECK_CLOSE_ABS(typed->materialSlab(lp).thickness(), + gism->materialSlab(lp).thickness(), 1e-5); + } +} + +BOOST_AUTO_TEST_CASE(DirectGridMaterialRoundTrip) { + auto gsm = makeDirect(); + + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson(*gsm); + BOOST_CHECK_EQUAL(jMaterial["type"], "grid"); + BOOST_CHECK_EQUAL(jMaterial["accessor"]["type"], "direct"); + BOOST_CHECK(!jMaterial["accessor"].contains("storage_vector")); + + auto read = roundTrip(*gsm); + BOOST_REQUIRE(read != nullptr); + const auto* typed = dynamic_cast(read.get()); + BOOST_REQUIRE(typed != nullptr); + BOOST_CHECK( + std::holds_alternative(typed->storage())); + + for (const Vector2& lp : testPoints()) { + CHECK_CLOSE_ABS(typed->materialSlab(lp).thickness(), + gsm->materialSlab(lp).thickness(), 1e-5); + } +} + +BOOST_AUTO_TEST_CASE(EncoderCoversAllSurfaceMaterials) { + const auto& cfg = SurfaceMaterialJsonConverter::defaultConfig(); + + std::vector> materials; + materials.push_back(std::make_shared( + MaterialSlab(Material::fromMolarDensity(1.0, 2.0, 3.0, 4.0, 5.0), 1.))); + materials.push_back(std::make_shared( + testBinUtility2D(), testMatrix2D())); + materials.push_back( + std::make_shared(testBinUtility2D())); + materials.push_back(std::make_shared( + MultiAxisSpec2D{std::array{ + AxisSpec::Equidistant(2u, 0., 1., AxisBoundaryType::Bound, + AxisDirection::AxisX), + AxisSpec::Equidistant(2u, 0., 1., AxisBoundaryType::Bound, + AxisDirection::AxisY)}})); + materials.push_back(std::make_shared()); + materials.push_back(makeIndexed()); + materials.push_back(makeGloballyIndexed()); + materials.push_back(makeDirect()); + + for (std::size_t im = 0; im < materials.size(); ++im) { + const auto& material = materials[im]; + BOOST_TEST_CONTEXT("material " << im) { + // Exactly one encoder must claim the type, otherwise the call throws + BOOST_CHECK(cfg.encoder.hasFunction(*material)); + nlohmann::json jMaterial; + BOOST_REQUIRE_NO_THROW( + jMaterial = SurfaceMaterialJsonConverter::toJson(*material)); + // Every tag the encoder can emit must be known to the decoder + BOOST_CHECK(cfg.decoder.hasKind(jMaterial["type"].get())); + } + } + + // One concrete class carries the whole grid material family + BOOST_CHECK(cfg.encoder.hasFunction()); + BOOST_CHECK(cfg.encoder.hasFunction()); + BOOST_CHECK(cfg.encoder.hasFunction()); + BOOST_CHECK(cfg.encoder.hasFunction()); + BOOST_CHECK(cfg.encoder.hasFunction()); + BOOST_CHECK(cfg.encoder.hasFunction()); + BOOST_CHECK_EQUAL(cfg.encoder.size(), 6u); + BOOST_CHECK_EQUAL(cfg.decoder.size(), 6u); +} + +BOOST_AUTO_TEST_CASE(MissingAndUnknownTypeTagThrow) { + nlohmann::json jMissing; + jMissing["mapMaterial"] = true; + BOOST_CHECK_THROW(SurfaceMaterialJsonConverter::fromJson(jMissing), + std::invalid_argument); + + nlohmann::json jUnknown; + jUnknown["mapMaterial"] = true; + jUnknown["type"] = "not-a-material"; + BOOST_CHECK_THROW(SurfaceMaterialJsonConverter::fromJson(jUnknown), + std::invalid_argument); +} + +BOOST_AUTO_TEST_CASE(UnmappedMaterialYieldsNoMaterial) { + nlohmann::json jMaterial; + jMaterial["type"] = "proto"; + jMaterial["mapMaterial"] = false; + BOOST_CHECK(SurfaceMaterialJsonConverter::fromJson(jMaterial) == nullptr); + + // A proto material without binning is flagged as not mapped on write + ProtoSurfaceMaterial psm{BinUtility{}}; + nlohmann::json jProto = SurfaceMaterialJsonConverter::toJson(psm); + BOOST_CHECK_EQUAL(jProto["mapMaterial"], false); + BOOST_CHECK(SurfaceMaterialJsonConverter::fromJson(jProto) == nullptr); +} + +BOOST_AUTO_TEST_SUITE_END() + +} // namespace ActsTests From a573c4cc3562f82c18ea8b2501cd51c85dc0dab7 Mon Sep 17 00:00:00 2001 From: Paul Gessinger Date: Thu, 3 Sep 2026 14:46:50 +0200 Subject: [PATCH 2/5] refactor(json): Thread a store context through surface material JSON dispatch Globally indexed grid material indexes into a slab store that several grids of a material map can share. The JSON payload had no way to express that sharing: the previous commit makes each entry carry its own copy of the store, which is correct but turns one shared vector into one vector per surface on the way back. Introduce an encode/decode context that a whole document is written and read with. On encoding, the grid encoder registers the store it encounters with the context, which keys stores on `shared_ptr` identity (never on content, so two stores holding equal slabs stay distinct) and hands back an id assigned in encounter order. The entry then only writes `"store": `, and the map-level converter can emit the stores once as a top-level table. On decoding, the table is read first into the context and `"store": ` resolves to the shared allocation, so the grids that shared a store before the round trip share it again after. A default constructed context has no store table. The grid encoder then inlines the store as `storage_vector`, exactly as the locally indexed backend does, which keeps a surface serialized on its own -- through `SurfaceJsonConverter`, `JsonSurfacesWriter` or the tracking geometry converter -- self-contained. A `"store"` reference read without a table, or one past the end of the table, is an error rather than a silently empty store. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BSH9Jg53ECS7DmzTYJdsZs --- .../ActsPlugins/Json/MaterialJsonContext.hpp | 135 ++++++++++++++++++ .../Json/SurfaceMaterialJsonConverter.hpp | 36 ++++- .../Json/src/SurfaceMaterialJsonConverter.cpp | 78 +++++++--- .../SurfaceMaterialJsonConverterTests.cpp | 88 ++++++++++++ 4 files changed, 316 insertions(+), 21 deletions(-) create mode 100644 Plugins/Json/include/ActsPlugins/Json/MaterialJsonContext.hpp diff --git a/Plugins/Json/include/ActsPlugins/Json/MaterialJsonContext.hpp b/Plugins/Json/include/ActsPlugins/Json/MaterialJsonContext.hpp new file mode 100644 index 00000000000..d773c0dbb1f --- /dev/null +++ b/Plugins/Json/include/ActsPlugins/Json/MaterialJsonContext.hpp @@ -0,0 +1,135 @@ +// This file is part of the ACTS project. +// +// Copyright (C) 2016 CERN for the benefit of the ACTS project +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#pragma once + +#include "Acts/Material/MaterialSlab.hpp" + +#include +#include +#include +#include +#include + +namespace Acts { + +/// @addtogroup json_plugin +/// @{ + +/// A store of material slabs that several index grids can share +using MaterialSlabStore = std::shared_ptr>; + +/// Context threaded through the material encoders of a single document. +/// +/// Material grids that index into a shared slab store register that store +/// here instead of inlining it, so the document holds one copy per store. +/// A default constructed context has no store table, which makes the +/// encoders fall back to inlining and keeps standalone payloads +/// self-contained. +class MaterialJsonEncodeContext { + public: + /// Default construction, the store table is disabled + MaterialJsonEncodeContext() = default; + + /// Create a context that collects slab stores in a table + /// + /// @return a context with the store table enabled + static MaterialJsonEncodeContext withStoreTable() { + MaterialJsonEncodeContext ctx; + ctx.m_storeTable = true; + return ctx; + } + + /// @return whether encoders should reference the store table + bool storeTableEnabled() const { return m_storeTable; } + + /// Look up, and if needed assign, the table id of a slab store + /// + /// Stores are keyed on pointer identity, never on content, so two stores + /// that happen to hold the same slabs stay distinct. + /// + /// @param store the slab store to register + /// + /// @return the id of the store in the table + std::size_t storeId(const MaterialSlabStore& store) { + if (!m_storeTable) { + throw std::logic_error( + "MaterialJsonEncodeContext: store table is not enabled"); + } + if (store == nullptr) { + throw std::invalid_argument( + "MaterialJsonEncodeContext: cannot register a null slab store"); + } + for (std::size_t is = 0; is < m_stores.size(); ++is) { + if (m_stores[is] == store) { + return is; + } + } + m_stores.push_back(store); + return m_stores.size() - 1; + } + + /// @return the collected slab stores, in encounter order + const std::vector& stores() const { return m_stores; } + + private: + bool m_storeTable = false; + std::vector m_stores; +}; + +/// Context threaded through the material decoders of a single document. +/// +/// It carries the slab store table read from the document so that all +/// grids referencing the same id end up sharing one allocation. +class MaterialJsonDecodeContext { + public: + /// Default construction, no store table is available + MaterialJsonDecodeContext() = default; + + /// Install the slab store table of the document + /// + /// @param stores the stores, indexed by their document id + void setStores(std::vector stores) { + m_stores = std::move(stores); + m_storeTable = true; + } + + /// @return whether a store table is available + bool storeTableEnabled() const { return m_storeTable; } + + /// Resolve a slab store by its document id + /// + /// @param id the id of the store in the table + /// + /// @return the shared slab store + MaterialSlabStore store(std::size_t id) const { + if (!m_storeTable) { + throw std::invalid_argument( + "MaterialJsonDecodeContext: the payload references slab store " + + std::to_string(id) + " but no store table was read"); + } + if (id >= m_stores.size()) { + throw std::invalid_argument("MaterialJsonDecodeContext: slab store id " + + std::to_string(id) + + " is out of range, the table holds " + + std::to_string(m_stores.size()) + " stores"); + } + return m_stores[id]; + } + + /// @return the number of stores in the table + std::size_t size() const { return m_stores.size(); } + + private: + bool m_storeTable = false; + std::vector m_stores; +}; + +/// @} + +} // namespace Acts diff --git a/Plugins/Json/include/ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp b/Plugins/Json/include/ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp index 2e97c801737..9a910a1fb54 100644 --- a/Plugins/Json/include/ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp +++ b/Plugins/Json/include/ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp @@ -13,6 +13,7 @@ #include "ActsPlugins/Json/ActsJson.hpp" #include "ActsPlugins/Json/GeometryJsonKeys.hpp" #include "ActsPlugins/Json/JsonKindDispatcher.hpp" +#include "ActsPlugins/Json/MaterialJsonContext.hpp" #include @@ -30,11 +31,19 @@ namespace Acts { /// decoding side a @c JsonKindDispatcher keyed on the payload type tag. class SurfaceMaterialJsonConverter { public: + /// Context collecting the slab stores of the document being written + using EncodeContext = MaterialJsonEncodeContext; + + /// Context carrying the slab stores of the document being read + using DecodeContext = MaterialJsonDecodeContext; + /// Encoder type for the surface material - using Encoder = TypeDispatcher; + using Encoder = + TypeDispatcher; /// Decoder type for the surface material - using Decoder = JsonKindDispatcher>; + using Decoder = JsonKindDispatcher, + const DecodeContext&>; /// Configuration struct struct Config { @@ -61,16 +70,39 @@ class SurfaceMaterialJsonConverter { /// Convert surface material into its json payload /// /// @param material the material to be converted + /// @param context the document context collecting the slab stores /// @param config the converter configuration /// /// @return the json payload of the material, i.e. the value that goes /// under the @c material key of a surface + static nlohmann::json toJson(const ISurfaceMaterial& material, + EncodeContext& context, + const Config& config = defaultConfig()); + + /// Convert surface material into a self-contained json payload + /// + /// @param material the material to be converted + /// @param config the converter configuration + /// + /// @return the json payload of the material, with any slab store inlined static nlohmann::json toJson(const ISurfaceMaterial& material, const Config& config = defaultConfig()); /// Convert a json payload back into surface material /// /// @param jMaterial the json payload of the material + /// @param context the document context holding the slab stores + /// @param config the converter configuration + /// + /// @return the decoded material, or a nullptr if the payload is flagged + /// as not participating in the material mapping + static std::unique_ptr fromJson( + const nlohmann::json& jMaterial, const DecodeContext& context, + const Config& config = defaultConfig()); + + /// Convert a self-contained json payload back into surface material + /// + /// @param jMaterial the json payload of the material /// @param config the converter configuration /// /// @return the decoded material, or a nullptr if the payload is flagged diff --git a/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp b/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp index 8af17aa8110..52a816a72b9 100644 --- a/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp +++ b/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp @@ -36,6 +36,9 @@ namespace { using namespace Acts; +using EncodeContext = SurfaceMaterialJsonConverter::EncodeContext; +using DecodeContext = SurfaceMaterialJsonConverter::DecodeContext; + /// Payload type tags, shared between the encoder and the decoder constexpr const char* kHomogeneousTag = "homogeneous"; constexpr const char* kBinnedTag = "binned"; @@ -49,7 +52,8 @@ constexpr const char* kDirectAccessorTag = "direct"; constexpr const char* kIndexedAccessorTag = "indexed"; constexpr const char* kGloballyIndexedAccessorTag = "globally_indexed"; -nlohmann::json homogeneousToJson(const HomogeneousSurfaceMaterial& material) { +nlohmann::json homogeneousToJson(const HomogeneousSurfaceMaterial& material, + EncodeContext& /*ctx*/) { nlohmann::json jMaterial; jMaterial[jsonKey().typekey] = kHomogeneousTag; jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); @@ -63,7 +67,8 @@ nlohmann::json homogeneousToJson(const HomogeneousSurfaceMaterial& material) { return jMaterial; } -nlohmann::json binnedToJson(const BinnedSurfaceMaterial& material) { +nlohmann::json binnedToJson(const BinnedSurfaceMaterial& material, + EncodeContext& /*ctx*/) { nlohmann::json jMaterial; jMaterial[jsonKey().typekey] = kBinnedTag; jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); @@ -82,7 +87,8 @@ nlohmann::json binnedToJson(const BinnedSurfaceMaterial& material) { return jMaterial; } -nlohmann::json protoToJson(const ProtoSurfaceMaterial& material) { +nlohmann::json protoToJson(const ProtoSurfaceMaterial& material, + EncodeContext& /*ctx*/) { nlohmann::json jMaterial; jMaterial[jsonKey().typekey] = kProtoTag; jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); @@ -99,7 +105,8 @@ nlohmann::json protoToJson(const ProtoSurfaceMaterial& material) { return jMaterial; } -nlohmann::json protoGridToJson(const ProtoGridSurfaceMaterial& material) { +nlohmann::json protoGridToJson(const ProtoGridSurfaceMaterial& material, + EncodeContext& /*ctx*/) { nlohmann::json jMaterial; jMaterial[jsonKey().typekey] = kProtoGridTag; jMaterial[jsonKey().maptype] = nlohmann::json(material.mappingType()); @@ -109,7 +116,8 @@ nlohmann::json protoGridToJson(const ProtoGridSurfaceMaterial& material) { return jMaterial; } -nlohmann::json mergedMarkerToJson(const MergedMaterialMarker& /*material*/) { +nlohmann::json mergedMarkerToJson(const MergedMaterialMarker& /*material*/, + EncodeContext& /*ctx*/) { nlohmann::json jMaterial; jMaterial[jsonKey().typekey] = kMergedMarkerTag; // Flag as "mapped" so the reader does not discard it @@ -155,7 +163,8 @@ nlohmann::json slabsToJson(const std::vector& slabs) { /// Encoder for the whole grid material family. The storage backend is /// resolved at runtime through the storage variant, so the concrete axis /// types never appear here. -nlohmann::json gridMaterialToJson(const GridSurfaceMaterial& material) { +nlohmann::json gridMaterialToJson(const GridSurfaceMaterial& material, + EncodeContext& ctx) { nlohmann::json jMaterial; jMaterial[jsonKey().typekey] = kGridTag; jMaterial[jsonKey().mapkey] = true; @@ -187,7 +196,15 @@ nlohmann::json gridMaterialToJson(const GridSurfaceMaterial& material) { "SurfaceMaterialJsonConverter: globally indexed material " "without a slab store"); } - jAccessor["storage_vector"] = slabsToJson(*storage.material); + if (ctx.storeTableEnabled()) { + // The store lives once in the document, the entry only + // references it + jAccessor["store"] = ctx.storeId(storage.material); + } else { + // Standalone payload, inline the store to keep it + // self-contained + jAccessor["storage_vector"] = slabsToJson(*storage.material); + } } jGrid["data"] = gridDataToJson(multiAxis, [&](std::size_t bin) { return nlohmann::json(storage.indices.at(bin)); @@ -211,7 +228,7 @@ MappingType readMappingType(const nlohmann::json& jMaterial) { } std::unique_ptr homogeneousFromJson( - const nlohmann::json& jMaterial) { + const nlohmann::json& jMaterial, const DecodeContext& /*ctx*/) { MaterialSlabMatrix matrix; from_json(jMaterial.at(jsonKey().datakey), matrix); if (matrix.empty() || matrix[0].empty()) { @@ -223,7 +240,7 @@ std::unique_ptr homogeneousFromJson( } std::unique_ptr binnedFromJson( - const nlohmann::json& jMaterial) { + const nlohmann::json& jMaterial, const DecodeContext& /*ctx*/) { BinUtility bUtility; from_json(jMaterial.at(jsonKey().binkey), bUtility); MaterialSlabMatrix matrix; @@ -233,7 +250,7 @@ std::unique_ptr binnedFromJson( } std::unique_ptr protoFromJson( - const nlohmann::json& jMaterial) { + const nlohmann::json& jMaterial, const DecodeContext& /*ctx*/) { BinUtility bUtility; if (jMaterial.contains(jsonKey().binkey) && !jMaterial.at(jsonKey().binkey).is_null()) { @@ -244,7 +261,7 @@ std::unique_ptr protoFromJson( } std::unique_ptr protoGridFromJson( - const nlohmann::json& jMaterial) { + const nlohmann::json& jMaterial, const DecodeContext& /*ctx*/) { MultiAxisSpec spec = MultiAxisSpecJsonConverter::fromJson(jMaterial.at("axis_specs")); if (spec.size() != 2u) { @@ -259,7 +276,7 @@ std::unique_ptr protoGridFromJson( } std::unique_ptr mergedMarkerFromJson( - const nlohmann::json& /*jMaterial*/) { + const nlohmann::json& /*jMaterial*/, const DecodeContext& /*ctx*/) { return std::make_unique(); } @@ -296,7 +313,7 @@ std::vector slabsFromJson(const nlohmann::json& jSlabs) { } std::unique_ptr gridFromJson( - const nlohmann::json& jMaterial) { + const nlohmann::json& jMaterial, const DecodeContext& ctx) { const nlohmann::json& jAccessor = jMaterial.at("accessor"); const nlohmann::json& jGrid = jAccessor.at("grid"); const nlohmann::json& jAxes = jGrid.at("axes"); @@ -323,11 +340,17 @@ std::unique_ptr gridFromJson( *axis0, *axis1, slabsFromJson(jAccessor.at("storage_vector")), indices); } if (accessorType == kGloballyIndexedAccessorTag) { + MaterialSlabStore store; + if (jAccessor.contains("store")) { + // Resolved through the document store table, so that grids referencing + // the same id keep sharing one allocation + store = ctx.store(jAccessor.at("store").get()); + } else { + store = std::make_shared>( + slabsFromJson(jAccessor.at("storage_vector"))); + } return GridSurfaceMaterial::createGloballyIndexed( - *axis0, *axis1, - std::make_shared>( - slabsFromJson(jAccessor.at("storage_vector"))), - indices); + *axis0, *axis1, std::move(store), indices); } throw std::invalid_argument( "SurfaceMaterialJsonConverter: unsupported grid material accessor: " + @@ -365,20 +388,37 @@ Acts::SurfaceMaterialJsonConverter::defaultConfig() { return cfg; } +nlohmann::json Acts::SurfaceMaterialJsonConverter::toJson( + const ISurfaceMaterial& material, EncodeContext& context, + const Config& config) { + return config.encoder(material, context); +} + nlohmann::json Acts::SurfaceMaterialJsonConverter::toJson( const ISurfaceMaterial& material, const Config& config) { - return config.encoder(material); + // Without a document context the encoders inline their slab stores + EncodeContext inlineContext; + return toJson(material, inlineContext, config); } std::unique_ptr Acts::SurfaceMaterialJsonConverter::fromJson(const nlohmann::json& jMaterial, + const DecodeContext& context, const Config& config) { // Surfaces that are flagged out of the mapping carry no material if (jMaterial.contains(jsonKey().mapkey) && jMaterial.at(jsonKey().mapkey) == false) { return nullptr; } - return config.decoder(jMaterial); + return config.decoder(jMaterial, context); +} + +std::unique_ptr +Acts::SurfaceMaterialJsonConverter::fromJson(const nlohmann::json& jMaterial, + const Config& config) { + // Without a document context a payload referencing a store is an error + const DecodeContext emptyContext; + return fromJson(jMaterial, emptyContext, config); } void Acts::to_json(nlohmann::json& j, diff --git a/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp b/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp index 309ab6a3b18..d5c54516039 100644 --- a/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp +++ b/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -253,6 +254,7 @@ BOOST_AUTO_TEST_CASE(GloballyIndexedGridMaterialRoundTrip) { // The store travels with the payload, so this reads back standalone. The // free-function reader on main refuses this case outright. BOOST_CHECK(jMaterial["accessor"].contains("storage_vector")); + BOOST_CHECK(!jMaterial["accessor"].contains("store")); auto read = roundTrip(*gism); BOOST_REQUIRE(read != nullptr); @@ -270,6 +272,92 @@ BOOST_AUTO_TEST_CASE(GloballyIndexedGridMaterialRoundTrip) { } } +BOOST_AUTO_TEST_CASE(GloballyIndexedSharedStoreThroughContext) { + auto store = std::make_shared>(testSlabs()); + auto first = makeGloballyIndexed(store); + auto second = makeGloballyIndexed(store); + + auto encodeContext = + SurfaceMaterialJsonConverter::EncodeContext::withStoreTable(); + nlohmann::json jFirst = + SurfaceMaterialJsonConverter::toJson(*first, encodeContext); + nlohmann::json jSecond = + SurfaceMaterialJsonConverter::toJson(*second, encodeContext); + + // One table entry, referenced by both surfaces + BOOST_REQUIRE_EQUAL(encodeContext.stores().size(), 1u); + BOOST_CHECK(encodeContext.stores()[0] == store); + BOOST_CHECK_EQUAL(jFirst["accessor"]["store"], 0u); + BOOST_CHECK_EQUAL(jSecond["accessor"]["store"], 0u); + BOOST_CHECK(!jFirst["accessor"].contains("storage_vector")); + BOOST_CHECK(!jSecond["accessor"].contains("storage_vector")); + + SurfaceMaterialJsonConverter::DecodeContext decodeContext; + decodeContext.setStores( + {std::make_shared>(testSlabs())}); + + auto readFirst = + SurfaceMaterialJsonConverter::fromJson(jFirst, decodeContext); + auto readSecond = + SurfaceMaterialJsonConverter::fromJson(jSecond, decodeContext); + BOOST_REQUIRE(readFirst != nullptr); + BOOST_REQUIRE(readSecond != nullptr); + + const auto& globalFirst = std::get( + dynamic_cast(*readFirst).storage()); + const auto& globalSecond = std::get( + dynamic_cast(*readSecond).storage()); + // The sharing must survive the round trip + BOOST_CHECK(globalFirst.material == globalSecond.material); + BOOST_CHECK(globalFirst.material == decodeContext.store(0u)); +} + +BOOST_AUTO_TEST_CASE(DistinctStoresGetSequentialIds) { + auto storeA = std::make_shared>(testSlabs()); + // Same content, different allocation: stores are keyed on identity + auto storeB = std::make_shared>(testSlabs()); + + auto ctx = SurfaceMaterialJsonConverter::EncodeContext::withStoreTable(); + nlohmann::json jA = + SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(storeA), ctx); + nlohmann::json jB = + SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(storeB), ctx); + nlohmann::json jA2 = + SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(storeA), ctx); + + BOOST_CHECK_EQUAL(jA["accessor"]["store"], 0u); + BOOST_CHECK_EQUAL(jB["accessor"]["store"], 1u); + BOOST_CHECK_EQUAL(jA2["accessor"]["store"], 0u); + BOOST_REQUIRE_EQUAL(ctx.stores().size(), 2u); + BOOST_CHECK(ctx.stores()[0] == storeA); + BOOST_CHECK(ctx.stores()[1] == storeB); +} + +BOOST_AUTO_TEST_CASE(StoreReferenceWithoutTableThrows) { + auto store = std::make_shared>(testSlabs()); + auto ctx = SurfaceMaterialJsonConverter::EncodeContext::withStoreTable(); + nlohmann::json jMaterial = + SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(store), ctx); + + // A default decode context has no table at all + BOOST_CHECK_THROW(SurfaceMaterialJsonConverter::fromJson(jMaterial), + std::invalid_argument); + + // A table that does not reach the referenced id + SurfaceMaterialJsonConverter::DecodeContext empty; + empty.setStores({}); + BOOST_CHECK_THROW(SurfaceMaterialJsonConverter::fromJson(jMaterial, empty), + std::invalid_argument); + + nlohmann::json jOutOfRange = jMaterial; + jOutOfRange["accessor"]["store"] = 7u; + SurfaceMaterialJsonConverter::DecodeContext oneEntry; + oneEntry.setStores({store}); + BOOST_CHECK_THROW( + SurfaceMaterialJsonConverter::fromJson(jOutOfRange, oneEntry), + std::invalid_argument); +} + BOOST_AUTO_TEST_CASE(DirectGridMaterialRoundTrip) { auto gsm = makeDirect(); From dbd0216f22410657f58d93af2d889c57d0415a38 Mon Sep 17 00:00:00 2001 From: Paul Gessinger Date: Fri, 4 Sep 2026 14:16:30 +0200 Subject: [PATCH 3/5] refactor(json): Trim the surface material converter API The store context work exposed more than callers need. Pull it back in. The two context classes move to `detail/MaterialJsonContext.hpp` in `Acts::detail`, following the plugin's existing detail convention. The public header forward declares them and keeps the `EncodeContext`/`DecodeContext` aliases, which is all the dispatcher signatures need; only the converter sources and the unit tests see the definitions. `toJson` and `fromJson` collapse from two overloads each to one, with the context as an optional trailing pointer. A null pointer means what the context-less overload used to mean: the encoder inlines the slab store, and the decoder rejects a payload that references one. The duplicate cached `SurfaceMaterialJsonConverter::defaultConfig()` is gone; `Config::defaultConfig()` does the caching, as in `SurfaceJsonConverter`. The free `to_json`/`from_json` on `std::shared_ptr` are removed. Their two callers -- the surface-and-material tuple encoder and the material map converter's surface hierarchy -- now go through the class API. The map converter encodes each entry itself and hands the hierarchy container ready json, which is also what will let it thread a document-wide context through the entries later. With this the branch touches no Core header at all; it is confined to `Plugins/Json`, its tests and the docs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BSH9Jg53ECS7DmzTYJdsZs --- .../Json/MaterialMapJsonConverter.hpp | 7 +- .../Json/SurfaceMaterialJsonConverter.hpp | 70 ++++++------------- .../Json/{ => detail}/MaterialJsonContext.hpp | 9 +-- Plugins/Json/src/MaterialMapJsonConverter.cpp | 46 ++++++------ Plugins/Json/src/SurfaceJsonConverter.cpp | 5 +- .../Json/src/SurfaceMaterialJsonConverter.cpp | 59 +++++----------- .../SurfaceMaterialJsonConverterTests.cpp | 37 +++++----- 7 files changed, 88 insertions(+), 145 deletions(-) rename Plugins/Json/include/ActsPlugins/Json/{ => detail}/MaterialJsonContext.hpp (98%) diff --git a/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp b/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp index c055db48c41..7b93cc9a66d 100644 --- a/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp +++ b/Plugins/Json/include/ActsPlugins/Json/MaterialMapJsonConverter.hpp @@ -153,10 +153,9 @@ class MaterialMapJsonConverter { /// Name of the surface hierarchy std::string m_surfaceName = "Material Surface Map"; - /// Geometry hierarchy writer for surface material. - Acts::GeometryHierarchyMapJsonConverter< - std::shared_ptr, - Acts::IVolumeMaterialJsonDecorator> + /// Geometry hierarchy writer for surface material. The material entries + /// are encoded by this converter, so the container only sees ready json. + Acts::GeometryHierarchyMapJsonConverter m_surfaceMaterialConverter; /// Geometry hierarchy writer for surface. Acts::GeometryHierarchyMapJsonConverter @@ -21,6 +20,11 @@ namespace Acts { +namespace detail { +class MaterialJsonEncodeContext; +class MaterialJsonDecodeContext; +} // namespace detail + /// @addtogroup json_plugin /// @{ @@ -32,10 +36,10 @@ namespace Acts { class SurfaceMaterialJsonConverter { public: /// Context collecting the slab stores of the document being written - using EncodeContext = MaterialJsonEncodeContext; + using EncodeContext = detail::MaterialJsonEncodeContext; /// Context carrying the slab stores of the document being read - using DecodeContext = MaterialJsonDecodeContext; + using DecodeContext = detail::MaterialJsonDecodeContext; /// Encoder type for the surface material using Encoder = @@ -53,78 +57,44 @@ class SurfaceMaterialJsonConverter { /// Decoder for the surface material, keyed on the payload type tag Decoder decoder{jsonKey().typekey, "surface material"}; - /// Default configuration construction + /// Access the shared default configuration /// - /// @return default configuration - static Config defaultConfig(); + /// @return the default configuration instance + static const Config& defaultConfig(); }; /// Delete the default constructor as the class is purely static SurfaceMaterialJsonConverter() = delete; - /// Access the shared default configuration - /// - /// @return the default configuration instance - static const Config& defaultConfig(); - /// Convert surface material into its json payload /// /// @param material the material to be converted - /// @param context the document context collecting the slab stores /// @param config the converter configuration + /// @param context the document context collecting the slab stores, or a + /// nullptr to inline any slab store and keep the payload + /// self-contained /// /// @return the json payload of the material, i.e. the value that goes /// under the @c material key of a surface static nlohmann::json toJson(const ISurfaceMaterial& material, - EncodeContext& context, - const Config& config = defaultConfig()); - - /// Convert surface material into a self-contained json payload - /// - /// @param material the material to be converted - /// @param config the converter configuration - /// - /// @return the json payload of the material, with any slab store inlined - static nlohmann::json toJson(const ISurfaceMaterial& material, - const Config& config = defaultConfig()); + const Config& config = Config::defaultConfig(), + EncodeContext* context = nullptr); /// Convert a json payload back into surface material /// /// @param jMaterial the json payload of the material - /// @param context the document context holding the slab stores /// @param config the converter configuration + /// @param context the document context holding the slab stores, or a + /// nullptr if the payload is expected to be self-contained /// /// @return the decoded material, or a nullptr if the payload is flagged /// as not participating in the material mapping static std::unique_ptr fromJson( - const nlohmann::json& jMaterial, const DecodeContext& context, - const Config& config = defaultConfig()); - - /// Convert a self-contained json payload back into surface material - /// - /// @param jMaterial the json payload of the material - /// @param config the converter configuration - /// - /// @return the decoded material, or a nullptr if the payload is flagged - /// as not participating in the material mapping - static std::unique_ptr fromJson( - const nlohmann::json& jMaterial, const Config& config = defaultConfig()); + const nlohmann::json& jMaterial, + const Config& config = Config::defaultConfig(), + const DecodeContext* context = nullptr); }; -/// Convert surface material into the @c material entry of a json object -/// -/// @param j Destination JSON object -/// @param material Source material, may be a nullptr -void to_json(nlohmann::json& j, - const std::shared_ptr& material); - -/// Read surface material from the @c material entry of a json object -/// -/// @param j Source JSON object -/// @param material Destination material -void from_json(const nlohmann::json& j, - std::shared_ptr& material); - /// @} } // namespace Acts diff --git a/Plugins/Json/include/ActsPlugins/Json/MaterialJsonContext.hpp b/Plugins/Json/include/ActsPlugins/Json/detail/MaterialJsonContext.hpp similarity index 98% rename from Plugins/Json/include/ActsPlugins/Json/MaterialJsonContext.hpp rename to Plugins/Json/include/ActsPlugins/Json/detail/MaterialJsonContext.hpp index d773c0dbb1f..bf26ac6ed8e 100644 --- a/Plugins/Json/include/ActsPlugins/Json/MaterialJsonContext.hpp +++ b/Plugins/Json/include/ActsPlugins/Json/detail/MaterialJsonContext.hpp @@ -16,10 +16,7 @@ #include #include -namespace Acts { - -/// @addtogroup json_plugin -/// @{ +namespace Acts::detail { /// A store of material slabs that several index grids can share using MaterialSlabStore = std::shared_ptr>; @@ -130,6 +127,4 @@ class MaterialJsonDecodeContext { std::vector m_stores; }; -/// @} - -} // namespace Acts +} // namespace Acts::detail diff --git a/Plugins/Json/src/MaterialMapJsonConverter.cpp b/Plugins/Json/src/MaterialMapJsonConverter.cpp index 6b6173d0025..c6579c8540b 100644 --- a/Plugins/Json/src/MaterialMapJsonConverter.cpp +++ b/Plugins/Json/src/MaterialMapJsonConverter.cpp @@ -74,15 +74,6 @@ inline void decorateJson( decorator->decorate(*src, dest); } } -template <> -inline void decorateJson>( - const IVolumeMaterialJsonDecorator* decorator, - const std::shared_ptr& src, - nlohmann::json& dest) { - if (decorator != nullptr && src != nullptr) { - decorator->decorate(*src, dest); - } -} } // namespace Acts namespace { @@ -260,17 +251,20 @@ nlohmann::json Acts::MaterialMapJsonConverter::materialMapsToJson( mapVolumeInit); nlohmann::json materialVolume = m_volumeMaterialConverter.toJson(hierarchyVolumeMap, decorator); - SurfaceMaterialMaps surfaceMap = maps.first; - std::vector< - std::pair>> - mapSurfaceInit; - for (const auto& [key, value] : surfaceMap) { - mapSurfaceInit.push_back({key, value}); + std::vector> surfaceEntries; + for (const auto& [geoId, material] : maps.first) { + nlohmann::json jEntry; + if (material != nullptr) { + jEntry[jsonKey().materialkey] = + SurfaceMaterialJsonConverter::toJson(*material); + if (decorator != nullptr) { + decorator->decorate(*material, jEntry); + } + } + surfaceEntries.emplace_back(geoId, std::move(jEntry)); } - GeometryHierarchyMap> - hierarchySurfaceMap(mapSurfaceInit); - nlohmann::json materialSurface = - m_surfaceMaterialConverter.toJson(hierarchySurfaceMap, decorator); + nlohmann::json materialSurface = m_surfaceMaterialConverter.toJson( + GeometryHierarchyMap(std::move(surfaceEntries)), nullptr); nlohmann::json materialMap; materialMap["Volumes"] = materialVolume; materialMap["Surfaces"] = materialSurface; @@ -289,14 +283,18 @@ Acts::MaterialMapJsonConverter::jsonToMaterialMaps( hierarchyVolumeMap.valueAt(i)); volumeMap.insert({hierarchyVolumeMap.idAt(i), std::move(volumePointer)}); } - nlohmann::json materialSurface = materialmap["Surfaces"]; - GeometryHierarchyMap> - hierarchySurfaceMap = - m_surfaceMaterialConverter.fromJson(materialSurface); + GeometryHierarchyMap hierarchySurfaceMap = + m_surfaceMaterialConverter.fromJson(materialmap["Surfaces"]); SurfaceMaterialMaps surfaceMap; for (std::size_t i = 0; i < hierarchySurfaceMap.size(); i++) { + const nlohmann::json& jEntry = hierarchySurfaceMap.valueAt(i); + if (!jEntry.contains(jsonKey().materialkey) || + jEntry.at(jsonKey().materialkey).is_null()) { + continue; + } surfaceMap.insert( - {hierarchySurfaceMap.idAt(i), hierarchySurfaceMap.valueAt(i)}); + {hierarchySurfaceMap.idAt(i), SurfaceMaterialJsonConverter::fromJson( + jEntry.at(jsonKey().materialkey))}); } Acts::TrackingGeometryMaterial maps = {surfaceMap, volumeMap}; diff --git a/Plugins/Json/src/SurfaceJsonConverter.cpp b/Plugins/Json/src/SurfaceJsonConverter.cpp index 86374c6affe..6ffcb5d9a7f 100644 --- a/Plugins/Json/src/SurfaceJsonConverter.cpp +++ b/Plugins/Json/src/SurfaceJsonConverter.cpp @@ -185,7 +185,10 @@ std::shared_ptr surfaceFromJsonT(const nlohmann::json& j) { void Acts::to_json(nlohmann::json& j, const Acts::SurfaceAndMaterialWithContext& surface) { toJson(j, std::get<0>(surface), std::get<2>(surface)); - to_json(j, std::get<1>(surface)); + const auto& material = std::get<1>(surface); + if (material != nullptr) { + j[jsonKey().materialkey] = SurfaceMaterialJsonConverter::toJson(*material); + } } void Acts::to_json(nlohmann::json& j, const Acts::Surface& surface) { diff --git a/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp b/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp index 52a816a72b9..f6fc7fb8cf8 100644 --- a/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp +++ b/Plugins/Json/src/SurfaceMaterialJsonConverter.cpp @@ -21,6 +21,7 @@ #include "ActsPlugins/Json/GridJsonConverter.hpp" #include "ActsPlugins/Json/MaterialJsonConverter.hpp" #include "ActsPlugins/Json/UtilitiesJsonConverter.hpp" +#include "ActsPlugins/Json/detail/MaterialJsonContext.hpp" #include #include @@ -340,7 +341,7 @@ std::unique_ptr gridFromJson( *axis0, *axis1, slabsFromJson(jAccessor.at("storage_vector")), indices); } if (accessorType == kGloballyIndexedAccessorTag) { - MaterialSlabStore store; + detail::MaterialSlabStore store; if (jAccessor.contains("store")) { // Resolved through the document store table, so that grids referencing // the same id keep sharing one allocation @@ -357,10 +358,8 @@ std::unique_ptr gridFromJson( accessorType); } -} // namespace - -Acts::SurfaceMaterialJsonConverter::Config -Acts::SurfaceMaterialJsonConverter::Config::defaultConfig() { +SurfaceMaterialJsonConverter::Config makeDefaultConfig() { + using Config = SurfaceMaterialJsonConverter::Config; Config cfg; cfg.encoder.registerFunction(homogeneousToJson); @@ -382,60 +381,34 @@ Acts::SurfaceMaterialJsonConverter::Config::defaultConfig() { return cfg; } +} // namespace + const Acts::SurfaceMaterialJsonConverter::Config& -Acts::SurfaceMaterialJsonConverter::defaultConfig() { - static const Config cfg = Config::defaultConfig(); +Acts::SurfaceMaterialJsonConverter::Config::defaultConfig() { + static const Config cfg = makeDefaultConfig(); return cfg; } nlohmann::json Acts::SurfaceMaterialJsonConverter::toJson( - const ISurfaceMaterial& material, EncodeContext& context, - const Config& config) { - return config.encoder(material, context); -} - -nlohmann::json Acts::SurfaceMaterialJsonConverter::toJson( - const ISurfaceMaterial& material, const Config& config) { + const ISurfaceMaterial& material, const Config& config, + EncodeContext* context) { // Without a document context the encoders inline their slab stores EncodeContext inlineContext; - return toJson(material, inlineContext, config); + return config.encoder(material, + context != nullptr ? *context : inlineContext); } std::unique_ptr Acts::SurfaceMaterialJsonConverter::fromJson(const nlohmann::json& jMaterial, - const DecodeContext& context, - const Config& config) { + const Config& config, + const DecodeContext* context) { // Surfaces that are flagged out of the mapping carry no material if (jMaterial.contains(jsonKey().mapkey) && jMaterial.at(jsonKey().mapkey) == false) { return nullptr; } - return config.decoder(jMaterial, context); -} - -std::unique_ptr -Acts::SurfaceMaterialJsonConverter::fromJson(const nlohmann::json& jMaterial, - const Config& config) { // Without a document context a payload referencing a store is an error const DecodeContext emptyContext; - return fromJson(jMaterial, emptyContext, config); -} - -void Acts::to_json(nlohmann::json& j, - const std::shared_ptr& material) { - if (material == nullptr) { - return; - } - j[jsonKey().materialkey] = SurfaceMaterialJsonConverter::toJson(*material); -} - -void Acts::from_json(const nlohmann::json& j, - std::shared_ptr& material) { - material = nullptr; - if (!j.contains(jsonKey().materialkey) || - j.at(jsonKey().materialkey).is_null()) { - return; - } - material = - SurfaceMaterialJsonConverter::fromJson(j.at(jsonKey().materialkey)); + return config.decoder(jMaterial, + context != nullptr ? *context : emptyContext); } diff --git a/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp b/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp index d5c54516039..c2fca51c697 100644 --- a/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp +++ b/Tests/UnitTests/Plugins/Json/SurfaceMaterialJsonConverterTests.cpp @@ -21,6 +21,7 @@ #include "Acts/Utilities/IAxis.hpp" #include "Acts/Utilities/MultiAxisSpec.hpp" #include "ActsPlugins/Json/SurfaceMaterialJsonConverter.hpp" +#include "ActsPlugins/Json/detail/MaterialJsonContext.hpp" #include "ActsTests/CommonHelpers/FloatComparisons.hpp" #include @@ -279,10 +280,11 @@ BOOST_AUTO_TEST_CASE(GloballyIndexedSharedStoreThroughContext) { auto encodeContext = SurfaceMaterialJsonConverter::EncodeContext::withStoreTable(); + const auto& config = SurfaceMaterialJsonConverter::Config::defaultConfig(); nlohmann::json jFirst = - SurfaceMaterialJsonConverter::toJson(*first, encodeContext); + SurfaceMaterialJsonConverter::toJson(*first, config, &encodeContext); nlohmann::json jSecond = - SurfaceMaterialJsonConverter::toJson(*second, encodeContext); + SurfaceMaterialJsonConverter::toJson(*second, config, &encodeContext); // One table entry, referenced by both surfaces BOOST_REQUIRE_EQUAL(encodeContext.stores().size(), 1u); @@ -297,9 +299,9 @@ BOOST_AUTO_TEST_CASE(GloballyIndexedSharedStoreThroughContext) { {std::make_shared>(testSlabs())}); auto readFirst = - SurfaceMaterialJsonConverter::fromJson(jFirst, decodeContext); + SurfaceMaterialJsonConverter::fromJson(jFirst, config, &decodeContext); auto readSecond = - SurfaceMaterialJsonConverter::fromJson(jSecond, decodeContext); + SurfaceMaterialJsonConverter::fromJson(jSecond, config, &decodeContext); BOOST_REQUIRE(readFirst != nullptr); BOOST_REQUIRE(readSecond != nullptr); @@ -318,12 +320,13 @@ BOOST_AUTO_TEST_CASE(DistinctStoresGetSequentialIds) { auto storeB = std::make_shared>(testSlabs()); auto ctx = SurfaceMaterialJsonConverter::EncodeContext::withStoreTable(); - nlohmann::json jA = - SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(storeA), ctx); - nlohmann::json jB = - SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(storeB), ctx); - nlohmann::json jA2 = - SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(storeA), ctx); + const auto& config = SurfaceMaterialJsonConverter::Config::defaultConfig(); + nlohmann::json jA = SurfaceMaterialJsonConverter::toJson( + *makeGloballyIndexed(storeA), config, &ctx); + nlohmann::json jB = SurfaceMaterialJsonConverter::toJson( + *makeGloballyIndexed(storeB), config, &ctx); + nlohmann::json jA2 = SurfaceMaterialJsonConverter::toJson( + *makeGloballyIndexed(storeA), config, &ctx); BOOST_CHECK_EQUAL(jA["accessor"]["store"], 0u); BOOST_CHECK_EQUAL(jB["accessor"]["store"], 1u); @@ -336,8 +339,9 @@ BOOST_AUTO_TEST_CASE(DistinctStoresGetSequentialIds) { BOOST_AUTO_TEST_CASE(StoreReferenceWithoutTableThrows) { auto store = std::make_shared>(testSlabs()); auto ctx = SurfaceMaterialJsonConverter::EncodeContext::withStoreTable(); - nlohmann::json jMaterial = - SurfaceMaterialJsonConverter::toJson(*makeGloballyIndexed(store), ctx); + const auto& config = SurfaceMaterialJsonConverter::Config::defaultConfig(); + nlohmann::json jMaterial = SurfaceMaterialJsonConverter::toJson( + *makeGloballyIndexed(store), config, &ctx); // A default decode context has no table at all BOOST_CHECK_THROW(SurfaceMaterialJsonConverter::fromJson(jMaterial), @@ -346,15 +350,16 @@ BOOST_AUTO_TEST_CASE(StoreReferenceWithoutTableThrows) { // A table that does not reach the referenced id SurfaceMaterialJsonConverter::DecodeContext empty; empty.setStores({}); - BOOST_CHECK_THROW(SurfaceMaterialJsonConverter::fromJson(jMaterial, empty), - std::invalid_argument); + BOOST_CHECK_THROW( + SurfaceMaterialJsonConverter::fromJson(jMaterial, config, &empty), + std::invalid_argument); nlohmann::json jOutOfRange = jMaterial; jOutOfRange["accessor"]["store"] = 7u; SurfaceMaterialJsonConverter::DecodeContext oneEntry; oneEntry.setStores({store}); BOOST_CHECK_THROW( - SurfaceMaterialJsonConverter::fromJson(jOutOfRange, oneEntry), + SurfaceMaterialJsonConverter::fromJson(jOutOfRange, config, &oneEntry), std::invalid_argument); } @@ -380,7 +385,7 @@ BOOST_AUTO_TEST_CASE(DirectGridMaterialRoundTrip) { } BOOST_AUTO_TEST_CASE(EncoderCoversAllSurfaceMaterials) { - const auto& cfg = SurfaceMaterialJsonConverter::defaultConfig(); + const auto& cfg = SurfaceMaterialJsonConverter::Config::defaultConfig(); std::vector> materials; materials.push_back(std::make_shared( From 3fadf472123cf864d4ddfd75016c99334ad10da6 Mon Sep 17 00:00:00 2001 From: Paul Gessinger Date: Mon, 7 Sep 2026 16:38:37 +0200 Subject: [PATCH 4/5] refactor(json): Use emplace in the material map converter Addresses the two SonarCloud code smells on the PR: push_back of a brace-initialised pair becomes emplace_back, and map insert of a pair becomes try_emplace. The volume-side insert is changed too so the two loops read the same. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YVsEtWnNPh2DejfRkESkTJ --- Plugins/Json/src/MaterialMapJsonConverter.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Plugins/Json/src/MaterialMapJsonConverter.cpp b/Plugins/Json/src/MaterialMapJsonConverter.cpp index c6579c8540b..2c76c1eab7b 100644 --- a/Plugins/Json/src/MaterialMapJsonConverter.cpp +++ b/Plugins/Json/src/MaterialMapJsonConverter.cpp @@ -245,7 +245,7 @@ nlohmann::json Acts::MaterialMapJsonConverter::materialMapsToJson( std::vector> mapVolumeInit; for (const auto& [key, value] : volumeMap) { - mapVolumeInit.push_back({key, value.get()}); + mapVolumeInit.emplace_back(key, value.get()); } GeometryHierarchyMap hierarchyVolumeMap( mapVolumeInit); @@ -281,7 +281,7 @@ Acts::MaterialMapJsonConverter::jsonToMaterialMaps( for (std::size_t i = 0; i < hierarchyVolumeMap.size(); i++) { std::shared_ptr volumePointer( hierarchyVolumeMap.valueAt(i)); - volumeMap.insert({hierarchyVolumeMap.idAt(i), std::move(volumePointer)}); + volumeMap.try_emplace(hierarchyVolumeMap.idAt(i), std::move(volumePointer)); } GeometryHierarchyMap hierarchySurfaceMap = m_surfaceMaterialConverter.fromJson(materialmap["Surfaces"]); @@ -292,9 +292,9 @@ Acts::MaterialMapJsonConverter::jsonToMaterialMaps( jEntry.at(jsonKey().materialkey).is_null()) { continue; } - surfaceMap.insert( - {hierarchySurfaceMap.idAt(i), SurfaceMaterialJsonConverter::fromJson( - jEntry.at(jsonKey().materialkey))}); + surfaceMap.try_emplace(hierarchySurfaceMap.idAt(i), + SurfaceMaterialJsonConverter::fromJson( + jEntry.at(jsonKey().materialkey))); } Acts::TrackingGeometryMaterial maps = {surfaceMap, volumeMap}; From 5c63c90a8dbf422fb8025e1f22c43914ceae8aec Mon Sep 17 00:00:00 2001 From: Paul Gessinger Date: Tue, 8 Sep 2026 12:03:28 +0200 Subject: [PATCH 5/5] docs(json): Document the material map file format The JSON plugin's most user-facing artifact is the material map file, and nothing described it. This adds a page that walks the document layout, every surface material payload the dispatcher can write, and the volume material payloads of the older converter. No JSON on the page is typed out by hand, so none of it can drift. `MaterialJsonDocumentation` builds the example, encodes it through `MaterialMapJsonConverter`, checks that the result reads back, and compares it against `docs/examples/material_map_example.json`; a format change that is not reflected in the docs fails the test, and running it with `ACTS_UPDATE_DOC_EXAMPLES=1` refreshes the file. The example is kept minimal, because it is included verbatim and every line of it is a line someone reads. It carries the three surface payloads that differ in shape -- a plain slab, a binned matrix, a grid indexing into a slab store -- plus one volume entry, with the smallest bin counts the format allows and a single material throughout. The types it leaves out differ only in keys, which the tables on the page list; that the encoder covers all of them is already asserted by SurfaceMaterialJsonConverterTests. The dump uses four spaces so the repository's json formatting hook leaves it alone, and .gitignore gets an exception for it next to the codegen manifest, since `*.json` is ignored wholesale. The test gets the example's path as a command line argument rather than a compile definition, so it does not have to be rebuilt when it moves and the path stays visible in the ctest invocation. `add_unittest` now forwards any argument after the source file to the executable, behind the `--` separator that Boost.Test wants for tokens that are not its own options; they arrive as `master_test_suite().argv`, so this stays an ordinary Boost unit test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BSH9Jg53ECS7DmzTYJdsZs --- .gitignore | 2 + Tests/UnitTests/CMakeLists.txt | 14 +- Tests/UnitTests/Plugins/Json/CMakeLists.txt | 8 + .../Json/MaterialJsonDocumentationTests.cpp | 154 +++++++++++++++++ docs/DoxygenLayout.xml | 1 + docs/examples/material_map_example.json | 163 ++++++++++++++++++ docs/groups/plugins/json.md | 4 + docs/pages/material_map_json_format.md | 134 ++++++++++++++ 8 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 Tests/UnitTests/Plugins/Json/MaterialJsonDocumentationTests.cpp create mode 100644 docs/examples/material_map_example.json create mode 100644 docs/pages/material_map_json_format.md diff --git a/.gitignore b/.gitignore index b696deb5893..5d858457291 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ codegen/build/ codegen/src/codegen.egg-info/ # the codegen manifest is tracked, despite the blanket *.json rule above !codegen/manifest.json +# the generated material map example shown in the docs, same exception +!docs/examples/material_map_example.json # generated code shipped in source archives, never committed /prebuilt-codegen/ diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 76ed1de3331..225bb19a7ad 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -4,6 +4,10 @@ # extended by setting the `unittest_extra_libraries` variables before # calling the macro. +# any arguments after the source file are passed to the test executable on the +# ctest command line, e.g. paths that the test needs but cannot know itself. +# The test reads them from boost::unit_test::framework::master_test_suite(). + add_custom_target(unit_tests) macro(add_unittest _name _source) @@ -27,8 +31,14 @@ macro(add_unittest _name _source) if(ACTS_TESTS_HAS_WNO_C2Y_EXTENSIONS) target_compile_options(${_target} PRIVATE -Wno-c2y-extensions) endif() - # register as unittest executable - add_test(NAME ${_name} COMMAND ${_target}) + # register as unittest executable. Extra arguments go behind the -- + # separator, where Boost.Test wants everything that is not one of its own + # options, and arrive as master_test_suite().argv + if(${ARGC} GREATER 2) + add_test(NAME ${_name} COMMAND ${_target} -- ${ARGN}) + else() + add_test(NAME ${_name} COMMAND ${_target}) + endif() add_dependencies(unit_tests ${_target}) endmacro() diff --git a/Tests/UnitTests/Plugins/Json/CMakeLists.txt b/Tests/UnitTests/Plugins/Json/CMakeLists.txt index a939a040dd9..edca4e78112 100644 --- a/Tests/UnitTests/Plugins/Json/CMakeLists.txt +++ b/Tests/UnitTests/Plugins/Json/CMakeLists.txt @@ -8,6 +8,14 @@ add_unittest(GridJsonConverter GridJsonConverterTests.cpp) add_unittest(IndexGridNavigationJsonConverter IndexGridNavigationJsonConverterTests.cpp) add_unittest(MaterialJsonConverter MaterialJsonConverterTests.cpp) add_unittest(MaterialMapJsonConverter MaterialMapJsonConverterTests.cpp) +# Generates the material map example shown in the JSON plugin documentation and +# fails if the committed file has gone stale. +add_unittest( + MaterialJsonDocumentation + MaterialJsonDocumentationTests.cpp + "${PROJECT_SOURCE_DIR}/docs/examples/material_map_example.json" +) + add_unittest(AxisSpecJsonConverter AxisSpecJsonConverterTests.cpp) add_unittest(ProtoAxisJsonConverter ProtoAxisJsonConverterTests.cpp) add_unittest(UtilitiesJsonConverter UtilitiesJsonConverterTests.cpp) diff --git a/Tests/UnitTests/Plugins/Json/MaterialJsonDocumentationTests.cpp b/Tests/UnitTests/Plugins/Json/MaterialJsonDocumentationTests.cpp new file mode 100644 index 00000000000..2ad215e6c32 --- /dev/null +++ b/Tests/UnitTests/Plugins/Json/MaterialJsonDocumentationTests.cpp @@ -0,0 +1,154 @@ +// This file is part of the ACTS project. +// +// Copyright (C) 2016 CERN for the benefit of the ACTS project +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#include + +#include "Acts/Definitions/Units.hpp" +#include "Acts/Geometry/GeometryIdentifier.hpp" +#include "Acts/Material/BinnedSurfaceMaterial.hpp" +#include "Acts/Material/GridSurfaceMaterial.hpp" +#include "Acts/Material/HomogeneousSurfaceMaterial.hpp" +#include "Acts/Material/HomogeneousVolumeMaterial.hpp" +#include "Acts/Material/MaterialSlab.hpp" +#include "Acts/Material/TrackingGeometryMaterial.hpp" +#include "Acts/Utilities/AxisDefinitions.hpp" +#include "Acts/Utilities/BinUtility.hpp" +#include "Acts/Utilities/IAxis.hpp" +#include "Acts/Utilities/Logger.hpp" +#include "ActsPlugins/Json/MaterialMapJsonConverter.hpp" +#include "ActsTests/CommonHelpers/PredefinedMaterials.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +using namespace Acts; +using namespace Acts::UnitLiterals; + +namespace ActsTests { + +namespace { + +/// The one real material of the example, reused wherever material is needed +MaterialSlab exampleSlab() { + return {makeSilicon(), 0.15_mm}; +} + +/// The store the index grid points into. Vacuum sits at index 0 so the example +/// shows both an empty slab and an index that is not the bin number. +std::vector exampleSlabStore() { + return {MaterialSlab::Nothing(), exampleSlab()}; +} + +/// The material of the documented example. +/// +/// This is not a coverage test -- that the encoder handles every material type +/// is checked by SurfaceMaterialJsonConverterTests. The example only has to +/// show the structure, so it carries the three surface payloads that differ in +/// shape (a plain slab, a binned matrix, a grid) plus one volume entry, and +/// every count is the smallest the format allows. The result is included +/// verbatim in the documentation, so every line of it is a line someone reads. +TrackingGeometryMaterial exampleMaterialMaps() { + SurfaceMaterialMaps surfaces; + VolumeMaterialMaps volumes; + + const GeometryIdentifier volume1 = GeometryIdentifier().withVolume(1); + const GeometryIdentifier volume2 = GeometryIdentifier().withVolume(2); + + // A single slab covering the whole surface + surfaces[volume1.withBoundary(1)] = + std::make_shared( + exampleSlab(), 1., MappingType::PreMapping); + + // The classic binned material: a BinUtility plus a slab matrix. The second + // bin is left empty to show how an uncovered bin is written. + BinUtility binUtility(2, -100., 100., open, AxisDirection::AxisZ); + MaterialSlabMatrix matrix{ + MaterialSlabVector{exampleSlab(), MaterialSlab::Nothing()}}; + surfaces[volume1.withLayer(2).withApproach(1)] = + std::make_shared(binUtility, + std::move(matrix)); + + // Grid material, bins index into a slab store. The other two storage + // backends, and the proto materials, differ from this only in the keys + // listed in the docs. The grid is always two dimensional. + { + auto axis0 = + IAxis::createEquidistant(AxisBoundaryType::Bound, -100., 100., 1); + auto axis1 = + IAxis::createEquidistant(AxisBoundaryType::Bound, -100., 100., 1); + surfaces[volume2.withLayer(4)] = GridSurfaceMaterial::createIndexed( + *axis0, *axis1, exampleSlabStore(), + std::vector>{std::vector{1u}}); + } + + volumes[volume1] = + std::make_shared(makeSilicon()); + + return {std::move(surfaces), std::move(volumes)}; +} + +/// The documented example file to check against, passed on the command line by +/// ctest so the test does not have to know where the source tree lives. +std::string exampleFilePath() { + const auto& master = boost::unit_test::framework::master_test_suite(); + BOOST_REQUIRE_MESSAGE(master.argc == 2, + "Expected exactly one argument, the example file path"); + return master.argv[1]; +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(JsonSuite) + +/// Writes the material map example that the JSON plugin documentation shows, +/// and fails if the committed file no longer matches what the converter +/// produces. Set ACTS_UPDATE_DOC_EXAMPLES=1 to refresh the file in place. +BOOST_AUTO_TEST_CASE(MaterialMapDocumentationExample) { + MaterialMapJsonConverter::Config converterCfg; + MaterialMapJsonConverter converter(converterCfg, Logging::WARNING); + + nlohmann::json jMap = converter.materialMapsToJson(exampleMaterialMaps()); + + // The documented example has to be something the plugin can read back + nlohmann::json jRoundTrip = + converter.materialMapsToJson(converter.jsonToMaterialMaps(jMap)); + BOOST_CHECK_EQUAL(jMap, jRoundTrip); + + const std::string path = exampleFilePath(); + // Four spaces and a trailing newline, so the file survives the + // repository's json formatting hook unchanged + const std::string encoded = jMap.dump(4) + "\n"; + + if (const char* update = std::getenv("ACTS_UPDATE_DOC_EXAMPLES"); + update != nullptr && std::string(update) != "0") { + std::ofstream out(path); + BOOST_REQUIRE_MESSAGE(out.is_open(), "Cannot write " << path); + out << encoded; + return; + } + + std::ifstream in(path); + BOOST_REQUIRE_MESSAGE(in.is_open(), "Cannot read " << path); + const std::string committed{std::istreambuf_iterator(in), + std::istreambuf_iterator()}; + + BOOST_CHECK_MESSAGE( + committed == encoded, + path << " is out of date, regenerate it by running this test with " + "ACTS_UPDATE_DOC_EXAMPLES=1"); +} + +BOOST_AUTO_TEST_SUITE_END() + +} // namespace ActsTests diff --git a/docs/DoxygenLayout.xml b/docs/DoxygenLayout.xml index bac6d785155..5aa626335bc 100644 --- a/docs/DoxygenLayout.xml +++ b/docs/DoxygenLayout.xml @@ -11,6 +11,7 @@ + diff --git a/docs/examples/material_map_example.json b/docs/examples/material_map_example.json new file mode 100644 index 00000000000..91345de4a01 --- /dev/null +++ b/docs/examples/material_map_example.json @@ -0,0 +1,163 @@ +{ + "Surfaces": { + "acts-geometry-hierarchy-map": { + "format-version": 0, + "value-identifier": "Material Surface Map" + }, + "entries": [ + { + "approach": 1, + "layer": 2, + "value": { + "material": { + "binUtility": { + "binningdata": [ + { + "bins": 2, + "max": 100.0, + "min": -100.0, + "option": "open", + "type": "equidistant", + "value": "AxisZ" + } + ] + }, + "data": [ + [ + { + "material": [ + 93.69999694824219, + 465.20001220703125, + 28.085500717163086, + 14.0, + 8.292535494547337e-05 + ], + "thickness": 0.15000000596046448 + }, + { + "material": null, + "thickness": 0.0 + } + ] + ], + "mapMaterial": true, + "mappingType": "Default", + "type": "binned" + } + }, + "volume": 1 + }, + { + "boundary": 1, + "value": { + "material": { + "data": [ + [ + { + "material": [ + 93.69999694824219, + 465.20001220703125, + 28.085500717163086, + 14.0, + 8.292535494547337e-05 + ], + "thickness": 0.15000000596046448 + } + ] + ], + "mapMaterial": true, + "mappingType": "PreMapping", + "type": "homogeneous" + } + }, + "volume": 1 + }, + { + "layer": 4, + "value": { + "material": { + "accessor": { + "grid": { + "axes": [ + { + "bins": 1, + "boundary_type": "Bound", + "range": [ + -100.0, + 100.0 + ], + "type": "Equidistant" + }, + { + "bins": 1, + "boundary_type": "Bound", + "range": [ + -100.0, + 100.0 + ], + "type": "Equidistant" + } + ], + "data": [ + [ + [ + 1, + 1 + ], + 1 + ] + ] + }, + "storage_vector": [ + { + "material": null, + "thickness": 0.0 + }, + { + "material": [ + 93.69999694824219, + 465.20001220703125, + 28.085500717163086, + 14.0, + 8.292535494547337e-05 + ], + "thickness": 0.15000000596046448 + } + ], + "type": "indexed" + }, + "mapMaterial": true, + "type": "grid" + } + }, + "volume": 2 + } + ] + }, + "Volumes": { + "acts-geometry-hierarchy-map": { + "format-version": 0, + "value-identifier": "Material Volume Map" + }, + "entries": [ + { + "value": { + "material": { + "data": [ + [ + 93.69999694824219, + 465.20001220703125, + 28.085500717163086, + 14.0, + 8.292535494547337e-05 + ] + ], + "mapMaterial": true, + "type": "homogeneous" + } + }, + "volume": 1 + } + ] + } +} diff --git a/docs/groups/plugins/json.md b/docs/groups/plugins/json.md index b8542e461a0..c8effb39407 100644 --- a/docs/groups/plugins/json.md +++ b/docs/groups/plugins/json.md @@ -1,2 +1,6 @@ @defgroup json_plugin JSON Plugin @ingroup plugins +@brief JSON (de)serialization of geometry, material and configuration. + +The file format written by the material converters is documented in +@ref material_map_json_format. diff --git a/docs/pages/material_map_json_format.md b/docs/pages/material_map_json_format.md new file mode 100644 index 00000000000..b09d8361062 --- /dev/null +++ b/docs/pages/material_map_json_format.md @@ -0,0 +1,134 @@ +@page material_map_json_format Material map JSON format + +A material map is the file the material mapping writes and the +@ref Acts::JsonMaterialDecorator "JsonMaterialDecorator" reads back to attach +material to a tracking geometry. This page documents the on-disk layout as +produced by @ref Acts::MaterialMapJsonConverter "MaterialMapJsonConverter" and +@ref Acts::SurfaceMaterialJsonConverter "SurfaceMaterialJsonConverter". + +The example below is not typed out by hand. It is generated from the converters +by the `MaterialJsonDocumentation` unit test, which fails if this file stops +matching what the code writes; run that test with `ACTS_UPDATE_DOC_EXAMPLES=1` +to refresh it after a format change. It is deliberately as small as the format +allows, and shows the three surface payloads that differ in shape plus one +volume entry. The remaining payload types differ only in the keys tabulated +further down. + +@include examples/material_map_example.json + +## Document layout + +The document has two top level keys, `Surfaces` and `Volumes`. Each holds a +@ref Acts::GeometryHierarchyMapJsonConverter "geometry hierarchy map" document, +which is a header naming the container -- `acts-geometry-hierarchy-map`, with a +`format-version` and a `value-identifier` -- followed by a flat list of +`entries`. + +An entry carries the non-zero levels of its @ref Acts::GeometryIdentifier +(`volume`, `boundary`, `layer`, `approach`, `sensitive`) next to a `value` +object. Levels that are zero are omitted, which is why the homogeneous entry +above shows `volume` and `boundary` but no `layer`. For material maps the +`value` object has a single `material` key. An entry whose `material` is +missing or `null` is skipped when reading, which is how a geometry dump can +list surfaces that carry no material yet. + +## Surface material payloads + +The value under `material` is a self-describing payload. Its `type` tag selects +the decoder and is authoritative: a missing or unknown tag is an error, the +payload is never guessed from the keys that happen to be present. + +| `type` | C++ type | Payload keys | +|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| +| `homogeneous` | @ref Acts::HomogeneousSurfaceMaterial | `data` | +| `binned` | @ref Acts::BinnedSurfaceMaterial | `binUtility`, `data` | +| `proto` | @ref Acts::ProtoSurfaceMaterial | `binUtility` | +| `proto-grid` | @ref Acts::ProtoGridSurfaceMaterial | `axis_specs` | +| `grid` | @ref Acts::GridSurfaceMaterial | `accessor` | +| `merged-material-marker` | @ref Acts::MergedMaterialMarker | none | + +Two more keys are common to all of them: + +- `mapMaterial` steers the material mapping. Reading a payload with + `mapMaterial: false` yields no material at all, so this is also how a surface + is flagged out of the mapping. Proto material without any binning is written + with `mapMaterial: false` for that reason. +- `mappingType` is one of `PreMapping`, `Default`, `PostMapping` or `Sensor` + and tells the mapper where along the propagation the material should be + assigned. It is absent from the `grid` and `merged-material-marker` + payloads, which do not participate in the deprecated mapping path. + +### Material slabs + +Wherever material itself is stored, it is a slab: the opaque +@ref Acts::Material parameter vector plus a thickness. The vector currently +holds radiation length, interaction length, relative atomic mass, nuclear +charge and molar density in ACTS native units, but it is deliberately opaque -- +read it through the converter rather than by index, since more parameters may +be appended later. Vacuum is written as a `null` vector, as in the empty second +bin of the binned entry above. + +### `homogeneous` and `binned` + +`homogeneous` is one slab for the whole surface. The slab sits in a nested +array for historical reasons: it is the degenerate case of the `binned` matrix, +which pairs a @ref Acts::BinUtility with the slabs it addresses. That matrix is +indexed `[bin of the second binning][bin of the first binning]`, so the one +dimensional binning of the example gives a single row of two slabs. + +### `proto` and `proto-grid` + +Binning instructions for the material mapping that carry no material yet. +`proto` expresses the binning as a @ref Acts::BinUtility, exactly as `binned` +does but without the `data`. `proto-grid` expresses it as an `axis_specs` list +of @ref Acts::AxisSpec, which is the representation the grid based material +uses; each spec has a `type`, a `bins` count, a `range`, a `boundary_type` and +a `direction`. Exactly two specs are required. + +### `grid` + +The whole grid material family shares one tag, and one C++ class: +@ref Acts::GridSurfaceMaterial. The payload is a grid under `accessor`, made of +a list of `axes` and a `data` list of `[local bins, value]` pairs. The local bin +indices are **one based** and follow the axis order of `axes`. + +The grid is always two dimensional, and lookup is local: `loc0` addresses axis +0 and `loc1` axis 1 directly. There is no global (position) lookup and hence no +coordinate-transform description in the payload. + +What sits in a bin depends on `accessor.type`, which names the storage backend: + +| `accessor.type` | Bin value | Extra keys | +|--------------------|-----------------|--------------------------------| +| `direct` | a material slab | none | +| `indexed` | an index | `storage_vector` | +| `globally_indexed` | an index | `storage_vector` **or** `store` | + +`indexed`, shown in the example, keeps a slab store next to the grid and the +bins index into it, which pays off as soon as several bins share the same slab. +Note that the store index is unrelated to the bin number: the single bin of the +example holds index 1, the second entry of its `storage_vector`. + +`globally_indexed` is the same, except that the store may be shared with other +surfaces. A standalone payload inlines the store as `storage_vector` and stays +self-contained; when a document-wide store table is in use, the entry instead +references it by id under `store`, so the sharing survives the round trip +rather than being flattened into one copy per surface. The two are mutually +exclusive: an entry carrying `store` can only be read with the table that +defines it. + +### `merged-material-marker` + +A sentinel left behind by @ref Acts::Portal::merge when the material of two +merged portal surfaces had to be dropped. Its payload is just the tag and +`mapMaterial`; it carries no material, only the information that something was +lost here. + +## Volume material payloads + +Volume material entries are written by a separate, older converter and use +their own tags: `homogeneous` (a single @ref Acts::Material parameter vector +under `data`, as in the example above), `proto` (a `binUtility` only), and +`interpolated2D` / `interpolated3D` (a `binUtility` plus one parameter vector +per bin). The tags overlap with the surface ones by accident; the two lists +live under different top level keys and are never mixed.