Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-09-17 08:23:56

0001 // This file is part of the ACTS project.
0002 //
0003 // Copyright (C) 2016 CERN for the benefit of the ACTS project
0004 //
0005 // This Source Code Form is subject to the terms of the Mozilla Public
0006 // License, v. 2.0. If a copy of the MPL was not distributed with this
0007 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
0008 
0009 #include <boost/test/data/test_case.hpp>
0010 #include <boost/test/tools/old/interface.hpp>
0011 #include <boost/test/unit_test.hpp>
0012 #include <boost/test/unit_test_suite.hpp>
0013 
0014 #include "Acts/Definitions/Units.hpp"
0015 #include "Acts/Geometry/Blueprint.hpp"
0016 #include "Acts/Geometry/BlueprintNode.hpp"
0017 #include "Acts/Geometry/ContainerBlueprintNode.hpp"
0018 #include "Acts/Geometry/CuboidVolumeBounds.hpp"
0019 #include "Acts/Geometry/CylinderVolumeBounds.hpp"
0020 #include "Acts/Geometry/GeometryContext.hpp"
0021 #include "Acts/Geometry/LayerBlueprintNode.hpp"
0022 #include "Acts/Geometry/MaterialDesignatorBlueprintNode.hpp"
0023 #include "Acts/Geometry/PadBlueprintNode.hpp"
0024 #include "Acts/Geometry/StaticBlueprintNode.hpp"
0025 #include "Acts/Geometry/TrackingVolume.hpp"
0026 #include "Acts/Geometry/TrapezoidVolumeBounds.hpp"
0027 #include "Acts/Geometry/VolumeAttachmentStrategy.hpp"
0028 #include "Acts/Material/HomogeneousSurfaceMaterial.hpp"
0029 #include "Acts/Material/Material.hpp"
0030 #include "Acts/Material/MaterialSlab.hpp"
0031 #include "Acts/Material/ProtoSurfaceMaterial.hpp"
0032 #include "Acts/Surfaces/RectangleBounds.hpp"
0033 #include "Acts/Utilities/AxisDefinitions.hpp"
0034 #include "Acts/Utilities/AxisSpec.hpp"
0035 #include "Acts/Utilities/Diagnostics.hpp"
0036 #include "Acts/Utilities/Logger.hpp"
0037 #include "Acts/Utilities/ProtoAxis.hpp"
0038 #include "ActsTests/CommonHelpers/DetectorElementStub.hpp"
0039 
0040 #include <memory>
0041 #include <stdexcept>
0042 #include <vector>
0043 
0044 using namespace Acts;
0045 using namespace Acts::UnitLiterals;
0046 using Acts::Blueprint;
0047 using Acts::BlueprintNode;
0048 using Acts::BlueprintOptions;
0049 using Acts::LayerBlueprintNode;
0050 using Acts::MaterialDesignatorBlueprintNode;
0051 using Acts::PadBlueprintNode;
0052 using Acts::StaticBlueprintNode;
0053 
0054 namespace ActsTests {
0055 
0056 auto logger = getDefaultLogger("UnitTests", Logging::VERBOSE);
0057 
0058 auto gctx = GeometryContext::dangerouslyDefaultConstruct();
0059 
0060 auto nameLookup(const TrackingGeometry& geo) {
0061   return [&](const std::string& name) -> const TrackingVolume& {
0062     const TrackingVolume* volume = nullptr;
0063 
0064     geo.visitVolumes([&](const TrackingVolume* v) {
0065       if (v->volumeName() == name) {
0066         volume = v;
0067       }
0068     });
0069 
0070     if (volume == nullptr) {
0071       throw std::runtime_error("Volume not found: " + name);
0072     }
0073     return *volume;
0074   };
0075 }
0076 
0077 std::size_t countVolumes(const TrackingGeometry& geo) {
0078   std::size_t nVolumes = 0;
0079   geo.visitVolumes([&](const TrackingVolume* /*volume*/) { nVolumes++; });
0080   return nVolumes;
0081 }
0082 
0083 BOOST_AUTO_TEST_SUITE(GeometrySuite);
0084 
0085 BOOST_AUTO_TEST_CASE(InvalidRoot) {
0086   Logging::ScopedFailureThreshold threshold{Logging::Level::FATAL};
0087 
0088   Blueprint::Config cfg;
0089   Blueprint root{cfg};
0090   BOOST_CHECK_THROW(root.construct({}, gctx, *logger), std::logic_error);
0091 
0092   // More than one child is also invalid
0093   auto cylBounds = std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 100_mm);
0094   root.addChild(
0095       std::make_unique<StaticBlueprintNode>(std::make_unique<TrackingVolume>(
0096           Transform3::Identity(), cylBounds, "child1")));
0097   root.addChild(
0098       std::make_unique<StaticBlueprintNode>(std::make_unique<TrackingVolume>(
0099           Transform3::Identity(), cylBounds, "child2")));
0100 
0101   BOOST_CHECK_THROW(root.construct({}, gctx, *logger), std::logic_error);
0102 }
0103 
0104 class DummyNode : public BlueprintNode {
0105  public:
0106   explicit DummyNode(const std::string& name) : m_name(name) {}
0107 
0108   const std::string& name() const override { return m_name; }
0109 
0110   Volume& build(const BlueprintOptions& /*options*/,
0111                 const GeometryContext& /*gctx*/,
0112                 const Logger& /*logger*/) override {
0113     throw std::logic_error("Not implemented");
0114   }
0115 
0116   PortalShellBase& connect(const BlueprintOptions& /*options*/,
0117                            const GeometryContext& /*gctx*/,
0118                            const Logger& /*logger */) override {
0119     throw std::logic_error("Not implemented");
0120   }
0121 
0122   void finalize(const BlueprintOptions& /*options*/,
0123                 const GeometryContext& /*gctx*/, TrackingVolume& /*parent*/,
0124                 const Logger& /*logger*/) override {
0125     throw std::logic_error("Not implemented");
0126   }
0127 
0128  private:
0129   std::string m_name;
0130 };
0131 
0132 BOOST_AUTO_TEST_CASE(RootCannotBeChild) {
0133   auto node = std::make_shared<DummyNode>("node");
0134   Blueprint::Config cfg;
0135   auto root = std::make_shared<Blueprint>(cfg);
0136 
0137   BOOST_CHECK_THROW(node->addChild(root), std::invalid_argument);
0138 }
0139 
0140 BOOST_AUTO_TEST_CASE(AddChildInvalid) {
0141   auto node = std::make_shared<DummyNode>("node");
0142 
0143   // Add self
0144   BOOST_CHECK_THROW(node->addChild(node), std::invalid_argument);
0145 
0146   // Add nullptr
0147   BOOST_CHECK_THROW(node->addChild(nullptr), std::invalid_argument);
0148 
0149   auto nodeB = std::make_shared<DummyNode>("nodeB");
0150   auto nodeC = std::make_shared<DummyNode>("nodeC");
0151 
0152   node->addChild(nodeB);
0153   nodeB->addChild(nodeC);
0154   BOOST_CHECK_THROW(nodeC->addChild(node), std::invalid_argument);
0155 
0156   // already has parent, can't be added as a child anywhere else
0157   BOOST_CHECK_THROW(node->addChild(nodeC), std::invalid_argument);
0158 }
0159 
0160 BOOST_AUTO_TEST_CASE(Depth) {
0161   auto node1 = std::make_shared<DummyNode>("node1");
0162   auto node2 = std::make_shared<DummyNode>("node2");
0163   auto node3 = std::make_shared<DummyNode>("node3");
0164 
0165   BOOST_CHECK_EQUAL(node1->depth(), 0);
0166   BOOST_CHECK_EQUAL(node2->depth(), 0);
0167   BOOST_CHECK_EQUAL(node3->depth(), 0);
0168 
0169   node2->addChild(node3);
0170   BOOST_CHECK_EQUAL(node2->depth(), 0);
0171   BOOST_CHECK_EQUAL(node3->depth(), 1);
0172 
0173   node1->addChild(node2);
0174   BOOST_CHECK_EQUAL(node1->depth(), 0);
0175   BOOST_CHECK_EQUAL(node2->depth(), 1);
0176   BOOST_CHECK_EQUAL(node3->depth(), 2);
0177 }
0178 
0179 BOOST_AUTO_TEST_CASE(Static) {
0180   Blueprint::Config cfg;
0181   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0182   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0183   Blueprint root{cfg};
0184 
0185   double hlZ = 30_mm;
0186   auto cylBounds = std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, hlZ);
0187   auto cyl = std::make_unique<TrackingVolume>(Transform3::Identity(), cylBounds,
0188                                               "child");
0189 
0190   root.addStaticVolume(std::move(cyl));
0191 
0192   BOOST_CHECK_EQUAL(root.children().size(), 1);
0193 
0194   auto tGeometry = root.construct({}, gctx, *logger);
0195 
0196   BOOST_REQUIRE(tGeometry);
0197 
0198   BOOST_CHECK(tGeometry->geometryVersion() ==
0199               TrackingGeometry::GeometryVersion::Gen3);
0200 
0201   BOOST_CHECK_EQUAL(tGeometry->highestTrackingVolume()->volumes().size(), 1);
0202 
0203   BOOST_CHECK_EQUAL(countVolumes(*tGeometry), 2);
0204 
0205   auto lookup = nameLookup(*tGeometry);
0206   BOOST_CHECK_EQUAL(lookup("child").volumeBounds().type(),
0207                     VolumeBounds::BoundsType::eCylinder);
0208   auto actCyl =
0209       dynamic_cast<const CylinderVolumeBounds&>(lookup("child").volumeBounds());
0210   // Size as given
0211   BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMinR), 10_mm);
0212   BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMaxR), 20_mm);
0213   BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eHalfLengthZ), hlZ);
0214 
0215   BOOST_CHECK_EQUAL(lookup("World").volumeBounds().type(),
0216                     VolumeBounds::BoundsType::eCylinder);
0217   auto worldCyl =
0218       dynamic_cast<const CylinderVolumeBounds&>(lookup("World").volumeBounds());
0219   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 9_mm);
0220   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 22_mm);
0221   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ),
0222                     hlZ + 20_mm);
0223 
0224   BOOST_CHECK_EQUAL(lookup("World").portals().size(), 8);
0225 }
0226 
0227 BOOST_AUTO_TEST_CASE(CylinderContainer) {
0228   Blueprint::Config cfg;
0229   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0230   cfg.envelope[AxisDirection::AxisR] = {2_mm, 20_mm};
0231   auto root = std::make_unique<Blueprint>(cfg);
0232 
0233   auto& cyl = root->addCylinderContainer("Container", AxisDirection::AxisZ);
0234   cyl.setAttachmentStrategy(VolumeAttachmentStrategy::Gap);
0235 
0236   double z0 = -200_mm;
0237   double hlZ = 30_mm;
0238   auto cylBounds = std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, hlZ);
0239   for (std::size_t i = 0; i < 3; i++) {
0240     auto childCyl = std::make_unique<TrackingVolume>(
0241         Transform3::Identity() *
0242             Translation3{Vector3{0, 0, z0 + i * 2 * hlZ * 1.2}},
0243         cylBounds, "child" + std::to_string(i));
0244     cyl.addStaticVolume(std::move(childCyl));
0245   }
0246 
0247   auto tGeometry = root->construct({}, gctx, *logger);
0248   BOOST_CHECK(tGeometry->geometryVersion() ==
0249               TrackingGeometry::GeometryVersion::Gen3);
0250 
0251   // 4 real volumes + 2 gaps
0252   BOOST_CHECK_EQUAL(countVolumes(*tGeometry), 6);
0253 
0254   auto lookup = nameLookup(*tGeometry);
0255   BOOST_CHECK_EQUAL(lookup("World").volumeBounds().type(),
0256                     VolumeBounds::BoundsType::eCylinder);
0257   auto worldCyl =
0258       dynamic_cast<const CylinderVolumeBounds&>(lookup("World").volumeBounds());
0259   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 8_mm);
0260   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 40_mm);
0261   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ), 122_mm);
0262 
0263   BOOST_CHECK_EQUAL(lookup("World").portals().size(), 8);
0264 
0265   for (std::size_t i = 0; i < 3; i++) {
0266     const auto& vol{lookup("child" + std::to_string(i))};
0267     BOOST_CHECK_EQUAL(vol.volumeBounds().type(),
0268                       VolumeBounds::BoundsType::eCylinder);
0269     auto actCyl = dynamic_cast<const CylinderVolumeBounds&>(vol.volumeBounds());
0270     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMinR), 10_mm);
0271     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMaxR), 20_mm);
0272     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eHalfLengthZ), hlZ);
0273   }
0274 
0275   for (std::size_t i = 0; i < 2; i++) {
0276     const auto& vol{lookup("Container::Gap" + std::to_string(i + 1))};
0277     BOOST_CHECK_EQUAL(vol.volumeBounds().type(),
0278                       VolumeBounds::BoundsType::eCylinder);
0279     auto gapCyl = dynamic_cast<const CylinderVolumeBounds&>(vol.volumeBounds());
0280     BOOST_CHECK_EQUAL(gapCyl.get(CylinderVolumeBounds::eMinR), 10_mm);
0281     BOOST_CHECK_EQUAL(gapCyl.get(CylinderVolumeBounds::eMaxR), 20_mm);
0282     BOOST_CHECK_EQUAL(gapCyl.get(CylinderVolumeBounds::eHalfLengthZ), 6_mm);
0283   }
0284 }
0285 
0286 BOOST_AUTO_TEST_CASE(Confined) {
0287   Transform3 base{Transform3::Identity()};
0288 
0289   Blueprint::Config cfg;
0290   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0291   cfg.envelope[AxisDirection::AxisR] = {2_mm, 20_mm};
0292   auto root = std::make_unique<Blueprint>(cfg);
0293 
0294   root->addStaticVolume(
0295       base, std::make_shared<CylinderVolumeBounds>(50_mm, 400_mm, 1000_mm),
0296       "PixelWrapper", [&](auto& wrap) {
0297         double rMin = 100_mm;
0298         double rMax = 350_mm;
0299         double hlZ = 100_mm;
0300 
0301         wrap.addStaticVolume(
0302             base * Translation3{Vector3{0, 0, -600_mm}},
0303             std::make_shared<CylinderVolumeBounds>(rMin, rMax, hlZ),
0304             "PixelNeg1");
0305 
0306         wrap.addStaticVolume(
0307             base * Translation3{Vector3{0, 0, -200_mm}},
0308             std::make_shared<CylinderVolumeBounds>(rMin, rMax, hlZ),
0309             "PixelNeg2");
0310 
0311         wrap.addStaticVolume(
0312             base * Translation3{Vector3{0, 0, 200_mm}},
0313             std::make_shared<CylinderVolumeBounds>(rMin, rMax, hlZ),
0314             "PixelPos1");
0315 
0316         wrap.addStaticVolume(
0317             base * Translation3{Vector3{0, 0, 600_mm}},
0318             std::make_shared<CylinderVolumeBounds>(rMin, rMax, hlZ),
0319             "PixelPos2");
0320       });
0321 
0322   auto trackingGeometry = root->construct({}, gctx, *logger);
0323 
0324   // overall dimensions are the wrapper volume + envelope
0325   auto lookup = nameLookup(*trackingGeometry);
0326 
0327   BOOST_CHECK_EQUAL(lookup("World").volumeBounds().type(),
0328                     VolumeBounds::BoundsType::eCylinder);
0329   auto worldCyl =
0330       dynamic_cast<const CylinderVolumeBounds&>(lookup("World").volumeBounds());
0331   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 48_mm);
0332   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 420_mm);
0333   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ), 1020_mm);
0334 
0335   // 4 outer portals and 4 inner
0336   BOOST_CHECK_EQUAL(lookup("World").portals().size(), 8);
0337   BOOST_CHECK_EQUAL(lookup("World").volumes().size(), 1);
0338 
0339   BOOST_CHECK_EQUAL(lookup("PixelWrapper").volumeBounds().type(),
0340                     VolumeBounds::BoundsType::eCylinder);
0341 
0342   auto wrapperCyl = dynamic_cast<const CylinderVolumeBounds&>(
0343       lookup("PixelWrapper").volumeBounds());
0344   BOOST_CHECK_EQUAL(wrapperCyl.get(CylinderVolumeBounds::eMinR), 50_mm);
0345   BOOST_CHECK_EQUAL(wrapperCyl.get(CylinderVolumeBounds::eMaxR), 400_mm);
0346   BOOST_CHECK_EQUAL(wrapperCyl.get(CylinderVolumeBounds::eHalfLengthZ),
0347                     1000_mm);
0348   BOOST_CHECK_EQUAL(lookup("PixelWrapper").portals().size(), 4 + 4 * 4);
0349   BOOST_CHECK_EQUAL(lookup("PixelWrapper").volumes().size(), 4);
0350 
0351   for (const auto& name :
0352        {"PixelNeg1", "PixelNeg2", "PixelPos1", "PixelPos2"}) {
0353     BOOST_CHECK_EQUAL(lookup(name).volumeBounds().type(),
0354                       VolumeBounds::BoundsType::eCylinder);
0355     auto actCyl =
0356         dynamic_cast<const CylinderVolumeBounds&>(lookup(name).volumeBounds());
0357     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMinR), 100_mm);
0358     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMaxR), 350_mm);
0359     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eHalfLengthZ), 100_mm);
0360     BOOST_CHECK_EQUAL(lookup(name).portals().size(), 4);
0361   }
0362 }
0363 
0364 BOOST_AUTO_TEST_CASE(ConfinedWithShared) {
0365   Transform3 base{Transform3::Identity()};
0366 
0367   constexpr double rMin = 100_mm;
0368   constexpr double rMax = 350_mm;
0369   constexpr double hlZ = 100_mm;
0370 
0371   auto sharedBounds = std::make_shared<CylinderVolumeBounds>(rMin, rMax, hlZ);
0372   Blueprint::Config cfg;
0373   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0374   cfg.envelope[AxisDirection::AxisR] = {2_mm, 20_mm};
0375   auto root = std::make_unique<Blueprint>(cfg);
0376 
0377   root->addCylinderContainer(
0378       "PixelWrapper", AxisDirection::AxisZ, [&](auto& wrap) {
0379         wrap.addStaticVolume(base * Translation3{Vector3{0, 0, -750_mm}},
0380                              sharedBounds, "PixelNeg1");
0381 
0382         wrap.addStaticVolume(base * Translation3{Vector3{0, 0, -200_mm}},
0383                              sharedBounds, "PixelNeg2");
0384 
0385         wrap.addStaticVolume(base * Translation3{Vector3{0, 0, 200_mm}},
0386                              sharedBounds, "PixelPos1");
0387 
0388         wrap.addStaticVolume(base * Translation3{Vector3{0, 0, 975_mm}},
0389                              sharedBounds, "PixelPos2");
0390       });
0391   auto trackingGeometry = root->construct({}, gctx, *logger);
0392   // overall dimensions are the wrapper volume + envelope
0393   auto lookup = nameLookup(*trackingGeometry);
0394   BOOST_CHECK_EQUAL(lookup("World").volumeBounds().type(),
0395                     VolumeBounds::BoundsType::eCylinder);
0396   auto worldCyl =
0397       dynamic_cast<const CylinderVolumeBounds&>(lookup("World").volumeBounds());
0398   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 98_mm);
0399   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 370_mm);
0400   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ), 982.5_mm);
0401   // 4 outer portals and 4 inner
0402   BOOST_CHECK_EQUAL(lookup("World").portals().size(), 8);
0403   BOOST_CHECK_EQUAL(lookup("World").volumes().size(), 4);
0404 
0405   constexpr std::array<double, 4> expHalfL{187.5_mm, 237.5_mm, 293.75_mm,
0406                                            243.75_mm};
0407   const std::array<std::string, 4> volNames{"PixelNeg1", "PixelNeg2",
0408                                             "PixelPos1", "PixelPos2"};
0409   for (std::size_t v = 0; v < 4; ++v) {
0410     const auto& testMe{lookup(volNames[v])};
0411     BOOST_CHECK_EQUAL(testMe.volumeBounds().type(),
0412                       VolumeBounds::BoundsType::eCylinder);
0413     BOOST_CHECK_EQUAL(testMe.volumeBoundsPtr() != sharedBounds, true);
0414 
0415     auto actCyl =
0416         dynamic_cast<const CylinderVolumeBounds&>(testMe.volumeBounds());
0417     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMinR), 100_mm);
0418     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eMaxR), 350_mm);
0419     BOOST_CHECK_EQUAL(actCyl.get(CylinderVolumeBounds::eHalfLengthZ),
0420                       expHalfL[v]);
0421     BOOST_CHECK_EQUAL(testMe.portals().size(), 4);
0422     if (v + 1 == 4) {
0423       break;
0424     }
0425     const auto& nextVol = lookup(volNames[(v + 1)]);
0426     const Vector3 outside =
0427         testMe.localToGlobalTransform(gctx).translation() +
0428         Vector3{150_mm, 0.,
0429                 actCyl.get(CylinderVolumeBounds::eHalfLengthZ) - 0.5_mm};
0430     BOOST_CHECK_EQUAL(nextVol.inside(gctx, outside), false);
0431     const Vector3 inside =
0432         testMe.localToGlobalTransform(gctx).translation() +
0433         Vector3{150_mm, 0.,
0434                 actCyl.get(CylinderVolumeBounds::eHalfLengthZ) + 0.5_mm};
0435     BOOST_CHECK_EQUAL(nextVol.inside(gctx, inside), true);
0436   }
0437 }
0438 
0439 BOOST_AUTO_TEST_CASE(DiscLayer) {
0440   double yrot = 45_degree;
0441   Transform3 base = Transform3::Identity() * AngleAxis3{yrot, Vector3::UnitY()};
0442 
0443   std::vector<std::shared_ptr<Surface>> surfaces;
0444   std::vector<std::unique_ptr<SurfacePlacementBase>> elements;
0445   double r = 300_mm;
0446   std::size_t nSensors = 8;
0447   double thickness = 2.5_mm;
0448   auto recBounds = std::make_shared<RectangleBounds>(40_mm, 60_mm);
0449 
0450   double deltaPhi = 2 * std::numbers::pi / nSensors;
0451   for (std::size_t i = 0; i < nSensors; i++) {
0452     // Create a fan of sensors
0453 
0454     Transform3 trf = base * AngleAxis3{deltaPhi * i, Vector3::UnitZ()} *
0455                      Translation3(Vector3::UnitX() * r);
0456 
0457     if (i % 2 == 0) {
0458       trf = trf * Translation3{Vector3::UnitZ() * 5_mm};
0459     }
0460 
0461     auto& element = elements.emplace_back(
0462         std::make_unique<DetectorElementStub>(trf, recBounds, thickness));
0463 
0464     element->surface().assignSurfacePlacement(*element);
0465 
0466     surfaces.push_back(element->surface().getSharedPtr());
0467   }
0468 
0469   std::function<void(LayerBlueprintNode&)> withSurfaces =
0470       [&surfaces, &base](LayerBlueprintNode& layer) {
0471         layer.setSurfaces(surfaces)
0472             .setLayerType(LayerBlueprintNode::LayerType::Disc)
0473             .setEnvelope(ExtentEnvelope{{
0474                 .z = {0.1_mm, 0.1_mm},
0475                 .r = {1_mm, 1_mm},
0476             }})
0477             .setTransform(base);
0478       };
0479 
0480   std::function<void(LayerBlueprintNode&)> withProtoLayer =
0481       [&surfaces, &base](LayerBlueprintNode& layer) {
0482         MutableProtoLayer protoLayer{gctx, surfaces, base.inverse()};
0483         layer.setProtoLayer(protoLayer)
0484             .setLayerType(LayerBlueprintNode::LayerType::Disc)
0485             .setEnvelope(ExtentEnvelope{{
0486                 .z = {0.1_mm, 0.1_mm},
0487                 .r = {1_mm, 1_mm},
0488             }});
0489       };
0490 
0491   for (const auto& func : {withSurfaces, withProtoLayer}) {
0492     Blueprint root{{.envelope = ExtentEnvelope{{
0493                         .z = {2_mm, 2_mm},
0494                         .r = {3_mm, 5_mm},
0495                     }}}};
0496 
0497     root.addLayer("Layer0", [&](auto& layer) { func(layer); });
0498 
0499     auto trackingGeometry = root.construct({}, gctx, *logger);
0500 
0501     std::size_t nSurfaces = 0;
0502 
0503     trackingGeometry->visitSurfaces([&](const Surface* surface) {
0504       if (surface->isSensitive()) {
0505         nSurfaces++;
0506       }
0507     });
0508 
0509     BOOST_CHECK_EQUAL(nSurfaces, surfaces.size());
0510     BOOST_CHECK_EQUAL(countVolumes(*trackingGeometry), 2);
0511     auto lookup = nameLookup(*trackingGeometry);
0512 
0513     BOOST_CHECK_EQUAL(lookup("Layer0").volumeBounds().type(),
0514                       VolumeBounds::BoundsType::eCylinder);
0515     auto layerCyl = dynamic_cast<const CylinderVolumeBounds&>(
0516         lookup("Layer0").volumeBounds());
0517     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMinR), 258.9999999_mm,
0518                       1e-6);
0519     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMaxR),
0520                       346.25353003_mm, 1e-6);
0521     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eHalfLengthZ), 3.85_mm,
0522                       1e-6);
0523   }
0524 }
0525 
0526 BOOST_AUTO_TEST_CASE(CylinderLayer) {
0527   double yrot = 0_degree;
0528   Transform3 base = Transform3::Identity() * AngleAxis3{yrot, Vector3::UnitY()};
0529 
0530   std::vector<std::shared_ptr<Surface>> surfaces;
0531   std::vector<std::unique_ptr<SurfacePlacementBase>> elements;
0532 
0533   double r = 300_mm;
0534   std::size_t nStaves = 10;
0535   int nSensorsPerStave = 8;
0536   double thickness = 0;
0537   double hlPhi = 40_mm;
0538   double hlZ = 60_mm;
0539   auto recBounds = std::make_shared<RectangleBounds>(hlPhi, hlZ);
0540 
0541   double deltaPhi = 2 * std::numbers::pi / nStaves;
0542 
0543   for (std::size_t istave = 0; istave < nStaves; istave++) {
0544     for (int isensor = -nSensorsPerStave; isensor <= nSensorsPerStave;
0545          isensor++) {
0546       double z = isensor * (2 * hlZ + 5_mm);
0547 
0548       Transform3 trf = base * Translation3(Vector3::UnitZ() * z) *
0549                        AngleAxis3{deltaPhi * istave, Vector3::UnitZ()} *
0550                        Translation3(Vector3::UnitX() * r) *
0551                        AngleAxis3{10_degree, Vector3::UnitZ()} *
0552                        AngleAxis3{90_degree, Vector3::UnitY()} *
0553                        AngleAxis3{90_degree, Vector3::UnitZ()};
0554       auto& element = elements.emplace_back(
0555           std::make_unique<DetectorElementStub>(trf, recBounds, thickness));
0556       element->surface().assignSurfacePlacement(*element);
0557       surfaces.push_back(element->surface().getSharedPtr());
0558     }
0559   }
0560 
0561   std::function<void(LayerBlueprintNode&)> withSurfaces =
0562       [&surfaces, &base](LayerBlueprintNode& layer) {
0563         layer.setSurfaces(surfaces)
0564             .setLayerType(LayerBlueprintNode::LayerType::Cylinder)
0565             .setEnvelope(ExtentEnvelope{{
0566                 .z = {10_mm, 10_mm},
0567                 .r = {20_mm, 10_mm},
0568             }})
0569             .setTransform(base);
0570       };
0571 
0572   std::function<void(LayerBlueprintNode&)> withProtoLayer =
0573       [&surfaces, &base](LayerBlueprintNode& layer) {
0574         MutableProtoLayer protoLayer{gctx, surfaces, base.inverse()};
0575         layer.setProtoLayer(protoLayer)
0576             .setLayerType(LayerBlueprintNode::LayerType::Cylinder)
0577             .setEnvelope(ExtentEnvelope{{
0578                 .z = {10_mm, 10_mm},
0579                 .r = {20_mm, 10_mm},
0580             }});
0581       };
0582 
0583   for (const auto& func : {withSurfaces, withProtoLayer}) {
0584     Blueprint root{{.envelope = ExtentEnvelope{{
0585                         .z = {2_mm, 2_mm},
0586                         .r = {3_mm, 5_mm},
0587                     }}}};
0588 
0589     root.addLayer("Layer0", [&](auto& layer) { func(layer); });
0590 
0591     auto trackingGeometry = root.construct({}, gctx, *logger);
0592 
0593     std::size_t nSurfaces = 0;
0594 
0595     trackingGeometry->visitSurfaces([&](const Surface* surface) {
0596       if (surface->isSensitive()) {
0597         nSurfaces++;
0598       }
0599     });
0600 
0601     BOOST_CHECK_EQUAL(nSurfaces, surfaces.size());
0602     BOOST_CHECK_EQUAL(countVolumes(*trackingGeometry), 2);
0603     auto lookup = nameLookup(*trackingGeometry);
0604 
0605     BOOST_CHECK_EQUAL(lookup("Layer0").volumeBounds().type(),
0606                       VolumeBounds::BoundsType::eCylinder);
0607     auto layerCyl = dynamic_cast<const CylinderVolumeBounds&>(
0608         lookup("Layer0").volumeBounds());
0609     BOOST_CHECK_EQUAL(lookup("Layer0").portals().size(), 4);
0610     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMinR), 275.6897761_mm,
0611                       1e-6);
0612     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMaxR), 319.4633358_mm,
0613                       1e-6);
0614     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eHalfLengthZ), 1070_mm,
0615                       1e-6);
0616   }
0617 }
0618 
0619 BOOST_AUTO_TEST_CASE(MaterialTesting) {
0620   Blueprint::Config cfg;
0621   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0622   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0623   Blueprint root{cfg};
0624 
0625   double hlZ = 30_mm;
0626   auto cylBounds = std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, hlZ);
0627   auto cyl = std::make_unique<TrackingVolume>(Transform3::Identity(), cylBounds,
0628                                               "child");
0629 
0630   using enum AxisDirection;
0631   using enum CylinderVolumeBounds::Face;
0632   using enum AxisBoundaryType;
0633 
0634   root.addMaterial("Material", [&](auto& mat) {
0635     mat.configureFace(NegativeDisc, AxisSpec::DeferredEquidistant(5, AxisR),
0636                       AxisSpec::DeferredEquidistant(10, AxisPhi));
0637     mat.configureFace(PositiveDisc, AxisSpec::DeferredEquidistant(15, AxisR),
0638                       AxisSpec::DeferredEquidistant(20, AxisPhi));
0639     mat.configureFace(OuterCylinder,
0640                       AxisSpec::DeferredEquidistant(25, AxisRPhi),
0641                       AxisSpec::DeferredEquidistant(30, AxisZ));
0642 
0643     mat.addStaticVolume(std::move(cyl));
0644   });
0645 
0646   auto trackingGeometry = root.construct({}, gctx, *logger);
0647 
0648   BOOST_CHECK_EQUAL(countVolumes(*trackingGeometry), 2);
0649   auto lookup = nameLookup(*trackingGeometry);
0650   auto& child = lookup("child");
0651 
0652   // Check negative disc material
0653   const auto* negDisc = child.portals()
0654                             .at(static_cast<std::size_t>(NegativeDisc))
0655                             .surface()
0656                             .surfaceMaterial();
0657   BOOST_REQUIRE_NE(negDisc, nullptr);
0658   const auto& negDiscMat =
0659       dynamic_cast<const ProtoGridSurfaceMaterial&>(*negDisc);
0660   // Check positive disc material
0661   const auto* posDisc = child.portals()
0662                             .at(static_cast<std::size_t>(PositiveDisc))
0663                             .surface()
0664                             .surfaceMaterial();
0665   BOOST_REQUIRE_NE(posDisc, nullptr);
0666   const auto& posDiscMat =
0667       dynamic_cast<const ProtoGridSurfaceMaterial&>(*posDisc);
0668 
0669   BOOST_CHECK_EQUAL(negDiscMat.binning().axisSpec(0).nBins(), 5);
0670   BOOST_CHECK_EQUAL(negDiscMat.binning().axisSpec(1).nBins(), 10);
0671   BOOST_CHECK_EQUAL(posDiscMat.binning().axisSpec(0).nBins(), 15);
0672   BOOST_CHECK_EQUAL(posDiscMat.binning().axisSpec(1).nBins(), 20);
0673 
0674   // Check outer cylinder material
0675   const auto* outerCyl = child.portals()
0676                              .at(static_cast<std::size_t>(OuterCylinder))
0677                              .surface()
0678                              .surfaceMaterial();
0679   BOOST_REQUIRE_NE(outerCyl, nullptr);
0680   const auto& outerCylMat =
0681       dynamic_cast<const ProtoGridSurfaceMaterial&>(*outerCyl);
0682   BOOST_CHECK_EQUAL(outerCylMat.binning().axisSpec(0).nBins(), 25);
0683   BOOST_CHECK_EQUAL(outerCylMat.binning().axisSpec(1).nBins(), 30);
0684 
0685   // Check that other faces have no material
0686   for (std::size_t i = 0; i < child.portals().size(); i++) {
0687     if (i != static_cast<std::size_t>(NegativeDisc) &&
0688         i != static_cast<std::size_t>(PositiveDisc) &&
0689         i != static_cast<std::size_t>(OuterCylinder)) {
0690       BOOST_CHECK_EQUAL(child.portals().at(i).surface().surfaceMaterial(),
0691                         nullptr);
0692     }
0693   }
0694 }
0695 
0696 BOOST_AUTO_TEST_CASE(MaterialInvalidAxisDirections) {
0697   Blueprint::Config cfg;
0698   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0699   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0700   Blueprint root{cfg};
0701 
0702   using enum AxisDirection;
0703   using enum AxisBoundaryType;
0704 
0705   // Test invalid axis direction combinations for cylinder faces
0706   BOOST_CHECK_THROW(
0707       root.addMaterial("Material",
0708                        [&](auto& mat) {
0709                          mat.configureFace(
0710                              CylinderVolumeBounds::Face::NegativeDisc,
0711                              AxisSpec::DeferredEquidistant(5, AxisZ),
0712                              AxisSpec::DeferredEquidistant(10, AxisPhi));
0713                        }),
0714       std::invalid_argument);
0715 
0716   BOOST_CHECK_THROW(
0717       root.addMaterial("Material",
0718                        [&](auto& mat) {
0719                          mat.configureFace(
0720                              CylinderVolumeBounds::Face::OuterCylinder,
0721                              AxisSpec::DeferredEquidistant(5, AxisR),
0722                              AxisSpec::DeferredEquidistant(10, AxisR));
0723                        }),
0724       std::invalid_argument);
0725 
0726   // Test invalid axis direction combinations for cuboid faces
0727   BOOST_CHECK_THROW(
0728       root.addMaterial("Material",
0729                        [&](auto& mat) {
0730                          mat.configureFace(
0731                              CuboidVolumeBounds::Face::NegativeXFace,
0732                              AxisSpec::DeferredEquidistant(5, AxisX),
0733                              AxisSpec::DeferredEquidistant(10, AxisZ));
0734                        }),
0735       std::invalid_argument);
0736 
0737   BOOST_CHECK_THROW(
0738       root.addMaterial("Material",
0739                        [&](auto& mat) {
0740                          mat.configureFace(
0741                              CuboidVolumeBounds::Face::PositiveYFace,
0742                              AxisSpec::DeferredEquidistant(5, AxisY),
0743                              AxisSpec::DeferredEquidistant(10, AxisX));
0744                        }),
0745       std::invalid_argument);
0746 
0747   BOOST_CHECK_THROW(
0748       root.addMaterial("Material",
0749                        [&](auto& mat) {
0750                          mat.configureFace(
0751                              CuboidVolumeBounds::Face::NegativeZFace,
0752                              AxisSpec::DeferredEquidistant(5, AxisZ),
0753                              AxisSpec::DeferredEquidistant(10, AxisY));
0754                        }),
0755       std::invalid_argument);
0756 }
0757 
0758 BOOST_AUTO_TEST_CASE(MaterialAxisValidation) {
0759   Blueprint::Config cfg;
0760   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0761   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0762   Blueprint root{cfg};
0763 
0764   using enum AxisDirection;
0765   using enum AxisBoundaryType;
0766   using enum CylinderVolumeBounds::Face;
0767 
0768   // Fully specified axes are rejected: the range comes from the surface
0769   BOOST_CHECK_THROW(
0770       root.addMaterial("Material",
0771                        [&](auto& mat) {
0772                          mat.configureFace(
0773                              NegativeDisc,
0774                              AxisSpec::Equidistant(5, 0., 1., Bound, AxisR),
0775                              AxisSpec::DeferredEquidistant(10, AxisPhi));
0776                        }),
0777       std::invalid_argument);
0778 
0779   // Axes without directions are accepted, the directions follow from the face
0780   BOOST_CHECK_NO_THROW(root.addMaterial("Material", [&](auto& mat) {
0781     mat.configureFace(NegativeDisc, AxisSpec::DeferredEquidistant(5),
0782                       AxisSpec::DeferredEquidistant(10));
0783   }));
0784 
0785   // The superseded DirectedProtoAxis interface converts to deferred axes
0786   ACTS_PUSH_IGNORE_DEPRECATED()
0787   BOOST_CHECK_NO_THROW(root.addMaterial("Material", [&](auto& mat) {
0788     mat.configureFace(PositiveDisc, DirectedProtoAxis(AxisR, Bound, 5),
0789                       DirectedProtoAxis(AxisPhi, Bound, 0., 1., 10));
0790   }));
0791   ACTS_POP_IGNORE_DEPRECATED()
0792 }
0793 
0794 BOOST_AUTO_TEST_CASE(MaterialMixedVolumeTypes) {
0795   Blueprint::Config cfg;
0796   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0797   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0798   Blueprint root{cfg};
0799 
0800   using enum AxisDirection;
0801   using enum AxisBoundaryType;
0802 
0803   // Configure for cylinder first, then try to add cuboid - should throw
0804   BOOST_CHECK_THROW(
0805       root.addMaterial(
0806           "Material",
0807           [&](auto& mat) {
0808             mat.configureFace(CylinderVolumeBounds::Face::NegativeDisc,
0809                               AxisSpec::DeferredEquidistant(5, AxisR),
0810                               AxisSpec::DeferredEquidistant(10, AxisPhi));
0811             mat.configureFace(CuboidVolumeBounds::Face::NegativeXFace,
0812                               AxisSpec::DeferredEquidistant(5, AxisX),
0813                               AxisSpec::DeferredEquidistant(10, AxisY));
0814           }),
0815       std::runtime_error);
0816 
0817   // Configure for cuboid first, then try to add cylinder - should throw
0818   BOOST_CHECK_THROW(
0819       root.addMaterial(
0820           "Material",
0821           [&](auto& mat) {
0822             mat.configureFace(CuboidVolumeBounds::Face::NegativeXFace,
0823                               AxisSpec::DeferredEquidistant(5, AxisX),
0824                               AxisSpec::DeferredEquidistant(10, AxisY));
0825             mat.configureFace(CylinderVolumeBounds::Face::NegativeDisc,
0826                               AxisSpec::DeferredEquidistant(5, AxisR),
0827                               AxisSpec::DeferredEquidistant(10, AxisPhi));
0828           }),
0829       std::runtime_error);
0830 }
0831 
0832 BOOST_AUTO_TEST_CASE(MaterialCuboid) {
0833   Blueprint::Config cfg;
0834   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0835   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0836   Blueprint root{cfg};
0837 
0838   using enum AxisDirection;
0839   using enum AxisBoundaryType;
0840   using enum CuboidVolumeBounds::Face;
0841 
0842   double hlX = 30_mm;
0843   double hlY = 40_mm;
0844   double hlZ = 50_mm;
0845   auto cuboidBounds = std::make_shared<CuboidVolumeBounds>(hlX, hlY, hlZ);
0846   auto cuboid = std::make_unique<TrackingVolume>(Transform3::Identity(),
0847                                                  cuboidBounds, "child");
0848 
0849   auto mat = std::make_shared<MaterialDesignatorBlueprintNode>("Material");
0850 
0851   // Configure material for different faces with different binning
0852   mat->configureFace(NegativeXFace, AxisSpec::DeferredEquidistant(5, AxisX),
0853                      AxisSpec::DeferredEquidistant(10, AxisY));
0854   mat->configureFace(PositiveXFace, AxisSpec::DeferredEquidistant(15, AxisX),
0855                      AxisSpec::DeferredEquidistant(20, AxisY));
0856   mat->configureFace(NegativeYFace, AxisSpec::DeferredEquidistant(25, AxisX),
0857                      AxisSpec::DeferredEquidistant(30, AxisY));
0858   mat->configureFace(PositiveYFace, AxisSpec::DeferredEquidistant(35, AxisX),
0859                      AxisSpec::DeferredEquidistant(40, AxisY));
0860   mat->configureFace(NegativeZFace, AxisSpec::DeferredEquidistant(45, AxisX),
0861                      AxisSpec::DeferredEquidistant(50, AxisY));
0862   mat->configureFace(PositiveZFace, AxisSpec::DeferredEquidistant(55, AxisX),
0863                      AxisSpec::DeferredEquidistant(60, AxisY));
0864 
0865   mat->addChild(std::make_shared<StaticBlueprintNode>(std::move(cuboid)));
0866 
0867   root.addChild(mat);
0868 
0869   auto trackingGeometry =
0870       root.construct({}, gctx, *logger->clone(std::nullopt, Logging::VERBOSE));
0871 
0872   BOOST_REQUIRE(trackingGeometry);
0873 
0874   auto lookup = nameLookup(*trackingGeometry);
0875   auto& child = lookup("child");
0876 
0877   // Check that material is attached to all faces
0878   for (std::size_t i = 0; i < child.portals().size(); i++) {
0879     const auto* material = child.portals().at(i).surface().surfaceMaterial();
0880     BOOST_REQUIRE_NE(material, nullptr);
0881 
0882     const auto& gridMaterial =
0883         dynamic_cast<const ProtoGridSurfaceMaterial&>(*material);
0884 
0885     // Check binning based on face
0886     CuboidVolumeBounds::Face face = static_cast<CuboidVolumeBounds::Face>(i);
0887     switch (face) {
0888       case NegativeXFace:
0889         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(0).nBins(), 5);
0890         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(1).nBins(), 10);
0891         break;
0892       case PositiveXFace:
0893         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(0).nBins(), 15);
0894         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(1).nBins(), 20);
0895         break;
0896       case NegativeYFace:
0897         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(0).nBins(), 25);
0898         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(1).nBins(), 30);
0899         break;
0900       case PositiveYFace:
0901         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(0).nBins(), 35);
0902         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(1).nBins(), 40);
0903         break;
0904       case NegativeZFace:
0905         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(0).nBins(), 45);
0906         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(1).nBins(), 50);
0907         break;
0908       case PositiveZFace:
0909         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(0).nBins(), 55);
0910         BOOST_CHECK_EQUAL(gridMaterial.binning().axisSpec(1).nBins(), 60);
0911         break;
0912     }
0913   }
0914 }
0915 
0916 BOOST_AUTO_TEST_CASE(HomogeneousMaterialCylinder) {
0917   Blueprint::Config cfg;
0918   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0919   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0920   Blueprint root{cfg};
0921 
0922   double hlZ = 30_mm;
0923   auto cylBounds = std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, hlZ);
0924   auto cyl = std::make_unique<TrackingVolume>(Transform3::Identity(), cylBounds,
0925                                               "child");
0926 
0927   using enum CylinderVolumeBounds::Face;
0928 
0929   // Create some homogeneous materials with different properties
0930   auto testMaterial = Material::fromMolarDensity(
0931       9.370_cm, 46.52_cm, 28.0855, 14, (2.329 / 28.0855) * 1_mol / 1_cm3);
0932 
0933   auto negDiscMat = std::make_shared<HomogeneousSurfaceMaterial>(
0934       MaterialSlab(testMaterial, 0.1_mm));
0935   auto posDiscMat = std::make_shared<HomogeneousSurfaceMaterial>(
0936       MaterialSlab(testMaterial, 0.2_mm));
0937   auto outerCylMat = std::make_shared<HomogeneousSurfaceMaterial>(
0938       MaterialSlab(testMaterial, 0.3_mm));
0939 
0940   root.addMaterial("Material", [&](auto& mat) {
0941     mat.configureFace(NegativeDisc, negDiscMat);
0942     mat.configureFace(PositiveDisc, posDiscMat);
0943     mat.configureFace(OuterCylinder, outerCylMat);
0944 
0945     mat.addStaticVolume(std::move(cyl));
0946   });
0947 
0948   auto trackingGeometry = root.construct({}, gctx, *logger);
0949 
0950   BOOST_CHECK_EQUAL(countVolumes(*trackingGeometry), 2);
0951   auto lookup = nameLookup(*trackingGeometry);
0952   auto& child = lookup("child");
0953 
0954   // Check negative disc material
0955   const auto* negDisc = child.portals()
0956                             .at(static_cast<std::size_t>(NegativeDisc))
0957                             .surface()
0958                             .surfaceMaterial();
0959   BOOST_REQUIRE_NE(negDisc, nullptr);
0960   BOOST_CHECK_EQUAL(negDisc, negDiscMat.get());
0961 
0962   // Check positive disc material
0963   const auto* posDisc = child.portals()
0964                             .at(static_cast<std::size_t>(PositiveDisc))
0965                             .surface()
0966                             .surfaceMaterial();
0967   BOOST_REQUIRE_NE(posDisc, nullptr);
0968   BOOST_CHECK_EQUAL(posDisc, posDiscMat.get());
0969 
0970   // Check outer cylinder material
0971   const auto* outerCyl = child.portals()
0972                              .at(static_cast<std::size_t>(OuterCylinder))
0973                              .surface()
0974                              .surfaceMaterial();
0975   BOOST_REQUIRE_NE(outerCyl, nullptr);
0976   BOOST_CHECK_EQUAL(outerCyl, outerCylMat.get());
0977 
0978   // Check that other faces have no material
0979   for (std::size_t i = 0; i < child.portals().size(); i++) {
0980     if (i != static_cast<std::size_t>(NegativeDisc) &&
0981         i != static_cast<std::size_t>(PositiveDisc) &&
0982         i != static_cast<std::size_t>(OuterCylinder)) {
0983       BOOST_CHECK_EQUAL(child.portals().at(i).surface().surfaceMaterial(),
0984                         nullptr);
0985     }
0986   }
0987 }
0988 
0989 BOOST_AUTO_TEST_CASE(HomogeneousMaterialCuboid) {
0990   Blueprint::Config cfg;
0991   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0992   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
0993   Blueprint root{cfg};
0994 
0995   using enum CuboidVolumeBounds::Face;
0996 
0997   double hlX = 30_mm;
0998   double hlY = 40_mm;
0999   double hlZ = 50_mm;
1000   auto cuboidBounds = std::make_shared<CuboidVolumeBounds>(hlX, hlY, hlZ);
1001   auto cuboid = std::make_unique<TrackingVolume>(Transform3::Identity(),
1002                                                  cuboidBounds, "child");
1003 
1004   // Create different homogeneous materials for each face
1005   auto testMaterial = Material::fromMolarDensity(
1006       9.370_cm, 46.52_cm, 28.0855, 14, (2.329 / 28.0855) * 1_mol / 1_cm3);
1007 
1008   auto negXMat = std::make_shared<HomogeneousSurfaceMaterial>(
1009       MaterialSlab(testMaterial, 0.1_mm));
1010   auto posXMat = std::make_shared<HomogeneousSurfaceMaterial>(
1011       MaterialSlab(testMaterial, 0.2_mm));
1012   auto negYMat = std::make_shared<HomogeneousSurfaceMaterial>(
1013       MaterialSlab(testMaterial, 0.3_mm));
1014   auto posYMat = std::make_shared<HomogeneousSurfaceMaterial>(
1015       MaterialSlab(testMaterial, 0.4_mm));
1016   auto negZMat = std::make_shared<HomogeneousSurfaceMaterial>(
1017       MaterialSlab(testMaterial, 0.5_mm));
1018   auto posZMat = std::make_shared<HomogeneousSurfaceMaterial>(
1019       MaterialSlab(testMaterial, 0.6_mm));
1020 
1021   root.addMaterial("Material", [&](auto& mat) {
1022     mat.configureFace(NegativeXFace, negXMat);
1023     mat.configureFace(PositiveXFace, posXMat);
1024     mat.configureFace(NegativeYFace, negYMat);
1025     mat.configureFace(PositiveYFace, posYMat);
1026     mat.configureFace(NegativeZFace, negZMat);
1027     mat.configureFace(PositiveZFace, posZMat);
1028 
1029     mat.addStaticVolume(std::move(cuboid));
1030   });
1031 
1032   auto trackingGeometry = root.construct({}, gctx, *logger);
1033 
1034   BOOST_REQUIRE(trackingGeometry);
1035 
1036   auto lookup = nameLookup(*trackingGeometry);
1037   auto& child = lookup("child");
1038 
1039   // Check that material is attached to all faces with correct properties
1040   for (std::size_t i = 0; i < child.portals().size(); i++) {
1041     const auto* material = child.portals().at(i).surface().surfaceMaterial();
1042     BOOST_REQUIRE_NE(material, nullptr);
1043 
1044     const auto* homMaterial =
1045         dynamic_cast<const HomogeneousSurfaceMaterial*>(material);
1046     BOOST_REQUIRE_NE(homMaterial, nullptr);
1047 
1048     // Check thickness based on face
1049     CuboidVolumeBounds::Face face = static_cast<CuboidVolumeBounds::Face>(i);
1050     switch (face) {
1051       case NegativeXFace:
1052         BOOST_CHECK_EQUAL(homMaterial, negXMat.get());
1053         break;
1054       case PositiveXFace:
1055         BOOST_CHECK_EQUAL(homMaterial, posXMat.get());
1056         break;
1057       case NegativeYFace:
1058         BOOST_CHECK_EQUAL(homMaterial, negYMat.get());
1059         break;
1060       case PositiveYFace:
1061         BOOST_CHECK_EQUAL(homMaterial, posYMat.get());
1062         break;
1063       case NegativeZFace:
1064         BOOST_CHECK_EQUAL(homMaterial, negZMat.get());
1065         break;
1066       case PositiveZFace:
1067         BOOST_CHECK_EQUAL(homMaterial, posZMat.get());
1068         break;
1069     }
1070   }
1071 }
1072 
1073 BOOST_AUTO_TEST_CASE(HomogeneousMaterialMixedVolumeTypes) {
1074   Blueprint::Config cfg;
1075   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
1076   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1077   Blueprint root{cfg};
1078 
1079   auto testMaterial = Material::fromMolarDensity(
1080       9.370_cm, 46.52_cm, 28.0855, 14, (2.329 / 28.0855) * 1_mol / 1_cm3);
1081 
1082   auto material = std::make_shared<HomogeneousSurfaceMaterial>(
1083       MaterialSlab(testMaterial, 0.1_mm));
1084 
1085   // Configure for cylinder first, then try to add cuboid - should throw
1086   BOOST_CHECK_THROW(
1087       root.addMaterial("Material",
1088                        [&](auto& mat) {
1089                          mat.configureFace(
1090                              CylinderVolumeBounds::Face::NegativeDisc,
1091                              material);
1092                          mat.configureFace(
1093                              CuboidVolumeBounds::Face::NegativeXFace, material);
1094                        }),
1095       std::runtime_error);
1096 
1097   // Configure for cuboid first, then try to add cylinder - should throw
1098   BOOST_CHECK_THROW(
1099       root.addMaterial("Material",
1100                        [&](auto& mat) {
1101                          mat.configureFace(
1102                              CuboidVolumeBounds::Face::NegativeXFace, material);
1103                          mat.configureFace(
1104                              CylinderVolumeBounds::Face::NegativeDisc,
1105                              material);
1106                        }),
1107       std::runtime_error);
1108 }
1109 
1110 BOOST_AUTO_TEST_CASE(LayerCenterOfGravity) {
1111   // Test disc layer with center of gravity disabled
1112   {
1113     double yrot = 45_degree;
1114     Transform3 base =
1115         Transform3::Identity() * AngleAxis3{yrot, Vector3::UnitY()};
1116 
1117     std::vector<std::shared_ptr<Surface>> surfaces;
1118     std::vector<std::unique_ptr<SurfacePlacementBase>> elements;
1119     double r = 300_mm;
1120     std::size_t nSensors = 8;
1121     double thickness = 2.5_mm;
1122     auto recBounds = std::make_shared<RectangleBounds>(40_mm, 60_mm);
1123 
1124     double deltaPhi = 2 * std::numbers::pi / nSensors;
1125     for (std::size_t i = 0; i < nSensors; i++) {
1126       Transform3 trf = base * AngleAxis3{deltaPhi * i, Vector3::UnitZ()} *
1127                        Translation3(Vector3::UnitX() * r);
1128 
1129       if (i % 2 == 0) {
1130         trf = trf * Translation3{Vector3::UnitZ() * 5_mm};
1131       }
1132 
1133       auto& element = elements.emplace_back(
1134           std::make_unique<DetectorElementStub>(trf, recBounds, thickness));
1135 
1136       element->surface().assignSurfacePlacement(*element);
1137       surfaces.push_back(element->surface().getSharedPtr());
1138     }
1139 
1140     Blueprint root{{.envelope = ExtentEnvelope{{
1141                         .z = {2_mm, 2_mm},
1142                         .r = {3_mm, 5_mm},
1143                     }}}};
1144 
1145     root.addLayer("Layer0", [&](auto& layer) {
1146       layer.setSurfaces(surfaces)
1147           .setLayerType(LayerBlueprintNode::LayerType::Disc)
1148           .setEnvelope(ExtentEnvelope{{
1149               .z = {0.1_mm, 0.1_mm},
1150               .r = {1_mm, 1_mm},
1151           }})
1152           .setTransform(base)
1153           .setUseCenterOfGravity(false, false, false);  // Disable all axes
1154     });
1155 
1156     auto trackingGeometry = root.construct({}, gctx, *logger);
1157     auto lookup = nameLookup(*trackingGeometry);
1158 
1159     BOOST_CHECK_EQUAL(lookup("Layer0").volumeBounds().type(),
1160                       VolumeBounds::BoundsType::eCylinder);
1161 
1162     auto layerCyl = dynamic_cast<const CylinderVolumeBounds&>(
1163         lookup("Layer0").volumeBounds());
1164 
1165     // With center of gravity disabled, the layer should be at the origin
1166     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMinR), 258.9999999_mm,
1167                       1e-6);
1168     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMaxR),
1169                       346.25353003_mm, 1e-6);
1170     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eHalfLengthZ), 3.85_mm,
1171                       1e-6);
1172   }
1173 
1174   // Test cylinder layer with center of gravity disabled
1175   {
1176     double yrot = 0_degree;
1177     Transform3 base =
1178         Transform3::Identity() * AngleAxis3{yrot, Vector3::UnitY()};
1179 
1180     std::vector<std::shared_ptr<Surface>> surfaces;
1181     std::vector<std::unique_ptr<SurfacePlacementBase>> elements;
1182 
1183     double r = 300_mm;
1184     std::size_t nStaves = 10;
1185     int nSensorsPerStave = 8;
1186     double thickness = 0;
1187     double hlPhi = 40_mm;
1188     double hlZ = 60_mm;
1189     auto recBounds = std::make_shared<RectangleBounds>(hlPhi, hlZ);
1190 
1191     double deltaPhi = 2 * std::numbers::pi / nStaves;
1192 
1193     for (std::size_t istave = 0; istave < nStaves; istave++) {
1194       for (int isensor = -nSensorsPerStave; isensor <= nSensorsPerStave;
1195            isensor++) {
1196         double z = isensor * (2 * hlZ + 5_mm);
1197 
1198         Transform3 trf = base * Translation3(Vector3::UnitZ() * z) *
1199                          AngleAxis3{deltaPhi * istave, Vector3::UnitZ()} *
1200                          Translation3(Vector3::UnitX() * r) *
1201                          AngleAxis3{10_degree, Vector3::UnitZ()} *
1202                          AngleAxis3{90_degree, Vector3::UnitY()} *
1203                          AngleAxis3{90_degree, Vector3::UnitZ()};
1204         auto& element = elements.emplace_back(
1205             std::make_unique<DetectorElementStub>(trf, recBounds, thickness));
1206         element->surface().assignSurfacePlacement(*element);
1207         surfaces.push_back(element->surface().getSharedPtr());
1208       }
1209     }
1210 
1211     Blueprint root{{.envelope = ExtentEnvelope{{
1212                         .z = {2_mm, 2_mm},
1213                         .r = {3_mm, 5_mm},
1214                     }}}};
1215 
1216     root.addLayer("Layer0", [&](auto& layer) {
1217       layer.setSurfaces(surfaces)
1218           .setLayerType(LayerBlueprintNode::LayerType::Cylinder)
1219           .setEnvelope(ExtentEnvelope{{
1220               .z = {10_mm, 10_mm},
1221               .r = {20_mm, 10_mm},
1222           }})
1223           .setTransform(base)
1224           .setUseCenterOfGravity(false, false, false);  // Disable all axes
1225     });
1226 
1227     auto trackingGeometry = root.construct({}, gctx, *logger);
1228     auto lookup = nameLookup(*trackingGeometry);
1229     BOOST_CHECK_EQUAL(lookup("Layer0").volumeBounds().type(),
1230                       VolumeBounds::BoundsType::eCylinder);
1231 
1232     auto layerCyl = dynamic_cast<const CylinderVolumeBounds&>(
1233         lookup("Layer0").volumeBounds());
1234 
1235     // With center of gravity disabled, the layer should be at the origin
1236     BOOST_CHECK_EQUAL(lookup("Layer0").portals().size(), 4);
1237     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMinR), 275.6897761_mm,
1238                       1e-6);
1239     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eMaxR), 319.4633358_mm,
1240                       1e-6);
1241     BOOST_CHECK_CLOSE(layerCyl.get(CylinderVolumeBounds::eHalfLengthZ), 1070_mm,
1242                       1e-6);
1243   }
1244 }
1245 
1246 BOOST_AUTO_TEST_CASE(GeometryIdnetifiersForPortals) {
1247   Blueprint::Config cfg;
1248   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
1249   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1250   Blueprint root{cfg};
1251 
1252   auto& cubcontainer =
1253       root.addCuboidContainer("CuboidContainer", AxisDirection::AxisX);
1254   auto parentBounds = std::make_shared<CuboidVolumeBounds>(1_m, 20_mm, 20_mm);
1255   auto parentVol = std::make_unique<TrackingVolume>(Transform3::Identity(),
1256                                                     parentBounds, "parent");
1257   parentVol->assignGeometryId(GeometryIdentifier{}.withVolume(1));
1258   auto parentNode = std::make_shared<StaticBlueprintNode>(std::move(parentVol));
1259   std::size_t nChambers = 50;
1260   // start from the edge of the parent volume
1261   double startX = -1000. + 3. + 0.5;
1262   Transform3 trf = Transform3(Translation3(startX, 0, 0));
1263   auto tbounds =
1264       std::make_shared<TrapezoidVolumeBounds>(3_mm, 3_mm, 10_mm, 15_mm);
1265 
1266   for (std::size_t i = 0; i < nChambers; i++) {
1267     // move the chambers position
1268     trf.translation() += Vector3::UnitX() * i * 7_mm;
1269 
1270     auto childVol = std::make_unique<TrackingVolume>(
1271         trf, tbounds, "child" + std::to_string(i));
1272     childVol->assignGeometryId(
1273         GeometryIdentifier{}.withVolume(1).withLayer(i + 1));
1274     auto childNode = std::make_shared<StaticBlueprintNode>(std::move(childVol));
1275     parentNode->addChild(std::move(childNode));
1276   }
1277 
1278   cubcontainer.addChild(std::move(parentNode));
1279 
1280   auto trackingGeometry = root.construct({}, gctx, *logger);
1281 }
1282 
1283 BOOST_AUTO_TEST_CASE(PadBlueprintNodeCylinder) {
1284   Blueprint::Config cfg;
1285   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
1286   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1287 
1288   PadBlueprintNode pad("World", cfg.envelope);
1289 
1290   auto child = std::make_unique<TrackingVolume>(
1291       Transform3::Identity(),
1292       std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 30_mm), "child");
1293   const TrackingVolume* childVol = child.get();
1294   pad.addStaticVolume(std::move(child));
1295 
1296   BlueprintOptions options;
1297   // build() presents the padded volume to the parent
1298   auto& world =
1299       dynamic_cast<TrackingVolume&>(pad.build(options, gctx, *logger));
1300 
1301   // Child bounds are unchanged, padding creates a new volume
1302   const auto& childCyl =
1303       dynamic_cast<const CylinderVolumeBounds&>(childVol->volumeBounds());
1304   BOOST_CHECK_EQUAL(childCyl.get(CylinderVolumeBounds::eMinR), 10_mm);
1305   BOOST_CHECK_EQUAL(childCyl.get(CylinderVolumeBounds::eMaxR), 20_mm);
1306   BOOST_CHECK_EQUAL(childCyl.get(CylinderVolumeBounds::eHalfLengthZ), 30_mm);
1307 
1308   BOOST_CHECK_EQUAL(world.volumeName(), "World");
1309   BOOST_CHECK_EQUAL(world.volumeBounds().type(),
1310                     VolumeBounds::BoundsType::eCylinder);
1311 
1312   const auto& worldCyl =
1313       dynamic_cast<const CylinderVolumeBounds&>(world.volumeBounds());
1314   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 10_mm - 1_mm);
1315   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 20_mm + 2_mm);
1316   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ),
1317                     30_mm + 20_mm);
1318 }
1319 
1320 BOOST_AUTO_TEST_CASE(PadBlueprintNodeLegacyPaddedOverload) {
1321   Blueprint::Config cfg;
1322   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
1323   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1324 
1325   Volume child(Transform3::Identity(),
1326                std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 30_mm));
1327 
1328   // Pin the pre-reference-axis signature: the logger in fifth position must
1329   // keep resolving, so downstream call sites do not need touching.
1330   ACTS_PUSH_IGNORE_DEPRECATED()
1331   auto world =
1332       PadBlueprintNode::padded(gctx, child, cfg.envelope, "World", *logger);
1333   ACTS_POP_IGNORE_DEPRECATED()
1334 
1335   BOOST_CHECK_EQUAL(world->volumeName(), "World");
1336   const auto& worldCyl =
1337       dynamic_cast<const CylinderVolumeBounds&>(world->volumeBounds());
1338   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 10_mm - 1_mm);
1339   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 20_mm + 2_mm);
1340   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ),
1341                     30_mm + 20_mm);
1342   BOOST_CHECK_SMALL(world->center(gctx).norm(), 1e-9);
1343 }
1344 
1345 BOOST_AUTO_TEST_CASE(PadBlueprintNodeCuboid) {
1346   Blueprint::Config cfg;
1347   cfg.envelope[AxisDirection::AxisX] = {3_mm, 3_mm};
1348   cfg.envelope[AxisDirection::AxisY] = {5_mm, 5_mm};
1349   cfg.envelope[AxisDirection::AxisZ] = {7_mm, 7_mm};
1350 
1351   PadBlueprintNode pad("World", cfg.envelope);
1352   auto child = std::make_unique<TrackingVolume>(
1353       Transform3::Identity(),
1354       std::make_shared<CuboidVolumeBounds>(10_mm, 20_mm, 30_mm), "child");
1355   const TrackingVolume* childVol = child.get();
1356   pad.addStaticVolume(std::move(child));
1357 
1358   BlueprintOptions options;
1359   // build() presents the padded volume to the parent
1360   auto& world =
1361       dynamic_cast<TrackingVolume&>(pad.build(options, gctx, *logger));
1362 
1363   // Child bounds are unchanged, padding creates a new volume
1364   const auto& childBox =
1365       dynamic_cast<const CuboidVolumeBounds&>(childVol->volumeBounds());
1366   BOOST_CHECK_EQUAL(childBox.get(CuboidVolumeBounds::eHalfLengthX), 10_mm);
1367   BOOST_CHECK_EQUAL(childBox.get(CuboidVolumeBounds::eHalfLengthY), 20_mm);
1368   BOOST_CHECK_EQUAL(childBox.get(CuboidVolumeBounds::eHalfLengthZ), 30_mm);
1369 
1370   BOOST_CHECK_EQUAL(world.volumeName(), "World");
1371   BOOST_CHECK_EQUAL(world.volumeBounds().type(),
1372                     VolumeBounds::BoundsType::eCuboid);
1373 
1374   const auto& worldBox =
1375       dynamic_cast<const CuboidVolumeBounds&>(world.volumeBounds());
1376   BOOST_CHECK_EQUAL(worldBox.get(CuboidVolumeBounds::eHalfLengthX),
1377                     10_mm + 3_mm);
1378   BOOST_CHECK_EQUAL(worldBox.get(CuboidVolumeBounds::eHalfLengthY),
1379                     20_mm + 5_mm);
1380   BOOST_CHECK_EQUAL(worldBox.get(CuboidVolumeBounds::eHalfLengthZ),
1381                     30_mm + 7_mm);
1382 
1383   // 6 faces for a cuboid
1384   BOOST_CHECK_EQUAL(world.portals().size(), 0);
1385 }
1386 
1387 BOOST_AUTO_TEST_CASE(PadBlueprintNodeRotatedCylinder) {
1388   Blueprint::Config cfg;
1389   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
1390   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1391 
1392   PadBlueprintNode pad("World", cfg.envelope);
1393 
1394   Transform3 childTrf =
1395       Transform3::Identity() * AngleAxis3{45_degree, Vector3::UnitY()};
1396 
1397   pad.addStaticVolume(std::make_unique<TrackingVolume>(
1398       childTrf, std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 30_mm),
1399       "child"));
1400 
1401   BlueprintOptions options;
1402   auto& world =
1403       dynamic_cast<TrackingVolume&>(pad.build(options, gctx, *logger));
1404 
1405   BOOST_CHECK_EQUAL(world.volumeName(), "World");
1406   BOOST_CHECK_EQUAL(world.volumeBounds().type(),
1407                     VolumeBounds::BoundsType::eCylinder);
1408 
1409   // Bounds are padded in local frame regardless of orientation
1410   const auto& worldCyl =
1411       dynamic_cast<const CylinderVolumeBounds&>(world.volumeBounds());
1412   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 10_mm - 1_mm);
1413   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 20_mm + 2_mm);
1414   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ),
1415                     30_mm + 20_mm);
1416 
1417   // Transform is inherited from the child
1418   BOOST_CHECK(world.localToGlobalTransform(gctx).isApprox(childTrf));
1419 }
1420 
1421 BOOST_AUTO_TEST_CASE(PadBlueprintNodeNestedInContainer) {
1422   Blueprint::Config cfg;
1423   cfg.envelope[AxisDirection::AxisZ] = {5_mm, 5_mm};
1424   cfg.envelope[AxisDirection::AxisR] = {5_mm, 5_mm};
1425   Blueprint root{cfg};
1426 
1427   auto& container =
1428       root.addCylinderContainer("Container", AxisDirection::AxisZ);
1429 
1430   ExtentEnvelope padEnvelope = ExtentEnvelope::Zero();
1431   padEnvelope[AxisDirection::AxisZ] = {10_mm, 10_mm};
1432   padEnvelope[AxisDirection::AxisR] = {1_mm, 10_mm};
1433   auto pad = std::make_shared<PadBlueprintNode>("Pad", padEnvelope);
1434 
1435   pad->addStaticVolume(std::make_unique<TrackingVolume>(
1436       Transform3::Identity(),
1437       std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 30_mm), "child"));
1438 
1439   container.addChild(pad);
1440 
1441   auto trackingGeometry = root.construct({}, gctx, *logger);
1442   auto lookup = nameLookup(*trackingGeometry);
1443 
1444   // The pad's own volume must reflect its own envelope applied on top of
1445   // the raw child bounds.
1446   const auto& padCyl =
1447       dynamic_cast<const CylinderVolumeBounds&>(lookup("Pad").volumeBounds());
1448   BOOST_CHECK_EQUAL(padCyl.get(CylinderVolumeBounds::eMinR), 10_mm - 1_mm);
1449   BOOST_CHECK_EQUAL(padCyl.get(CylinderVolumeBounds::eMaxR), 20_mm + 10_mm);
1450   BOOST_CHECK_EQUAL(padCyl.get(CylinderVolumeBounds::eHalfLengthZ),
1451                     30_mm + 10_mm);
1452 
1453   // World wraps Container, which wraps Pad. World's bounds must be
1454   // strictly larger than the pad's own (already padded) bounds by exactly
1455   // the root envelope.
1456   const auto& worldCyl =
1457       dynamic_cast<const CylinderVolumeBounds&>(lookup("World").volumeBounds());
1458   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR),
1459                     padCyl.get(CylinderVolumeBounds::eMinR) - 5_mm);
1460   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR),
1461                     padCyl.get(CylinderVolumeBounds::eMaxR) + 5_mm);
1462   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ),
1463                     padCyl.get(CylinderVolumeBounds::eHalfLengthZ) + 5_mm);
1464 }
1465 
1466 BOOST_AUTO_TEST_CASE(PadBlueprintNodeReferenceAxisBounds) {
1467   Blueprint::Config cfg;
1468   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
1469   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1470 
1471   // Reference axis is the global z-axis; the child sits 50mm off it in x.
1472   PadBlueprintNode pad("World", cfg.envelope);
1473   pad.setReferenceAxis(Transform3::Identity());
1474 
1475   const double offsetX = 50_mm;
1476   auto child = std::make_unique<TrackingVolume>(
1477       Transform3::Identity() * Translation3{Vector3{offsetX, 0, 0}},
1478       std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 30_mm), "child");
1479   const TrackingVolume* childVol = child.get();
1480   pad.addStaticVolume(std::move(child));
1481 
1482   BlueprintOptions options;
1483   auto& world =
1484       dynamic_cast<TrackingVolume&>(pad.build(options, gctx, *logger));
1485 
1486   // The child keeps its off-axis placement.
1487   BOOST_CHECK_CLOSE(childVol->center(gctx)[eX], offsetX, 1e-9);
1488 
1489   BOOST_CHECK_EQUAL(world.volumeName(), "World");
1490   BOOST_CHECK_EQUAL(world.volumeBounds().type(),
1491                     VolumeBounds::BoundsType::eCylinder);
1492 
1493   // The envelope is recentered onto the reference axis ...
1494   BOOST_CHECK_SMALL(world.center(gctx)[eX], 1e-9);
1495   BOOST_CHECK_SMALL(world.center(gctx)[eY], 1e-9);
1496 
1497   // ... and grown radially so the displaced child stays enclosed:
1498   //   minR = max(0, max(minR - offset, offset - maxR) - rInner)
1499   //        = max(0, max(10 - 50, 50 - 20) - 1) = 29
1500   //   maxR = maxR + offset + rOuter = 20 + 50 + 2 = 72
1501   const auto& worldCyl =
1502       dynamic_cast<const CylinderVolumeBounds&>(world.volumeBounds());
1503   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 29_mm);
1504   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 72_mm);
1505   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ),
1506                     30_mm + 20_mm);
1507 }
1508 
1509 BOOST_AUTO_TEST_CASE(PadBlueprintNodeReferenceAxisRejectsCuboid) {
1510   // The rejection path logs at ERROR before throwing; allow that under the
1511   // compile-time log failure threshold used in CI.
1512   Logging::ScopedFailureThreshold threshold{Logging::Level::FATAL};
1513 
1514   Blueprint::Config cfg;
1515   cfg.envelope[AxisDirection::AxisX] = {1_mm, 1_mm};
1516   cfg.envelope[AxisDirection::AxisY] = {1_mm, 1_mm};
1517   cfg.envelope[AxisDirection::AxisZ] = {1_mm, 1_mm};
1518 
1519   PadBlueprintNode pad("World", cfg.envelope);
1520   pad.setReferenceAxis(Transform3::Identity());
1521   pad.addStaticVolume(std::make_unique<TrackingVolume>(
1522       Transform3::Identity(),
1523       std::make_shared<CuboidVolumeBounds>(10_mm, 20_mm, 30_mm), "child"));
1524 
1525   BlueprintOptions options;
1526   BOOST_CHECK_THROW(pad.build(options, gctx, *logger), std::logic_error);
1527 }
1528 
1529 BOOST_AUTO_TEST_CASE(PadBlueprintNodeReferenceAxisInCylinderHierarchy) {
1530   Blueprint::Config cfg;
1531   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
1532   cfg.envelope[AxisDirection::AxisR] = {2_mm, 20_mm};
1533   auto root = std::make_unique<Blueprint>(cfg);
1534 
1535   std::vector<std::unique_ptr<SurfacePlacementBase>> elements;
1536   std::vector<std::shared_ptr<Surface>> b0SensitiveSurfaces;
1537 
1538   // A displaced (off-beamline) cylindrical layer with a ring of sensitive
1539   // modules, modelling e.g. an EIC/ePIC B0 tracker station.
1540   auto makeB0Layer = [&](std::size_t layer) -> std::unique_ptr<TrackingVolume> {
1541     const double offsetX = -160_mm;
1542     const double z = 6250_mm + layer * 100_mm;
1543     auto layerVolume = std::make_unique<TrackingVolume>(
1544         Transform3::Identity() * Translation3{Vector3{offsetX, 0., z}},
1545         std::make_shared<CylinderVolumeBounds>(35_mm, 150_mm, 20_mm),
1546         "B0Layer" + std::to_string(layer));
1547 
1548     const std::size_t nModules = 6;
1549     const double moduleR = 90_mm;
1550     const auto moduleBounds = std::make_shared<RectangleBounds>(8_mm, 15_mm);
1551     for (std::size_t i = 0; i < nModules; ++i) {
1552       const double phi = 2. * std::numbers::pi * static_cast<double>(i) /
1553                          static_cast<double>(nModules);
1554       Transform3 moduleTransform = Transform3::Identity() *
1555                                    Translation3{Vector3{offsetX, 0., z}} *
1556                                    AngleAxis3{phi, Vector3::UnitZ()} *
1557                                    Translation3{Vector3::UnitX() * moduleR} *
1558                                    AngleAxis3{90_degree, Vector3::UnitY()} *
1559                                    AngleAxis3{90_degree, Vector3::UnitZ()};
1560 
1561       auto& element =
1562           elements.emplace_back(std::make_unique<DetectorElementStub>(
1563               moduleTransform, moduleBounds, 0.));
1564       element->surface().assignSurfacePlacement(*element);
1565       auto surface = element->surface().getSharedPtr();
1566       layerVolume->addSurface(surface);
1567       b0SensitiveSurfaces.push_back(std::move(surface));
1568     }
1569 
1570     return layerVolume;
1571   };
1572 
1573   root->addCylinderContainer("Detector", AxisDirection::AxisZ, [&](auto& det) {
1574     det.addStaticVolume(
1575         Transform3::Identity(),
1576         std::make_shared<CylinderVolumeBounds>(20_mm, 400_mm, 1000_mm),
1577         "MainTracker");
1578 
1579     // The pad node wraps the displaced B0 subtree into an axis-aligned envelope
1580     // so the co-axial Detector stack accepts it.
1581     ExtentEnvelope padEnvelope = ExtentEnvelope::Zero();
1582     padEnvelope[AxisDirection::AxisZ] = {2_mm, 2_mm};
1583     padEnvelope[AxisDirection::AxisR] = {2_mm, 2_mm};
1584     auto pad = std::make_shared<PadBlueprintNode>("B0Envelope", padEnvelope);
1585     pad->setReferenceAxis(Transform3::Identity());
1586     pad->addCylinderContainer("B0", AxisDirection::AxisZ, [&](auto& b0) {
1587       b0.addStaticVolume(makeB0Layer(0));
1588       b0.addStaticVolume(makeB0Layer(1));
1589     });
1590     det.addChild(pad);
1591   });
1592 
1593   auto trackingGeometry = root->construct({}, gctx, *logger);
1594   BOOST_REQUIRE(trackingGeometry != nullptr);
1595   BOOST_CHECK(trackingGeometry->geometryVersion() ==
1596               TrackingGeometry::GeometryVersion::Gen3);
1597 
1598   // The wrapper envelope is on-axis ...
1599   const auto* envelope = trackingGeometry->findVolumeByName("B0Envelope");
1600   BOOST_REQUIRE(envelope != nullptr);
1601   BOOST_CHECK_SMALL(envelope->center(gctx)[eX], 1e-9);
1602   BOOST_CHECK_SMALL(envelope->center(gctx)[eY], 1e-9);
1603 
1604   // ... while the child layers keep their displaced placement.
1605   const auto* b0Layer0 = trackingGeometry->findVolumeByName("B0Layer0");
1606   BOOST_REQUIRE(b0Layer0 != nullptr);
1607   BOOST_CHECK_CLOSE(b0Layer0->center(gctx)[eX], -160_mm, 1e-8);
1608 
1609   // Every sensitive surface inside the off-axis subtree remains reachable
1610   // through the constructed geometry.
1611   std::size_t found = 0;
1612   for (const auto& surface : b0SensitiveSurfaces) {
1613     const auto id = surface->geometryId();
1614     BOOST_CHECK_NE(id.sensitive(), 0u);
1615     BOOST_REQUIRE(trackingGeometry->findSurface(id) != nullptr);
1616     found++;
1617   }
1618   BOOST_CHECK_EQUAL(found, b0SensitiveSurfaces.size());
1619 }
1620 
1621 BOOST_AUTO_TEST_CASE(PadBlueprintNodeCenteredRejectsAsymmetricZ) {
1622   // The default Centered mode cannot represent an asymmetric envelope in a
1623   // symmetric direction, so it must throw rather than silently shifting.
1624   // The rejection path logs at ERROR before throwing.
1625   Logging::ScopedFailureThreshold threshold{Logging::Level::FATAL};
1626 
1627   Blueprint::Config cfg;
1628   cfg.envelope[AxisDirection::AxisZ] = {10_mm, 40_mm};
1629   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1630 
1631   PadBlueprintNode pad("World", cfg.envelope);
1632   pad.addStaticVolume(std::make_unique<TrackingVolume>(
1633       Transform3::Identity(),
1634       std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 30_mm), "child"));
1635 
1636   BlueprintOptions options;
1637   BOOST_CHECK_THROW(pad.build(options, gctx, *logger), std::logic_error);
1638 }
1639 
1640 BOOST_AUTO_TEST_CASE(PadBlueprintNodeFitBoundsCylinder) {
1641   // FitBounds allows an asymmetric z envelope; the enclosure shifts to the
1642   // midpoint of the expanded extent instead of throwing.
1643   Blueprint::Config cfg;
1644   cfg.envelope[AxisDirection::AxisZ] = {10_mm, 40_mm};
1645   cfg.envelope[AxisDirection::AxisR] = {1_mm, 2_mm};
1646 
1647   PadBlueprintNode pad("World", cfg.envelope);
1648   pad.setCentering(PadBlueprintNode::Centering::FitBounds);
1649   pad.addStaticVolume(std::make_unique<TrackingVolume>(
1650       Transform3::Identity(),
1651       std::make_shared<CylinderVolumeBounds>(10_mm, 20_mm, 30_mm), "child"));
1652 
1653   BlueprintOptions options;
1654   auto& world =
1655       dynamic_cast<TrackingVolume&>(pad.build(options, gctx, *logger));
1656 
1657   const auto& worldCyl =
1658       dynamic_cast<const CylinderVolumeBounds&>(world.volumeBounds());
1659   // halfZ = 30 + (10 + 40) / 2 = 55; center shifts by (40 - 10) / 2 = 15
1660   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eHalfLengthZ), 55_mm);
1661   BOOST_CHECK_CLOSE(world.center(gctx)[eZ], 15_mm, 1e-9);
1662   // r is asymmetric even when Centered, so it is unchanged here
1663   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMinR), 9_mm);
1664   BOOST_CHECK_EQUAL(worldCyl.get(CylinderVolumeBounds::eMaxR), 22_mm);
1665 }
1666 
1667 BOOST_AUTO_TEST_CASE(PadBlueprintNodeFitBoundsCuboid) {
1668   // FitBounds shifts a cuboid per axis by half the envelope asymmetry.
1669   Blueprint::Config cfg;
1670   cfg.envelope[AxisDirection::AxisX] = {2_mm, 6_mm};
1671   cfg.envelope[AxisDirection::AxisY] = {3_mm, 3_mm};
1672   cfg.envelope[AxisDirection::AxisZ] = {0_mm, 0_mm};
1673 
1674   PadBlueprintNode pad("World", cfg.envelope);
1675   pad.setCentering(PadBlueprintNode::Centering::FitBounds);
1676   pad.addStaticVolume(std::make_unique<TrackingVolume>(
1677       Transform3::Identity(),
1678       std::make_shared<CuboidVolumeBounds>(10_mm, 20_mm, 30_mm), "child"));
1679 
1680   BlueprintOptions options;
1681   auto& world =
1682       dynamic_cast<TrackingVolume&>(pad.build(options, gctx, *logger));
1683 
1684   const auto& worldBox =
1685       dynamic_cast<const CuboidVolumeBounds&>(world.volumeBounds());
1686   BOOST_CHECK_EQUAL(worldBox.get(CuboidVolumeBounds::eHalfLengthX), 14_mm);
1687   BOOST_CHECK_EQUAL(worldBox.get(CuboidVolumeBounds::eHalfLengthY), 23_mm);
1688   BOOST_CHECK_EQUAL(worldBox.get(CuboidVolumeBounds::eHalfLengthZ), 30_mm);
1689   BOOST_CHECK_CLOSE(world.center(gctx)[eX], 2_mm, 1e-9);
1690   BOOST_CHECK_SMALL(world.center(gctx)[eY], 1e-9);
1691   BOOST_CHECK_SMALL(world.center(gctx)[eZ], 1e-9);
1692 }
1693 
1694 BOOST_AUTO_TEST_SUITE_END();
1695 
1696 }  // namespace ActsTests