From ed72b013868478fa66dcec58b625c388e4404c1d Mon Sep 17 00:00:00 2001 From: Quentin Misslin <22476605+qmisslin@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:15:18 +0200 Subject: [PATCH 1/2] feat(core): expose G4GDMLParser to Python --- .../g4_bindings/pyG4GDMLParser.cpp | 29 +++++++++++++++++++ core/opengate_core/opengate_core.cpp | 7 +++++ 2 files changed, 36 insertions(+) create mode 100644 core/opengate_core/g4_bindings/pyG4GDMLParser.cpp diff --git a/core/opengate_core/g4_bindings/pyG4GDMLParser.cpp b/core/opengate_core/g4_bindings/pyG4GDMLParser.cpp new file mode 100644 index 0000000000..098bb608aa --- /dev/null +++ b/core/opengate_core/g4_bindings/pyG4GDMLParser.cpp @@ -0,0 +1,29 @@ +#include + +#ifdef USE_GDML + +#include "G4GDMLParser.hh" + +namespace py = pybind11; + +void init_G4GDMLParser(py::module &m) { + py::class_(m, "G4GDMLParser") + .def(py::init<>()) + + .def( + "Read", + [](G4GDMLParser &parser, const G4String &filename, G4bool validate) { + parser.Read(filename, validate); + }, + py::arg("filename"), py::arg("validate") = true) + + .def("GetWorldVolume", &G4GDMLParser::GetWorldVolume, + py::arg("setup_name") = "Default", + py::return_value_policy::reference_internal) + + .def("SetStripFlag", &G4GDMLParser::SetStripFlag, py::arg("strip")) + + .def("SetOverlapCheck", &G4GDMLParser::SetOverlapCheck, py::arg("check")); +} + +#endif diff --git a/core/opengate_core/opengate_core.cpp b/core/opengate_core/opengate_core.cpp index e317eedc17..401a2629c3 100644 --- a/core/opengate_core/opengate_core.cpp +++ b/core/opengate_core/opengate_core.cpp @@ -566,8 +566,15 @@ void init_GateVolumeVoxelizer(py::module &); void init_GateImageBox(py::module &m); +#ifdef USE_GDML +void init_G4GDMLParser(py::module &); +#endif + PYBIND11_MODULE(opengate_core, m) { +#ifdef USE_GDML + init_G4GDMLParser(m); +#endif init_G4ThreeVector(m); init_G4AffineTransform(m); init_G4String(m); From 57355ceb0f5b1ffdad7d67fe4013ff795add9016 Mon Sep 17 00:00:00 2001 From: Quentin Misslin <22476605+qmisslin@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:15:18 +0200 Subject: [PATCH 2/2] feat(geometry): add GDML volume import --- .../user_guide_reference_volumes.rst | 59 +++++++ opengate/geometry/volumes.py | 156 +++++++++++++++++ opengate/managers.py | 2 + .../tests/src/geometry/test113_gdml_volume.py | 161 ++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 opengate/tests/src/geometry/test113_gdml_volume.py diff --git a/docs/source/user_guide/user_guide_reference_volumes.rst b/docs/source/user_guide/user_guide_reference_volumes.rst index 331e2d5604..96d6a22ffb 100644 --- a/docs/source/user_guide/user_guide_reference_volumes.rst +++ b/docs/source/user_guide/user_guide_reference_volumes.rst @@ -162,6 +162,65 @@ Reference .. autoclass:: opengate.geometry.volumes.TubsVolume +GDML volumes +------------ + +Description +~~~~~~~~~~~ + +A GDML volume imports a complete geometry described with the Geant4 +Geometry Description Markup Language. The logical volume associated with +the selected GDML setup is placed as a subtree inside the specified GATE +mother volume. The regular GATE world volume is therefore preserved. + +Solids, logical volumes, placements, and materials contained in the GDML +file are constructed directly by Geant4. The ``material`` parameter of +the GATE volume is ignored because the materials are read from the GDML +file. + +GDML support requires ``opengate_core`` and Geant4 to have been compiled +with GDML support. + +A basic example is: + +.. code:: python + + import opengate as gate + + sim = gate.Simulation() + + gdml = sim.add_volume("GDML", name="ImportedGeometry") + gdml.file_name = "geometry.gdml" + gdml.mother = "world" + gdml.setup_name = "Default" + gdml.validate = False + gdml.strip_names = False + gdml.parser_overlap_check = False + +The standard volume parameters ``translation`` and ``rotation`` control +the placement of the imported GDML root inside its GATE mother volume. + +The GDML-specific parameters are: + +- ``file_name``: path to the GDML input file. +- ``setup_name``: GDML setup to import, ``"Default"`` by default. +- ``validate``: enable XML schema validation while reading the file. +- ``strip_names``: strip Geant4 pointer suffixes from imported names. +- ``parser_overlap_check``: enable overlap checking during GDML parsing. + +Actors attached to the GDML volume are propagated to its imported logical +volume subtree in the same way as for native GATE volumes. + +See +`test113_gdml_volume `_ +for an example simulation. + +Reference +~~~~~~~~~ + +.. autoclass:: opengate.geometry.volumes.GDMLVolume + + Tesselated (mesh) volumes ------------------------- diff --git a/opengate/geometry/volumes.py b/opengate/geometry/volumes.py index 537f0a355d..83b8b7be4e 100644 --- a/opengate/geometry/volumes.py +++ b/opengate/geometry/volumes.py @@ -765,6 +765,161 @@ class TesselatedVolume(RepeatableVolume, solids.TesselatedSolid): """ +class GDMLVolume(VolumeBase): + """ + Volume importing a complete GDML geometry as a subtree. + + The logical volume associated with the GDML world is placed inside + the OpenGATE mother volume. The OpenGATE world is therefore preserved. + """ + + user_info_defaults = { + "file_name": ( + "", + { + "doc": "Path to the GDML input file.", + "is_input_file": True, + }, + ), + "setup_name": ( + "Default", + { + "doc": "Name of the GDML setup whose world volume is imported.", + }, + ), + "validate": ( + False, + { + "doc": "Enable XML schema validation while reading the GDML file.", + "type": bool, + }, + ), + "strip_names": ( + False, + { + "doc": "Strip Geant4 pointer suffixes from imported GDML names.", + "type": bool, + }, + ), + "parser_overlap_check": ( + False, + { + "doc": "Enable overlap checking while the GDML parser creates placements.", + "type": bool, + }, + ), + "material": ( + None, + { + "doc": "Ignored. Materials are read directly from the GDML file.", + "override": True, + }, + ), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Keep the parser alive for as long as the imported geometry is used. + self.g4_gdml_parser = None + self.g4_imported_world_physical_volume = None + self.g4_solid = None + + def __getstate__(self): + return_dict = super().__getstate__() + return_dict["g4_gdml_parser"] = None + return_dict["g4_imported_world_physical_volume"] = None + return_dict["g4_solid"] = None + return return_dict + + def release_g4_references(self): + super().release_g4_references() + self.g4_imported_world_physical_volume = None + self.g4_gdml_parser = None + self.g4_solid = None + + def construct(self): + if self._is_constructed: + return + + if not hasattr(g4, "G4GDMLParser"): + fatal( + "GDML support is unavailable in opengate_core. " + "Geant4 and opengate_core must be compiled with GDML support." + ) + + if self.mother is None: + fatal( + f"GDMLVolume '{self.name}' cannot be used as the OpenGATE world. " + "Assign it to an existing OpenGATE mother volume." + ) + + gdml_file_name = ensure_filename_is_str(self.file_name) + + if not gdml_file_name: + fatal( + f"No GDML file was provided for GDMLVolume '{self.name}'. " + "Set its 'file_name' property." + ) + + if not os.path.isfile(gdml_file_name): + fatal( + f"GDML file '{gdml_file_name}' does not exist " + f"for GDMLVolume '{self.name}'." + ) + + parser = g4.G4GDMLParser() + parser.SetStripFlag(self.strip_names) + parser.SetOverlapCheck(self.parser_overlap_check) + parser.Read(gdml_file_name, self.validate) + + imported_world = parser.GetWorldVolume(self.setup_name) + + if imported_world is None: + fatal( + f"Unable to retrieve GDML setup '{self.setup_name}' " + f"from file '{gdml_file_name}'." + ) + + imported_logical_volume = imported_world.GetLogicalVolume() + + if imported_logical_volume is None: + fatal( + f"The world physical volume from GDML setup " + f"'{self.setup_name}' has no logical volume." + ) + + self.g4_gdml_parser = parser + self.g4_imported_world_physical_volume = imported_world + self.g4_logical_volume = imported_logical_volume + self.g4_solid = imported_logical_volume.GetSolid() + self.g4_material = imported_logical_volume.GetMaterial() + + if self.build_physical_volume: + self.construct_physical_volume() + + self._is_constructed = True + + def _make_physical_volume(self, volume_name, g4_transform, copy_index=0): + mother_volume = self.mother_volume + + if mother_volume is None or mother_volume.g4_logical_volume is None: + fatal( + f"Unable to retrieve the constructed mother logical volume " + f"for GDMLVolume '{self.name}'." + ) + + return g4.G4PVPlacement( + g4_transform, + self.g4_logical_volume, + volume_name, + mother_volume.g4_logical_volume, + False, + copy_index, + self.volume_manager.simulation.check_volumes_overlap, + ) + + class RepeatParametrisedVolume(VolumeBase): """ Volume created from another volume via translations. @@ -1440,4 +1595,5 @@ def __getstate__(self): process_cls(TubsVolume) process_cls(RepeatParametrisedVolume) process_cls(ImageVolume) +process_cls(GDMLVolume) process_cls(TesselatedVolume) diff --git a/opengate/managers.py b/opengate/managers.py index 69182d36ed..c6ada572a8 100644 --- a/opengate/managers.py +++ b/opengate/managers.py @@ -128,6 +128,7 @@ BoxVolume, ConsVolume, EllipsoidVolume, + GDMLVolume, HexagonVolume, ImageVolume, ParallelWorldVolume, @@ -1387,6 +1388,7 @@ class VolumeManager(GateObject): "TrdVolume": TrdVolume, "BooleanVolume": BooleanVolume, "RepeatParametrisedVolume": RepeatParametrisedVolume, + "GDMLVolume": GDMLVolume, "TesselatedVolume": TesselatedVolume, } diff --git a/opengate/tests/src/geometry/test113_gdml_volume.py b/opengate/tests/src/geometry/test113_gdml_volume.py new file mode 100644 index 0000000000..bc0987e8e6 --- /dev/null +++ b/opengate/tests/src/geometry/test113_gdml_volume.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import tempfile +from pathlib import Path + +import opengate as gate +import opengate_core as g4 +from opengate.geometry.volumes import GDMLVolume +from opengate.tests import utility + +GDML_CONTENT = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def create_simulation(gdml_path, output_path): + sim = gate.Simulation() + + sim.g4_verbose = False + sim.visu = False + sim.number_of_threads = 1 + sim.random_engine = "MersenneTwister" + sim.random_seed = 123456789 + sim.output_dir = output_path + + m = gate.g4_units.m + sim.world.size = [3 * m, 3 * m, 3 * m] + sim.world.material = "G4_AIR" + + imported = sim.add_volume("GDML", "ImportedGDMLGeometry") + imported.file_name = gdml_path + imported.setup_name = "Default" + imported.validate = False + imported.strip_names = False + imported.parser_overlap_check = False + imported.mother = "world" + + source = sim.add_source("GenericSource", "GammaSource") + source.particle = "gamma" + source.n = 100 + source.energy.mono = 1 * gate.g4_units.MeV + source.position.type = "point" + source.position.translation = [0, 0, 0] + source.direction.type = "momentum" + source.direction.momentum = [1, 0, 0] + + stats = sim.add_actor("SimulationStatisticsActor", "Stats") + + # Attach a sensitive actor to the imported GDML root. + # OpenGATE must propagate it to the logical-volume children + # created internally by G4GDMLParser. + kill_actor = sim.add_actor("KillActor", "KillImportedGeometry") + kill_actor.attached_to = imported + + return sim, imported, stats, kill_actor + + +def main(): + assert hasattr(g4, "G4GDMLParser") + + with tempfile.TemporaryDirectory(prefix="opengate_gdml_test_") as directory: + output_path = Path(directory) + gdml_path = output_path / "minimal.gdml" + gdml_path.write_text(GDML_CONTENT) + + assert gdml_path.is_file() + + sim, imported, stats, kill_actor = create_simulation( + gdml_path, + output_path, + ) + + assert isinstance(imported, GDMLVolume) + assert imported.mother == "world" + assert imported.setup_name == "Default" + assert Path(imported.file_name) == gdml_path + + sim.run() + + print(stats) + + assert stats.counts.runs == 1 + assert stats.counts.events == 100 + assert stats.counts.tracks >= 100 + assert stats.counts.steps >= stats.counts.tracks + + print( + "Particles killed in imported GDML geometry:", + kill_actor.number_of_killed_particles, + ) + assert kill_actor.number_of_killed_particles >= stats.counts.events + assert kill_actor.number_of_killed_particles == stats.counts.tracks + + utility.test_ok(True) + + +if __name__ == "__main__": + main()