Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-07-14 07:51:53

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/Algebra.hpp"
0015 #include "Acts/Definitions/Units.hpp"
0016 #include "Acts/Geometry/Blueprint.hpp"
0017 #include "Acts/Geometry/ContainerBlueprintNode.hpp"
0018 #include "Acts/Geometry/CylinderVolumeBounds.hpp"
0019 #include "Acts/Geometry/GeometryContext.hpp"
0020 #include "Acts/Geometry/LayerBlueprintNode.hpp"
0021 #include "Acts/Geometry/MaterialDesignatorBlueprintNode.hpp"
0022 #include "Acts/Geometry/Portal.hpp"
0023 #include "Acts/Geometry/PortalDesignatorBlueprintNode.hpp"
0024 #include "Acts/Geometry/TrackingGeometry.hpp"
0025 #include "Acts/Geometry/TrackingVolume.hpp"
0026 #include "Acts/Geometry/VolumeAttachmentStrategy.hpp"
0027 #include "Acts/Geometry/VolumeResizeStrategy.hpp"
0028 #include "Acts/Material/MergedMaterialMarker.hpp"
0029 #include "Acts/Navigation/INavigationPolicy.hpp"
0030 #include "Acts/Navigation/NavigationStream.hpp"
0031 #include "Acts/Surfaces/RectangleBounds.hpp"
0032 #include "Acts/Surfaces/Surface.hpp"
0033 #include "Acts/Utilities/Logger.hpp"
0034 #include "Acts/Utilities/ProtoAxis.hpp"
0035 #include "Acts/Visualization/GeometryView3D.hpp"
0036 #include "Acts/Visualization/ObjVisualization3D.hpp"
0037 #include "ActsTests/CommonHelpers/DetectorElementStub.hpp"
0038 
0039 #include <fstream>
0040 #include <random>
0041 #include <vector>
0042 
0043 using namespace Acts;
0044 using namespace UnitLiterals;
0045 
0046 using Experimental::Blueprint;
0047 using Experimental::LayerBlueprintNode;
0048 using Experimental::MaterialDesignatorBlueprintNode;
0049 
0050 namespace ActsTests {
0051 
0052 auto logger = getDefaultLogger("UnitTests", Logging::DEBUG);
0053 
0054 auto gctx = GeometryContext::dangerouslyDefaultConstruct();
0055 
0056 inline std::vector<std::shared_ptr<Surface>> makeFanLayer(
0057     const Transform3& base,
0058     std::vector<std::unique_ptr<SurfacePlacementBase>>& elements,
0059     double r = 300_mm, std::size_t nSensors = 8, double thickness = 0) {
0060   auto recBounds = std::make_shared<RectangleBounds>(40_mm, 60_mm);
0061 
0062   double deltaPhi = 2 * std::numbers::pi / nSensors;
0063   std::vector<std::shared_ptr<Surface>> surfaces;
0064   for (std::size_t i = 0; i < nSensors; i++) {
0065     // Create a fan of sensors
0066 
0067     Transform3 trf = base * AngleAxis3{deltaPhi * i, Vector3::UnitZ()} *
0068                      Translation3(Vector3::UnitX() * r);
0069 
0070     if (i % 2 == 0) {
0071       trf = trf * Translation3{Vector3::UnitZ() * 5_mm};
0072     }
0073 
0074     auto& element = elements.emplace_back(
0075         std::make_unique<DetectorElementStub>(trf, recBounds, thickness));
0076 
0077     element->surface().assignSurfacePlacement(*element);
0078 
0079     surfaces.push_back(element->surface().getSharedPtr());
0080   }
0081   return surfaces;
0082 }
0083 
0084 inline std::vector<std::shared_ptr<Surface>> makeBarrelLayer(
0085     const Transform3& base,
0086     std::vector<std::unique_ptr<SurfacePlacementBase>>& elements,
0087     double r = 300_mm, std::size_t nStaves = 10, int nSensorsPerStave = 8,
0088     double thickness = 0, double hlPhi = 40_mm, double hlZ = 60_mm) {
0089   auto recBounds = std::make_shared<RectangleBounds>(hlPhi, hlZ);
0090 
0091   double deltaPhi = 2 * std::numbers::pi / nStaves;
0092   std::vector<std::shared_ptr<Surface>> surfaces;
0093 
0094   for (std::size_t istave = 0; istave < nStaves; istave++) {
0095     for (int isensor = -nSensorsPerStave; isensor <= nSensorsPerStave;
0096          isensor++) {
0097       double z = isensor * (2 * hlZ + 5_mm);
0098 
0099       Transform3 trf = base * Translation3(Vector3::UnitZ() * z) *
0100                        AngleAxis3{deltaPhi * istave, Vector3::UnitZ()} *
0101                        Translation3(Vector3::UnitX() * r) *
0102                        AngleAxis3{10_degree, Vector3::UnitZ()} *
0103                        AngleAxis3{90_degree, Vector3::UnitY()} *
0104                        AngleAxis3{90_degree, Vector3::UnitZ()};
0105       auto& element = elements.emplace_back(
0106           std::make_unique<DetectorElementStub>(trf, recBounds, thickness));
0107       element->surface().assignSurfacePlacement(*element);
0108       surfaces.push_back(element->surface().getSharedPtr());
0109     }
0110   }
0111 
0112   return surfaces;
0113 }
0114 
0115 }  // namespace ActsTests
0116 
0117 using namespace ActsTests;
0118 
0119 BOOST_AUTO_TEST_SUITE(GeometrySuite);
0120 
0121 void pseudoNavigation(const TrackingGeometry& trackingGeometry,
0122                       Vector3 position, const Vector3& direction,
0123                       std::ostream& csv, std::size_t run,
0124                       std::size_t substepsPerCm, const Logger& logger) {
0125   ACTS_VERBOSE("start navigation " << run);
0126   ACTS_VERBOSE("dir: " << direction.transpose());
0127   ACTS_VERBOSE(direction.norm());
0128 
0129   std::mt19937 rng{static_cast<unsigned int>(run)};
0130   std::uniform_real_distribution<> dist{0.01, 0.99};
0131 
0132   const auto* volume = trackingGeometry.lowestTrackingVolume(gctx, position);
0133   BOOST_REQUIRE_NE(volume, nullptr);
0134   ACTS_VERBOSE(volume->volumeName());
0135 
0136   NavigationStream main;
0137   const TrackingVolume* currentVolume = volume;
0138 
0139   csv << run << "," << position[0] << "," << position[1] << "," << position[2];
0140   csv << "," << volume->geometryId().volume();
0141   csv << "," << volume->geometryId().boundary();
0142   csv << "," << volume->geometryId().sensitive();
0143   csv << std::endl;
0144 
0145   ACTS_VERBOSE("start pseudo navigation");
0146 
0147   for (std::size_t i = 0; i < 100; i++) {
0148     main = NavigationStream{};
0149     AppendOnlyNavigationStream stream{main};
0150 
0151     NavigationArguments navArgs{.position = position, .direction = direction};
0152     NavigationPolicyStateManager stateManager;
0153     currentVolume->navigationPolicy()->createState(gctx, navArgs, stateManager,
0154                                                    logger);
0155     auto policyState = stateManager.currentState();
0156     currentVolume->initializeNavigationCandidates(gctx, navArgs, policyState,
0157                                                   stream, logger);
0158 
0159     ACTS_VERBOSE(main.candidates().size() << " candidates");
0160 
0161     for (const auto& candidate : main.candidates()) {
0162       ACTS_VERBOSE(" -> " << candidate.surface().geometryId());
0163       ACTS_VERBOSE("    " << candidate.surface().toStream(gctx));
0164     }
0165 
0166     ACTS_VERBOSE("initializing candidates");
0167     main.initialize(gctx, {position, direction}, BoundaryTolerance::None());
0168 
0169     ACTS_VERBOSE(main.candidates().size() << " candidates remaining");
0170 
0171     for (const auto& candidate : main.candidates()) {
0172       ACTS_VERBOSE(" -> " << candidate.surface().geometryId());
0173       ACTS_VERBOSE("    " << candidate.surface().toStream(gctx));
0174     }
0175 
0176     if (main.currentCandidate().surface().isOnSurface(gctx, position,
0177                                                       direction)) {
0178       ACTS_VERBOSE("Already on surface at initialization, skipping candidate");
0179 
0180       auto id = main.currentCandidate().surface().geometryId();
0181       csv << run << "," << position[0] << "," << position[1] << ","
0182           << position[2];
0183       csv << "," << id.volume();
0184       csv << "," << id.boundary();
0185       csv << "," << id.sensitive();
0186       csv << std::endl;
0187       if (!main.switchToNextCandidate()) {
0188         ACTS_WARNING("candidates exhausted unexpectedly");
0189         break;
0190       }
0191     }
0192 
0193     auto writeIntersection = [&](const Vector3& pos, const Surface& surface) {
0194       csv << run << "," << pos[0] << "," << pos[1] << "," << pos[2];
0195       csv << "," << surface.geometryId().volume();
0196       csv << "," << surface.geometryId().boundary();
0197       csv << "," << surface.geometryId().sensitive();
0198       csv << std::endl;
0199     };
0200 
0201     bool terminated = false;
0202     while (main.remainingCandidates() > 0) {
0203       const auto& candidate = main.currentCandidate();
0204 
0205       ACTS_VERBOSE(candidate.position().transpose());
0206 
0207       ACTS_VERBOSE("moving to position: " << position.transpose() << " (r="
0208                                           << VectorHelpers::perp(position)
0209                                           << ")");
0210 
0211       Vector3 delta = candidate.position() - position;
0212 
0213       std::size_t substeps =
0214           std::max(1l, std::lround(delta.norm() / 10_cm * substepsPerCm));
0215 
0216       for (std::size_t j = 0; j < substeps; j++) {
0217         // position += delta / (substeps + 1);
0218         Vector3 subpos = position + dist(rng) * delta;
0219         csv << run << "," << subpos[0] << "," << subpos[1] << "," << subpos[2];
0220         csv << "," << currentVolume->geometryId().volume();
0221         csv << ",0,0";  // zero boundary and sensitive ids
0222         csv << std::endl;
0223       }
0224 
0225       position = candidate.position();
0226       ACTS_VERBOSE("                 -> "
0227                    << position.transpose()
0228                    << " (r=" << VectorHelpers::perp(position) << ")");
0229 
0230       writeIntersection(position, candidate.surface());
0231 
0232       if (candidate.isPortalTarget()) {
0233         ACTS_VERBOSE("On portal: " << candidate.surface().toStream(gctx));
0234         currentVolume =
0235             candidate.portal().resolveVolume(gctx, position, direction).value();
0236 
0237         if (currentVolume == nullptr) {
0238           ACTS_VERBOSE("switched to nullptr -> we're done");
0239           terminated = true;
0240         }
0241         break;
0242 
0243       } else {
0244         ACTS_VERBOSE("Not on portal");
0245       }
0246 
0247       main.switchToNextCandidate();
0248     }
0249 
0250     if (terminated) {
0251       ACTS_VERBOSE("Terminate pseudo navigation");
0252       break;
0253     }
0254 
0255     ACTS_VERBOSE("switched to " << currentVolume->volumeName());
0256 
0257     ACTS_VERBOSE("-----");
0258   }
0259 }
0260 
0261 BOOST_AUTO_TEST_CASE(NodeApiTestContainers) {
0262   // Transform3 base{AngleAxis3{30_degree, Vector3{1, 0, 0}}};
0263   Transform3 base{Transform3::Identity()};
0264 
0265   std::vector<std::unique_ptr<SurfacePlacementBase>> detectorElements;
0266   auto makeFan = [&](const Transform3& layerBase, auto&&..., double r,
0267                      std::size_t nSensors, double thickness) {
0268     return makeFanLayer(layerBase, detectorElements, r, nSensors, thickness);
0269   };
0270 
0271   Blueprint::Config cfg;
0272   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0273   cfg.envelope[AxisDirection::AxisR] = {0_mm, 20_mm};
0274   auto root = std::make_unique<Blueprint>(cfg);
0275 
0276   root->addMaterial("GlobalMaterial", [&](MaterialDesignatorBlueprintNode&
0277                                               mat) {
0278     using enum AxisDirection;
0279     using enum AxisBoundaryType;
0280     using enum CylinderVolumeBounds::Face;
0281 
0282     // Configure cylinder faces with proper binning
0283     mat.configureFace(OuterCylinder, {AxisRPhi, Bound, 20}, {AxisZ, Bound, 20});
0284     mat.configureFace(NegativeDisc, {AxisR, Bound, 15}, {AxisPhi, Bound, 25});
0285     mat.configureFace(PositiveDisc, {AxisR, Bound, 15}, {AxisPhi, Bound, 25});
0286 
0287     mat.addCylinderContainer("Detector", AxisDirection::AxisR, [&](auto& det) {
0288       det.addCylinderContainer("Pixel", AxisDirection::AxisZ, [&](auto& cyl) {
0289         cyl.setAttachmentStrategy(VolumeAttachmentStrategy::Gap)
0290             .setResizeStrategy(VolumeResizeStrategy::Gap);
0291 
0292         cyl.addCylinderContainer(
0293             "PixelNegativeEndcap", AxisDirection::AxisZ, [&](auto& ec) {
0294               ec.setAttachmentStrategy(VolumeAttachmentStrategy::Gap);
0295 
0296               auto makeLayer = [&](const Transform3& trf, auto& layer) {
0297                 std::vector<std::shared_ptr<Surface>> surfaces;
0298                 auto layerSurfaces = makeFan(trf, 300_mm, 10, 2_mm);
0299                 std::copy(layerSurfaces.begin(), layerSurfaces.end(),
0300                           std::back_inserter(surfaces));
0301                 layerSurfaces = makeFan(trf, 500_mm, 16, 2_mm);
0302                 std::copy(layerSurfaces.begin(), layerSurfaces.end(),
0303                           std::back_inserter(surfaces));
0304 
0305                 layer.setSurfaces(surfaces)
0306                     .setLayerType(LayerBlueprintNode::LayerType::Disc)
0307                     .setEnvelope(ExtentEnvelope{{
0308                         .z = {5_mm, 5_mm},
0309                         .r = {10_mm, 20_mm},
0310                     }})
0311                     .setTransform(base);
0312               };
0313 
0314               ec.addLayer("PixelNeg1", [&](auto& layer) {
0315                 makeLayer(base * Translation3{Vector3{0, 0, -700_mm}}, layer);
0316               });
0317 
0318               ec.addLayer("PixelNeg2", [&](auto& layer) {
0319                 makeLayer(base * Translation3{Vector3{0, 0, -500_mm}}, layer);
0320               });
0321             });
0322 
0323         cyl.addCylinderContainer(
0324             "PixelBarrel", AxisDirection::AxisR, [&](auto& brl) {
0325               brl.setAttachmentStrategy(VolumeAttachmentStrategy::Gap)
0326                   .setResizeStrategy(VolumeResizeStrategy::Gap);
0327 
0328               auto makeLayer = [&](const std::string& name, double r,
0329                                    std::size_t nStaves, int nSensorsPerStave) {
0330                 brl.addLayer(name, [&](auto& layer) {
0331                   std::vector<std::shared_ptr<Surface>> surfaces =
0332                       makeBarrelLayer(base, detectorElements, r, nStaves,
0333                                       nSensorsPerStave, 2.5_mm, 10_mm, 20_mm);
0334 
0335                   layer.setSurfaces(surfaces)
0336                       .setLayerType(LayerBlueprintNode::LayerType::Cylinder)
0337                       .setEnvelope(ExtentEnvelope{{
0338                           .z = {5_mm, 5_mm},
0339                           .r = {1_mm, 1_mm},
0340                       }})
0341                       .setTransform(base);
0342                 });
0343               };
0344 
0345               makeLayer("PixelLayer0", 30_mm, 18, 5);
0346               makeLayer("PixelLayer1", 90_mm, 30, 6);
0347 
0348               brl.addStaticVolume(base,
0349                                   std::make_shared<CylinderVolumeBounds>(
0350                                       100_mm, 110_mm, 250_mm),
0351                                   "PixelSupport");
0352 
0353               makeLayer("PixelLayer2", 150_mm, 40, 7);
0354               makeLayer("PixelLayer3", 250_mm, 70, 8);
0355             });
0356 
0357         auto& ec =
0358             cyl.addCylinderContainer("PixelPosWrapper", AxisDirection::AxisR);
0359         ec.setResizeStrategy(VolumeResizeStrategy::Gap);
0360         ec.addStaticVolume(std::make_unique<TrackingVolume>(
0361             base * Translation3{Vector3{0, 0, 600_mm}},
0362             std::make_shared<CylinderVolumeBounds>(150_mm, 390_mm, 200_mm),
0363             "PixelPositiveEndcap"));
0364       });
0365 
0366       det.addStaticVolume(
0367           base, std::make_shared<CylinderVolumeBounds>(0_mm, 23_mm, 1000_mm),
0368           "BeamPipe");
0369     });
0370   });
0371 
0372   std::ofstream dot{"api_test_container.dot"};
0373   root->graphviz(dot);
0374 
0375   auto trackingGeometry = root->construct({}, gctx, *logger);
0376 
0377   BOOST_REQUIRE(trackingGeometry);
0378   BOOST_CHECK(trackingGeometry->geometryVersion() ==
0379               TrackingGeometry::GeometryVersion::Gen3);
0380 
0381   trackingGeometry->visitVolumes([&](const TrackingVolume* volume) {
0382     std::cout << volume->volumeName() << std::endl;
0383     std::cout << " -> id: " << volume->geometryId() << std::endl;
0384     std::cout << " -> " << volume->portals().size() << " portals" << std::endl;
0385   });
0386 
0387   ObjVisualization3D vis;
0388 
0389   trackingGeometry->visualize(vis, gctx, {}, {});
0390 
0391   vis.write("api_test_container.obj");
0392 
0393   Vector3 position = Vector3::Zero();
0394   std::ofstream csv{"api_test_container.csv"};
0395   csv << "x,y,z,volume,boundary,sensitive" << std::endl;
0396 
0397   std::mt19937 rnd{42};
0398 
0399   std::uniform_real_distribution<> dist{-1, 1};
0400 
0401   double etaWidth = 3;
0402   double thetaMin = 2 * std::atan(std::exp(-etaWidth));
0403   double thetaMax = 2 * std::atan(std::exp(etaWidth));
0404   std::uniform_real_distribution<> thetaDist{thetaMin, thetaMax};
0405 
0406   using namespace UnitLiterals;
0407 
0408   for (std::size_t i = 0; i < 5000; i++) {
0409     double theta = thetaDist(rnd);
0410     double phi = 2 * std::numbers::pi * dist(rnd);
0411 
0412     Vector3 direction;
0413     direction[0] = std::sin(theta) * std::cos(phi);
0414     direction[1] = std::sin(theta) * std::sin(phi);
0415     direction[2] = std::cos(theta);
0416 
0417     pseudoNavigation(*trackingGeometry, position, direction, csv, i, 2,
0418                      *logger->clone(std::nullopt, Logging::DEBUG));
0419   }
0420 }
0421 
0422 BOOST_AUTO_TEST_CASE(NodeApiTestCuboid) {
0423   Transform3 base{Transform3::Identity()};
0424 
0425   Blueprint::Config cfg;
0426   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0427   cfg.envelope[AxisDirection::AxisR] = {0_mm, 20_mm};
0428   auto root = std::make_unique<Blueprint>(cfg);
0429 
0430   root->addMaterial("GlobalMaterial", [&](MaterialDesignatorBlueprintNode&
0431                                               mat) {
0432     using enum AxisDirection;
0433     using enum AxisBoundaryType;
0434     using enum CuboidVolumeBounds::Face;
0435 
0436     // Configure valid axis combinations for each face type
0437     mat.configureFace(NegativeXFace, {AxisX, Bound, 20}, {AxisY, Bound, 20});
0438     mat.configureFace(PositiveXFace, {AxisX, Bound, 20}, {AxisY, Bound, 20});
0439     mat.configureFace(NegativeYFace, {AxisX, Bound, 15}, {AxisY, Bound, 25});
0440     mat.configureFace(PositiveYFace, {AxisX, Bound, 15}, {AxisY, Bound, 25});
0441     mat.configureFace(NegativeZFace, {AxisX, Bound, 15}, {AxisY, Bound, 25});
0442     mat.configureFace(PositiveZFace, {AxisX, Bound, 15}, {AxisY, Bound, 25});
0443 
0444     mat.addStaticVolume(
0445         base, std::make_shared<CuboidVolumeBounds>(100_mm, 100_mm, 100_mm),
0446         "TestVolume");
0447   });
0448 
0449   auto trackingGeometry = root->construct({}, gctx, *logger);
0450   BOOST_REQUIRE(trackingGeometry);
0451   BOOST_CHECK(trackingGeometry->geometryVersion() ==
0452               TrackingGeometry::GeometryVersion::Gen3);
0453 }
0454 
0455 // Reproduces the "material on a merged portal" failure: material is designated
0456 // on a portal face that is subsequently merged during container stacking. The
0457 // material designator wraps a child of a z-stacking container; since stacking
0458 // in z merges the OuterCylinder portals of all children, the designated face
0459 // cannot survive the merge.
0460 //
0461 // This is detected early by the container node, before the stack shell is
0462 // built, producing a node-scoped error. The deeper shell-level reporting is
0463 // exercised directly in the CylinderPortalShell tests.
0464 BOOST_AUTO_TEST_CASE(MaterialOnMergedPortalThrows) {
0465   Transform3 base{Transform3::Identity()};
0466 
0467   Blueprint::Config cfg;
0468   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0469   cfg.envelope[AxisDirection::AxisR] = {0_mm, 20_mm};
0470   auto root = std::make_unique<Blueprint>(cfg);
0471 
0472   root->addCylinderContainer("Stack", AxisDirection::AxisZ, [&](auto& stack) {
0473     using enum AxisDirection;
0474     using enum AxisBoundaryType;
0475     using enum CylinderVolumeBounds::Face;
0476 
0477     // First child: a static volume whose OuterCylinder face is given material.
0478     // This is the face that the parent z-stack will try to merge.
0479     stack.addMaterial("Material", [&](auto& mat) {
0480       mat.configureFace(OuterCylinder, {AxisRPhi, Bound, 20},
0481                         {AxisZ, Bound, 20});
0482       mat.addStaticVolume(
0483           base * Translation3{Vector3{0, 0, -200_mm}},
0484           std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0485           "VolumeA");
0486     });
0487 
0488     // Second child: plain static volume on the other side in z.
0489     stack.addStaticVolume(
0490         base * Translation3{Vector3{0, 0, 200_mm}},
0491         std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0492         "VolumeB");
0493   });
0494 
0495   // The early-detection error should name the offending face, the shell
0496   // involved, and explain that material was placed on a merged face.
0497   bool thrown = false;
0498   {
0499     Logging::ScopedFailureThreshold threshold{Logging::Level::FATAL};
0500     try {
0501       root->construct({}, gctx, *logger);
0502     } catch (const PortalMergingException& e) {
0503       thrown = true;
0504       std::string msg = e.what();
0505       BOOST_CHECK(msg.find("OuterCylinder") != std::string::npos);
0506       BOOST_CHECK(msg.find("VolumeA") != std::string::npos);
0507       BOOST_CHECK(msg.find("material") != std::string::npos);
0508     }
0509   }
0510   BOOST_CHECK(thrown);
0511 }
0512 
0513 BOOST_AUTO_TEST_CASE(MaterialOnMergedPortalKeepGoing) {
0514   // Same blueprint as MaterialOnMergedPortalThrows, but constructed with the
0515   // keep-going option. Construction must succeed, and the merged outer cylinder
0516   // surface must carry a MergedMaterialMarker.
0517   Transform3 base{Transform3::Identity()};
0518 
0519   Blueprint::Config cfg;
0520   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0521   cfg.envelope[AxisDirection::AxisR] = {0_mm, 20_mm};
0522   auto root = std::make_unique<Blueprint>(cfg);
0523 
0524   root->addCylinderContainer("Stack", AxisDirection::AxisZ, [&](auto& stack) {
0525     using enum AxisDirection;
0526     using enum AxisBoundaryType;
0527     using enum CylinderVolumeBounds::Face;
0528 
0529     stack.addMaterial("Material", [&](auto& mat) {
0530       mat.configureFace(OuterCylinder, {AxisRPhi, Bound, 20},
0531                         {AxisZ, Bound, 20});
0532       mat.addStaticVolume(
0533           base * Translation3{Vector3{0, 0, -200_mm}},
0534           std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0535           "VolumeA");
0536     });
0537 
0538     stack.addStaticVolume(
0539         base * Translation3{Vector3{0, 0, 200_mm}},
0540         std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0541         "VolumeB");
0542   });
0543 
0544   Experimental::BlueprintOptions options;
0545   options.keepGoingOnMaterialMergeFailure = true;
0546 
0547   std::unique_ptr<const TrackingGeometry> trackingGeometry;
0548   {
0549     Logging::ScopedFailureThreshold threshold{Logging::Level::FATAL};
0550     BOOST_REQUIRE_NO_THROW(trackingGeometry =
0551                                root->construct(options, gctx, *logger));
0552   }
0553   BOOST_REQUIRE(trackingGeometry != nullptr);
0554 
0555   std::size_t markerCount = 0;
0556   trackingGeometry->visitSurfaces(
0557       [&](const Surface* surface) {
0558         if (surface != nullptr && surface->surfaceMaterial() != nullptr &&
0559             dynamic_cast<const MergedMaterialMarker*>(
0560                 surface->surfaceMaterial()) != nullptr) {
0561           ++markerCount;
0562         }
0563       },
0564       false);
0565   BOOST_CHECK_GE(markerCount, 1u);
0566 }
0567 
0568 BOOST_AUTO_TEST_CASE(MaterialOnMergedPortalKeepGoingSingleChildFalseWarning) {
0569   // Reproducer for a false-positive in the early material-clash check:
0570   // a single-child AxisZ stack must NOT trigger a merge-material error because
0571   // no portal merge actually happens.  With keepGoingOnMaterialMergeFailure at
0572   // its default (false / strict), construction must succeed without throwing.
0573   Transform3 base{Transform3::Identity()};
0574 
0575   Blueprint::Config cfg;
0576   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0577   cfg.envelope[AxisDirection::AxisR] = {0_mm, 20_mm};
0578   auto root = std::make_unique<Blueprint>(cfg);
0579 
0580   root->addCylinderContainer("Stack", AxisDirection::AxisZ, [&](auto& stack) {
0581     using enum AxisDirection;
0582     using enum AxisBoundaryType;
0583     using enum CylinderVolumeBounds::Face;
0584 
0585     stack.addMaterial("Material", [&](auto& mat) {
0586       mat.configureFace(OuterCylinder, {AxisRPhi, Bound, 20},
0587                         {AxisZ, Bound, 20});
0588       mat.addStaticVolume(
0589           base, std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0590           "VolumeA");
0591     });
0592   });
0593 
0594   // Use strict mode (keepGoingOnMaterialMergeFailure = false by default).
0595   // The single-child stack must not be mistaken for a real merge, so no
0596   // exception should be thrown.
0597   Experimental::BlueprintOptions options;
0598 
0599   std::unique_ptr<const TrackingGeometry> trackingGeometry;
0600   BOOST_REQUIRE_NO_THROW(trackingGeometry =
0601                              root->construct(options, gctx, *logger));
0602   BOOST_REQUIRE(trackingGeometry != nullptr);
0603 
0604   std::size_t markerCount = 0;
0605   trackingGeometry->visitSurfaces(
0606       [&](const Surface* surface) {
0607         if (surface != nullptr && surface->surfaceMaterial() != nullptr &&
0608             dynamic_cast<const MergedMaterialMarker*>(
0609                 surface->surfaceMaterial()) != nullptr) {
0610           ++markerCount;
0611         }
0612       },
0613       false);
0614   BOOST_CHECK_EQUAL(markerCount, 0u);
0615 }
0616 
0617 // Tag the fused face between two z-stacked volumes and look the portal back up
0618 // from the final geometry. The tagged portal is shared by both volumes.
0619 BOOST_AUTO_TEST_CASE(PortalTagLookup) {
0620   using Experimental::PortalDesignatorBlueprintNode;
0621   Transform3 base{Transform3::Identity()};
0622 
0623   Blueprint::Config cfg;
0624   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0625   cfg.envelope[AxisDirection::AxisR] = {0_mm, 20_mm};
0626   auto root = std::make_unique<Blueprint>(cfg);
0627 
0628   root->addCylinderContainer("Stack", AxisDirection::AxisZ, [&](auto& stack) {
0629     using enum CylinderVolumeBounds::Face;
0630 
0631     // Tag VolumeA's PositiveDisc, the face fused with VolumeB's NegativeDisc.
0632     stack.addPortalDesignator("Tags", [&](auto& tags) {
0633       tags.tagFace(PositiveDisc, "tracker_calo_boundary");
0634       tags.addStaticVolume(
0635           base * Translation3{Vector3{0, 0, -200_mm}},
0636           std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0637           "VolumeA");
0638     });
0639 
0640     stack.addStaticVolume(
0641         base * Translation3{Vector3{0, 0, 200_mm}},
0642         std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0643         "VolumeB");
0644   });
0645 
0646   std::unique_ptr<const TrackingGeometry> trackingGeometry =
0647       root->construct({}, gctx, *logger);
0648   BOOST_REQUIRE(trackingGeometry != nullptr);
0649 
0650   const Portal* portal = trackingGeometry->findPortal("tracker_calo_boundary");
0651   BOOST_REQUIRE(portal != nullptr);
0652 
0653   BOOST_CHECK(trackingGeometry->findPortal("does_not_exist") == nullptr);
0654 
0655   // The tagged portal must be the shared portal reachable from both volumes.
0656   auto containsPortal = [&](const TrackingVolume& volume) {
0657     for (const auto& p : volume.portals()) {
0658       if (&p == portal) {
0659         return true;
0660       }
0661     }
0662     return false;
0663   };
0664 
0665   const TrackingVolume* volumeA = nullptr;
0666   const TrackingVolume* volumeB = nullptr;
0667   trackingGeometry->apply([&](const TrackingVolume& volume) {
0668     if (volume.volumeName() == "VolumeA") {
0669       volumeA = &volume;
0670     } else if (volume.volumeName() == "VolumeB") {
0671       volumeB = &volume;
0672     }
0673   });
0674 
0675   BOOST_REQUIRE(volumeA != nullptr);
0676   BOOST_REQUIRE(volumeB != nullptr);
0677   BOOST_CHECK(containsPortal(*volumeA));
0678   BOOST_CHECK(containsPortal(*volumeB));
0679 }
0680 
0681 // Tagging two distinct (non-fused) portals with the same label must be detected
0682 // as a collision when the geometry is closed.
0683 BOOST_AUTO_TEST_CASE(PortalTagDuplicateThrows) {
0684   using Experimental::PortalDesignatorBlueprintNode;
0685   Transform3 base{Transform3::Identity()};
0686 
0687   Blueprint::Config cfg;
0688   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0689   cfg.envelope[AxisDirection::AxisR] = {0_mm, 20_mm};
0690   auto root = std::make_unique<Blueprint>(cfg);
0691 
0692   root->addCylinderContainer("Stack", AxisDirection::AxisZ, [&](auto& stack) {
0693     using enum CylinderVolumeBounds::Face;
0694 
0695     // Tag VolumeA's NegativeDisc (an outer boundary, not fused).
0696     stack.addPortalDesignator("TagsA", [&](auto& tags) {
0697       tags.tagFace(NegativeDisc, "dup");
0698       tags.addStaticVolume(
0699           base * Translation3{Vector3{0, 0, -200_mm}},
0700           std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0701           "VolumeA");
0702     });
0703 
0704     // Tag VolumeB's PositiveDisc (a different outer boundary) with the same
0705     // tag.
0706     stack.addPortalDesignator("TagsB", [&](auto& tags) {
0707       tags.tagFace(PositiveDisc, "dup");
0708       tags.addStaticVolume(
0709           base * Translation3{Vector3{0, 0, 200_mm}},
0710           std::make_shared<CylinderVolumeBounds>(0_mm, 100_mm, 100_mm),
0711           "VolumeB");
0712     });
0713   });
0714 
0715   Logging::ScopedFailureThreshold threshold{Logging::Level::FATAL};
0716   BOOST_CHECK_THROW(root->construct({}, gctx, *logger), std::invalid_argument);
0717 }
0718 
0719 // Same as PortalTagLookup, but for a cuboid x-stack: VolumeA's PositiveXFace is
0720 // fused with VolumeB's NegativeXFace.
0721 BOOST_AUTO_TEST_CASE(PortalTagLookupCuboid) {
0722   using Experimental::PortalDesignatorBlueprintNode;
0723   Transform3 base{Transform3::Identity()};
0724 
0725   Blueprint::Config cfg;
0726   cfg.envelope[AxisDirection::AxisX] = {20_mm, 20_mm};
0727   cfg.envelope[AxisDirection::AxisY] = {20_mm, 20_mm};
0728   cfg.envelope[AxisDirection::AxisZ] = {20_mm, 20_mm};
0729   auto root = std::make_unique<Blueprint>(cfg);
0730 
0731   root->addCuboidContainer("Stack", AxisDirection::AxisX, [&](auto& stack) {
0732     using enum CuboidVolumeBounds::Face;
0733 
0734     stack.addPortalDesignator("Tags", [&](auto& tags) {
0735       tags.tagFace(PositiveXFace, "cuboid_boundary");
0736       tags.addStaticVolume(
0737           base * Translation3{Vector3{-200_mm, 0, 0}},
0738           std::make_shared<CuboidVolumeBounds>(100_mm, 100_mm, 100_mm),
0739           "VolumeA");
0740     });
0741 
0742     stack.addStaticVolume(
0743         base * Translation3{Vector3{200_mm, 0, 0}},
0744         std::make_shared<CuboidVolumeBounds>(100_mm, 100_mm, 100_mm),
0745         "VolumeB");
0746   });
0747 
0748   std::unique_ptr<const TrackingGeometry> trackingGeometry =
0749       root->construct({}, gctx, *logger);
0750   BOOST_REQUIRE(trackingGeometry != nullptr);
0751 
0752   const Portal* portal = trackingGeometry->findPortal("cuboid_boundary");
0753   BOOST_REQUIRE(portal != nullptr);
0754 
0755   auto containsPortal = [&](const TrackingVolume& volume) {
0756     for (const auto& p : volume.portals()) {
0757       if (&p == portal) {
0758         return true;
0759       }
0760     }
0761     return false;
0762   };
0763 
0764   const TrackingVolume* volumeA = nullptr;
0765   const TrackingVolume* volumeB = nullptr;
0766   trackingGeometry->apply([&](const TrackingVolume& volume) {
0767     if (volume.volumeName() == "VolumeA") {
0768       volumeA = &volume;
0769     } else if (volume.volumeName() == "VolumeB") {
0770       volumeB = &volume;
0771     }
0772   });
0773 
0774   BOOST_REQUIRE(volumeA != nullptr);
0775   BOOST_REQUIRE(volumeB != nullptr);
0776   BOOST_CHECK(containsPortal(*volumeA));
0777   BOOST_CHECK(containsPortal(*volumeB));
0778 }
0779 
0780 BOOST_AUTO_TEST_SUITE_END();