Repository: Fusion-Power-Plant-Framework/bluemira
Pull request #3905: CAD Imprinting
Author: login: oliverfunk
Labels: name: meshing
description: Tasks relating to the meshing module
name: neutronics
description: Tasks relating to the neutronics module
Created: 2025-05-01T10:12:07Z | Merged: 2025-05-15T09:59:02Z

## Linked Issues

Closes #3902

## Description

<!-- What is your PR trying to achieve? How did you go about achieving it? -->

## Interface Changes

<!-- If you've had to update an interface or introduce a new interface as part of your change then let us know here. -->

## Checklist

I confirm that I have completed the following checks:

- [ ] Tests run locally and pass `pytest tests --reactor`
- [ ] Code quality checks run locally and pass `pre-commit run --from-ref develop --to-ref HEAD`
- [ ] Documentation built locally and checked `sphinx-build -W documentation/source documentation/build`


Linked issue #3902: Imprinting CAD
### Description of issue / requirement to address

As a modeller interested in running a neutronics simulation on CAD, co-incident faces need to be imrpinted and then merged.

### Proposed solution

Implement an efficient algorithm that can detect (ideally taking to account the geometry in a confromal manner) overlapping or touching solids.

Then use a routine to imprint and merge said solids.

This should work such that the geometry saved into some format (say a .stp file) includes the merged faces.

It could still be fine if this is not that case as a pre-processing step can be run on the meshed geometry (i.e. after some MOAB meshing) that can merge close vertices/faces.

### Alternative solutions

_No response_

### Additional Context

_No response_

Diff:
diff --git a/bluemira/base/reactor.py b/bluemira/base/reactor.py
index 520c47e440..02e0abcd50 100644
--- a/bluemira/base/reactor.py
+++ b/bluemira/base/reactor.py
@@ -427,6 +427,7 @@ def time_since_init(self) -> float:
     def _component_managers(
         self,
         with_components: ComponentManager | list[ComponentManager] | None = None,
+        without_components: list[ComponentManager] | None = None,
     ) -> list[ComponentManager]:
         """
         Get the component managers for the reactor.
@@ -436,6 +437,9 @@ def _component_managers(
         with_components:
             The components to include. Defaults to None, which means
             include all components.
+        without_components:
+            The components to exclude. Defaults to None, which means
+            exclude no components.
 
         Returns
         -------
@@ -457,6 +461,8 @@ def _component_managers(
 
         if isinstance(with_components, ComponentManager):
             with_components = [with_components]
+        if isinstance(without_components, ComponentManager):
+            without_components = [without_components]
 
         comp_managers = [
             getattr(self, comp_name)
@@ -465,12 +471,23 @@ def _component_managers(
             if issubclass(comp_type, ComponentManager)
             # filter out component managers that are not initialised
             and getattr(self, comp_name, None) is not None
-            # if with_components is set, filter out components not in the list
-            and (with_components is None or getattr(self, comp_name) in with_components)
         ]
+        if with_components:
+            comp_managers = [
+                comp_manager
+                for comp_manager in comp_managers
+                if comp_manager in with_components
+            ]
+        elif without_components:
+            comp_managers = [
+                comp_manager
+                for comp_manager in comp_managers
+                if comp_manager not in without_components
+            ]
         if not comp_managers:
             raise ComponentError(
-                "The reactor has no components defined or instantiated."
+                "The reactor has no components defined, instantiated "
+                "or they've all been filtered."
             )
         return comp_managers
 
@@ -482,7 +499,11 @@ def _build_component_tree(
         for_save: bool = False,
     ) -> Component:
         reactor_component = Component(self.name)
-        for comp_manager in track(self._component_managers(cp_values.with_components)):
+        for comp_manager in track(
+            self._component_managers(
+                cp_values.with_components, cp_values.without_components
+            )
+        ):
             if dim:
                 if for_save:
                     comp = comp_manager._build_save_cad_component(dim, cp_values)
diff --git a/bluemira/base/tools.py b/bluemira/base/tools.py
index f996af95dc..a2c3df9d96 100644
--- a/bluemira/base/tools.py
+++ b/bluemira/base/tools.py
@@ -133,6 +133,7 @@ class ConstructionParams(TypedDict):
     """
 
     with_components: NotRequired[list[ComponentManager] | None]
+    without_components: NotRequired[list[ComponentManager] | None]
     component_filter: NotRequired[Callable[[Component], bool] | None]
     n_sectors: NotRequired[int | None]
     total_sectors: NotRequired[int | None]
@@ -147,6 +148,7 @@ class ConstructionParamValues:
     """
 
     with_components: list[ComponentManager] | None
+    without_components: list[ComponentManager] | None
     component_filter: Callable[[Component], bool] | None
     n_sectors: int
     total_sectors: int
@@ -165,6 +167,7 @@ def empty(cls) -> ConstructionParamValues:
         """
         return cls(
             with_components=None,
+            without_components=None,
             component_filter=None,
             n_sectors=1,
             total_sectors=1,
@@ -199,6 +202,7 @@ def from_construction_params(cls, construction_params: ConstructionParams | None
 
         return cls(
             with_components=construction_params.get("with_components"),
+            without_components=construction_params.get("without_components"),
             component_filter=comp_filter,
             n_sectors=n_secs,
             total_sectors=tot_secs,
diff --git a/bluemira/codes/cgal_ext/__init__.py b/bluemira/codes/cgal_ext/__init__.py
new file mode 100644
index 0000000000..daf6fd19e9
--- /dev/null
+++ b/bluemira/codes/cgal_ext/__init__.py
@@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+The API for CGAL.
+"""
+
+from bluemira.codes.cgal_ext._guard import cgal_available
+from bluemira.codes.cgal_ext.collision_detection import (
+    do_polys_collide,
+    tri_mesh_to_cgal_mesh,
+)
+
+__all__ = [
+    "cgal_available",
+    "do_polys_collide",
+    "tri_mesh_to_cgal_mesh",
+]
diff --git a/bluemira/codes/cgal_ext/_guard.py b/bluemira/codes/cgal_ext/_guard.py
new file mode 100644
index 0000000000..8447245749
--- /dev/null
+++ b/bluemira/codes/cgal_ext/_guard.py
@@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+API guard for the CGAL package.
+"""
+
+from bluemira.codes.utilities import code_guard, is_code_available
+
+
+def cgal_available():
+    return is_code_available("CGAL")
+
+
+cgal_guard = code_guard(
+    "CGAL",
+    "CGAL is not available. Run `pip install cgal` to use this function.",
+)
diff --git a/bluemira/codes/cgal_ext/collision_detection.py b/bluemira/codes/cgal_ext/collision_detection.py
new file mode 100644
index 0000000000..da05c936de
--- /dev/null
+++ b/bluemira/codes/cgal_ext/collision_detection.py
@@ -0,0 +1,128 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+Contains functions to efficiently convert to
+CGAL geometry and perform meshed-based collision detections.
+"""
+
+import numpy as np
+
+from bluemira.codes.cgal_ext._guard import cgal_guard
+
+try:
+    from CGAL.CGAL_Kernel import Point_3
+    from CGAL.CGAL_Polygon_mesh_processing import (
+        Int_Vector,
+        Point_3_Vector,
+        Polygon_Vector,
+        do_intersect,
+        polygon_soup_to_polygon_mesh,
+    )
+    from CGAL.CGAL_Polyhedron_3 import Polyhedron_3
+except ImportError:
+    pass
+
+
+def _scale_points_from_centroid(points, scale_factor):
+    """
+    Scale a set of points from their centroid by a given scale factor.
+
+    Parameters
+    ----------
+    points : np.ndarray
+        An array of shape (n, 3) representing the x, y, z coordinates of the points.
+    scale_factor : float
+        The scale factor by which to scale the points.
+
+    Returns
+    -------
+    scaled_points : np.ndarray
+        An array of shape (n, 3) representing the scaled x, y, z
+        coordinates of the points.
+    """
+    # Calculate the centroid
+    centroid = np.mean(points, axis=0)
+
+    # Translate points to the origin
+    translated_points = points - centroid
+
+    # Scale the points
+    scaled_points = translated_points * scale_factor
+
+    # Translate points back to the original position
+    scaled_points += centroid
+
+    return scaled_points
+
+
+@cgal_guard
+def tri_mesh_to_cgal_mesh(points: np.ndarray, tris: np.ndarray, scale: float = 1):
+    """
+    Convert a triangle mesh to a CGAL Polyhedron_3 object.
+    This function is used to create a CGAL mesh from a set of points and triangles.
+    It scales the points from their centroid by a given scale factor.
+
+    Parameters
+    ----------
+    points
+        An array of shape (n, 3) representing the x, y, z coordinates of the points.
+    tris
+        An array of shape (m, 3) representing the indices of the points that form
+        the triangles.
+    scale
+        The scale factor by which to scale the points from their centroid.
+
+    Returns
+    -------
+    Polyhedron_3
+        A CGAL Polyhedron_3 object representing the mesh.
+
+    Raises
+    ------
+    ImportError
+        If CGAL is not available, an ImportError is raised.
+    """
+    points = np.asarray(points)
+    points = _scale_points_from_centroid(points, scale)
+    pt_3_vec = Point_3_Vector()
+    pt_3_vec.reserve(3)
+    for p in points:
+        pt_3_vec.append(Point_3(p[0], p[1], p[2]))
+    poly_vec = Polygon_Vector()
+    poly_vec.reserve(len(tris))
+    for t in tris:
+        poly = Int_Vector()
+        poly.reserve(3)
+        poly.append(int(t[0]))
+        poly.append(int(t[1]))
+        poly.append(int(t[2]))
+        poly_vec.append(poly)
+    p = Polyhedron_3()
+    polygon_soup_to_polygon_mesh(pt_3_vec, poly_vec, p)
+    return p
+
+
+@cgal_guard
+def do_polys_collide(
+    mesh_a: Polyhedron_3,
+    mesh_b: Polyhedron_3,
+) -> bool:
+    """
+    Check if two CGAL Polyhedron_3 objects collide.
+
+    Parameters
+    ----------
+    mesh_a
+        The first CGAL Polyhedron_3 object.
+    mesh_b
+        The second CGAL Polyhedron_3 object.
+
+    Returns
+    -------
+    bool
+        True if the two meshes collide, False otherwise.
+    """
+    return do_intersect(mesh_a, mesh_b)
diff --git a/bluemira/codes/python_occ/__init__.py b/bluemira/codes/python_occ/__init__.py
new file mode 100644
index 0000000000..956395d4fc
--- /dev/null
+++ b/bluemira/codes/python_occ/__init__.py
@@ -0,0 +1,16 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+The API for Python OCC.
+"""
+
+from bluemira.codes.python_occ.imprint_solids import imprint_solids
+from bluemira.codes.python_occ.imprintable_solid import ImprintableSolid
+
+__all__ = [
+    "ImprintableSolid",
+    "imprint_solids",
+]
diff --git a/bluemira/codes/python_occ/_guard.py b/bluemira/codes/python_occ/_guard.py
new file mode 100644
index 0000000000..cd0c82aaba
--- /dev/null
+++ b/bluemira/codes/python_occ/_guard.py
@@ -0,0 +1,16 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+"""
+API guard for the OCC package.
+"""
+
+from bluemira.codes.utilities import code_guard
+
+occ_guard = code_guard(
+    "OCC",
+    "OCC is not available. Run `conda install pythonocc-core` to use this function.",
+)
diff --git a/bluemira/codes/python_occ/imprint_solids.py b/bluemira/codes/python_occ/imprint_solids.py
new file mode 100644
index 0000000000..223b535532
--- /dev/null
+++ b/bluemira/codes/python_occ/imprint_solids.py
@@ -0,0 +1,239 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+Imprinting solids together.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from rich.progress import track
+
+from bluemira.base.look_and_feel import bluemira_print
+from bluemira.codes.python_occ._guard import occ_guard
+from bluemira.codes.python_occ.imprintable_solid import ImprintableSolid
+from bluemira.geometry.overlap_checking import find_approx_overlapping_pairs
+from bluemira.geometry.solid import BluemiraSolid
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
+
+try:
+    from OCC.Core.BOPAlgo import BOPAlgo_MakeConnected
+    from OCC.Core.TopTools import TopTools_ListIteratorOfListOfShape
+    from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Solid  # noqa: TC002
+    from OCC.Extend.TopologyUtils import TopologyExplorer
+except ImportError:
+    pass
+
+
+class _Imprinter:
+    """Imprints solids together using the BOPAlgo_MakeConnected algorithm."""
+
+    def __init__(
+        self,
+        *,
+        run_parallel=False,
+        parallel_mode=False,
+        use_obb=False,
+    ):
+        self._imprint_builder = BOPAlgo_MakeConnected()
+        self._imprint_builder.SetRunParallel(run_parallel)
+        self._imprint_builder.SetParallelMode(parallel_mode)
+        self._imprint_builder.SetUseOBB(use_obb)
+
+    def __call__(self, imprintables: list[ImprintableSolid]) -> int:
+        """Imprints the solids together, internally mutating the ImprintableSolid.
+
+        Parameters
+        ----------
+        imprintables : list[ImprintableSolid]
+            The imprintables to imprint together.
+
+        Returns
+        -------
+        int
+            The number of imprints performed.
+
+        Raises
+        ------
+        ValueError
+            If the imprintables are not valid.
+        """
+        bldr = self._imprint_builder
+        bldr.Clear()
+
+        org_solid_to_imp_map: dict[Any, ImprintableSolid] = {}
+        org_face_to_imp_map: dict[Any, ImprintableSolid] = {}
+
+        for imp in imprintables:
+            occ_sld = imp.occ_solid
+            bldr.AddArgument(occ_sld)
+            org_solid_to_imp_map[occ_sld] = imp
+            for resulting_face in TopologyExplorer(occ_sld).faces():
+                org_face_to_imp_map[resulting_face] = imp
+
+        bldr.Perform()
+        res = bldr.Shape()
+
+        ex = TopologyExplorer(res)
+
+        # you have to update the occ_solid of the imprintable
+        # with the new imprinted solid. Imagen if there are
+        # two or more imprints on the same face, the final
+        # shape needs to account for each.
+        for resulting_solid in ex.solids():
+            solid_origin = bldr.GetOrigins(resulting_solid)
+            solid_origin_iter = TopTools_ListIteratorOfListOfShape(solid_origin)
+            i = 0
+            while solid_origin_iter.More():
+                i += 1
+                if i > 1:
+                    raise ValueError(
+                        "Imprinter does not support overlapping solids yet."
+                    )
+                # get the original solid
+                org_solid = solid_origin_iter.Value()
+                # use the map to set the new imprinted solid on
+                # on the corresponding imprintable
+                imp_from_solid = org_solid_to_imp_map[org_solid]
+                imp_from_solid.set_imprinted_solid(resulting_solid)
+                solid_origin_iter.Next()
+
+        imprints_performed = 0
+
+        for resulting_face in ex.faces():
+            face_origin = bldr.GetOrigins(resulting_face)
+            face_origin_iter = TopTools_ListIteratorOfListOfShape(face_origin)
+            original_faces = []
+            i = 0
+            while face_origin_iter.More():
+                i += 1
+                if i > 2:  # noqa: PLR2004
+                    raise ValueError(
+                        "Somehow there are more than 2 faces that imprinted this face."
+                    )
+                # get the original face
+                original_faces.append(face_origin_iter.Value())
+                face_origin_iter.Next()
+
+            if i == 0:
+                original_faces.append(resulting_face)
+
+            # i = 1 means the face was changed, but it's not the imprinted face
+
+            if i == 2:  # noqa: PLR2004
+                imprints_performed += 1
+
+            # bind faces
+            for org_f in original_faces:
+                imp_from_face = org_face_to_imp_map[org_f]
+                imp_from_face.bind_imprinted_face(resulting_face)
+
+        for imp in imprintables:
+            imp.finalise_binding()
+
+        return imprints_performed
+
+
+class ImprintResult:
+    """Result of imprinting solids together."""
+
+    def __init__(self, imprintables: list[ImprintableSolid], total_imprints: int):
+        self._imprintables = imprintables
+        self.total_imprints = total_imprints
+
+    @property
+    def imprintables(self) -> list[ImprintableSolid]:
+        """Returns the imprintables."""
+        return self._imprintables
+
+    @property
+    def labels(self) -> list[str]:
+        """Returns the labels of the imprintables."""
+        return [imp.label for imp in self._imprintables]
+
+    @property
+    def solids(self) -> list[BluemiraSolid]:
+        """Returns the imprinted BluemiraSolids."""
+        return [imp.to_bluemira_solid() for imp in self._imprintables]
+
+    @property
+    def occ_solids(self) -> list[TopoDS_Solid]:
+        """Returns the imprinted TopoDS_Solids."""
+        return [imp.occ_solid for imp in self._imprintables]
+
+    @property
+    def occ_faces(self) -> list[TopoDS_Face]:
+        """Returns the imprinted TopoDS_Face."""
+        return [face for imp in self._imprintables for face in imp.imprinted_faces]
+
+
+@occ_guard
+def imprint_solids(
+    solids: Iterable[BluemiraSolid],
+    labels: Iterable[str] | None = None,
+    *,
+    use_cgal=True,
+) -> ImprintResult:
+    """Imprints solids together.
+
+    Parameters
+    ----------
+    solids:
+        The solids to imprint together.
+    labels:
+        The labels to use for the solids. If None, the labels will be
+        taken from the solids. Must be the same length as solids.
+    use_cgal:
+        Whether to use CGAL for improved overlap checking speed and precision.
+        If True and CGAL is not available, a numpy based approach will be used
+        as a fallback.
+        If False, the numpy based approach will be used regardless of CGAL
+        availability.
+
+    Returns
+    -------
+        The imprintable solids.
+
+    Raises
+    ------
+    ValueError
+        If the labels are not the same length as the solids.
+    TypeError
+        If the solids are not of type BluemiraSolid.
+    """
+    if labels is None or len(labels) == 0:
+        labels = [sld.label for sld in solids]
+    if len(labels) != len(solids):
+        raise ValueError(
+            "Labels must be the same length as the solids iterable: "
+            f"{len(labels)} vs. {len(solids)}"
+        )
+    for sld, lbl in zip(solids, labels, strict=True):
+        if not isinstance(sld, BluemiraSolid):
+            raise TypeError(f"solids must be BluemiraSolid only - {lbl} is {type(sld)}")
+
+    pairs = find_approx_overlapping_pairs(solids, use_cgal=use_cgal)
+    imprintables = [
+        ImprintableSolid.from_bluemira_solid(lbl, sld)
+        for sld, lbl in zip(solids, labels, strict=False)
+    ]
+
+    # pairs and imprintables have the same ordering
+    # ie indexes match
+
+    imprinter = _Imprinter(parallel_mode=True, run_parallel=True)
+
+    total_imprints = 0
+    bluemira_print(f"Imprinting solids together: {len(pairs)} potential pairs found.")
+    for a, b in track(pairs):
+        total_imprints += imprinter([imprintables[a], imprintables[b]])
+    bluemira_print(f"Total imprints performed: {total_imprints}")
+
+    return ImprintResult(imprintables, total_imprints)
diff --git a/bluemira/codes/python_occ/imprintable_solid.py b/bluemira/codes/python_occ/imprintable_solid.py
new file mode 100644
index 0000000000..0dfd8233c5
--- /dev/null
+++ b/bluemira/codes/python_occ/imprintable_solid.py
@@ -0,0 +1,113 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+Working class for imprinting solids.
+"""
+
+from bluemira.codes.python_occ._guard import occ_guard
+from bluemira.geometry.solid import BluemiraSolid
+
+try:
+    from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Solid
+    from OCC.Extend.TopologyUtils import TopologyExplorer
+
+    import Part  # isort: skip
+except ImportError:
+    pass
+
+
+class ImprintableSolid:
+    """Represents a solid that can be imprinted."""
+
+    @occ_guard
+    def __init__(self, label: str, bm_solid: BluemiraSolid, occ_solid: TopoDS_Solid):
+        self._label = label
+        self._bm_solid = bm_solid
+        self._imprinted_occ_solid = occ_solid
+        self._has_imprinted = False
+        self._imprinted_faces: set[TopoDS_Face] = set(
+            TopologyExplorer(occ_solid).faces()
+        )
+        self._shadow_imprinted_faces: set[TopoDS_Face] = set()
+
+    @classmethod
+    def from_bluemira_solid(cls, label: str, bm_solid: BluemiraSolid):
+        """
+        Creates an ImprintableSolid from a BluemiraSolid.
+
+        Parameters
+        ----------
+        label : str
+            The label of the solid.
+        bm_solid : BluemiraSolid
+            The BluemiraSolid to imprint.
+
+        Returns
+        -------
+        ImprintableSolid
+            The ImprintableSolid.
+
+        Raises
+        ------
+        TypeError
+            If bm_solid is not a BluemiraSolid.
+        """
+        if not isinstance(bm_solid, BluemiraSolid):
+            raise TypeError(f"bm_solid must be a BluemiraSolid, got: {type(bm_solid)}")
+        return cls(label, bm_solid, Part.__toPythonOCC__(bm_solid.shape))
+
+    @property
+    def label(self) -> str:
+        """Returns the label of the solid."""
+        return self._label
+
+    @property
+    def occ_solid(self) -> TopoDS_Solid:
+        """Returns the TopoDS_Solid of the solid."""
+        return self._imprinted_occ_solid
+
+    @property
+    def imprinted_faces(self) -> set[TopoDS_Face]:
+        """Returns the imprinted faces of the solid."""
+        return self._imprinted_faces
+
+    def bind_imprinted_face(self, face: TopoDS_Face):
+        """
+        Binds a face to the imprintable solid, adding it to the shadow set.
+        The finalise_binding method must be called after binding all faces.
+        """
+        self._shadow_imprinted_faces.add(face)
+
+    def finalise_binding(self):
+        """
+        Finalises the binding of the imprinted faces, moving them from the
+        shadow set to the imprinted set.
+        """
+        self._imprinted_faces = self._shadow_imprinted_faces.copy()
+        self._shadow_imprinted_faces.clear()
+
+    def set_imprinted_solid(self, imprinted_occ_solid):
+        """
+        Sets the imprinted solid of the imprintable solid.
+        This is used to set the solid after imprinting.
+        """
+        self._imprinted_occ_solid = imprinted_occ_solid
+        self._has_imprinted = True
+
+    def to_bluemira_solid(self) -> BluemiraSolid:
+        """
+        Returns the imprinted BluemiraSolid.
+        If the solid has not been imprinted, it returns the original solid.
+
+        Returns
+        -------
+        BluemiraSolid
+            The imprinted BluemiraSolid.
+        """
+        if self._has_imprinted:
+            api_solid = Part.__fromPythonOCC__(self._imprinted_occ_solid)
+            return BluemiraSolid._create(Part.Solid(api_solid), self._label)
+        return self._bm_solid
diff --git a/bluemira/codes/utilities.py b/bluemira/codes/utilities.py
index 2fd10ef975..bc2d10854d 100644
--- a/bluemira/codes/utilities.py
+++ b/bluemira/codes/utilities.py
@@ -14,6 +14,7 @@
 import threading
 from collections.abc import Callable
 from enum import Enum
+from functools import wraps
 from types import ModuleType
 from typing import Any
 
@@ -96,6 +97,57 @@ def get_code_interface(module: str) -> ModuleType:
         return get_module(module)
 
 
+def is_code_available(code_module: str) -> bool:
+    """
+    Check if a code is available
+
+    Parameters
+    ----------
+    code_module:
+        The name of the code module to check.
+
+    Returns
+    -------
+    :
+        True if the code is available, False otherwise.
+    """
+    try:
+        get_module(code_module)
+    except ImportError:
+        return False
+    else:
+        return True
+
+
+def code_guard(code_module: str, add_message: str = ""):
+    """
+    Import guard for a code at runtime.
+
+    Parameters
+    ----------
+    code_module:
+        The name of the code module to check.
+
+    Raises
+    ------
+    ImportError
+        If the code is not available (installed).
+    """  # noqa: DOC201
+
+    def decorator(function):
+        @wraps(function)
+        def wrapper(*args, **kwargs):
+            if not is_code_available(code_module):
+                raise ImportError(
+                    f"{code_module} is not installed or available.\n{add_message}"
+                )
+            return function(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
 def create_mapping(
     in_mappings=None, out_mappings=None, io_mappings=None, none_mappings=None
 ) -> dict[str, Any]:
diff --git a/bluemira/geometry/overlap_checking.py b/bluemira/geometry/overlap_checking.py
new file mode 100644
index 0000000000..4f221c7776
--- /dev/null
+++ b/bluemira/geometry/overlap_checking.py
@@ -0,0 +1,327 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""Contains functions to efficiently check for overlaps between solids."""
+
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+import bluemira.codes.cgal_ext as cgal
+from bluemira.base.look_and_feel import bluemira_debug
+from bluemira.geometry.constants import D_TOLERANCE
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
+    from bluemira.geometry.bound_box import BoundingBox
+    from bluemira.geometry.solid import BluemiraSolid
+
+
+def two_set_mutually_exclusive(
+    min_a: np.ndarray[float],
+    max_a: np.ndarray[float],
+    min_b: np.ndarray[float],
+    max_b: np.ndarray[float],
+) -> np.ndarray:
+    """
+    Given TWO lists of bounds, (e.g. x-bounds, i.e. x-min and x-max for each cell),
+    find whether each cell is mutually exclusive (i.e. does NOT overlap) with other
+    cells. This forms a 2D exclusivity matrix.
+
+    Parameters
+    ----------
+    min_a:
+        lower bound for each cell in set A, a 1D array.
+    max_a:
+        upper bound for each cell in set A, a 1D array.
+    min_b:
+        lower bound for each cell in set B, a 1D array.
+    max_b:
+        upper bound for each cell in set B, a 1D array.
+
+
+    Returns
+    -------
+    :
+        A 2D exclusivity matrix showing True where they're NOT overlapping, False if
+        overlapping. The main-diagonal of this matrix can be ignored.
+
+    Note
+    ----
+    Must have the property that all(min_a<=max_a) and all(min_b<=max_b).
+    """
+    len_a = len(min_a)
+    len_b = len(min_b)
+    matrix_min_a = np.broadcast_to(min_a, (len_b, len_a)).T
+    matrix_max_a = np.broadcast_to(max_a, (len_b, len_a)).T
+    matrix_min_b = np.broadcast_to(min_b, (len_a, len_b))
+    matrix_max_b = np.broadcast_to(max_b, (len_a, len_b))
+    return np.logical_or(matrix_max_a < matrix_min_b, matrix_min_a > matrix_max_b)
+
+
+def check_two_sets_bb_non_interference(
+    set_a_3d_tensor: np.ndarray, set_b_3d_tensor: np.ndarray
+) -> np.ndarray:
+    """
+    Check which bounding box do not interfere/overlap with which other bounding box.
+
+    Parameters
+    ----------
+    set_a_3d_tensor:
+        An array of 2D arrays (each with shape = (3,2)), each row of the 2D array is
+        the x, y, z bounds (min, max) for that set A cell.
+    set_b_3d_tensor:
+        An array of 2D arrays (each with shape = (3,2)), each row of the 2D array is
+        the x, y, z bounds (min, max) for that set B cell.
+
+    Returns
+    -------
+    exclusivity_matrix:
+        A matrix of booleans showing whether the bounding boxes overlap.
+    """
+    x_bounds_a, y_bounds_a, z_bounds_a = set_a_3d_tensor.transpose([1, 2, 0])
+    x_bounds_b, y_bounds_b, z_bounds_b = set_b_3d_tensor.transpose([1, 2, 0])
+
+    return np.array([
+        two_set_mutually_exclusive(*x_bounds_a, *x_bounds_b),
+        two_set_mutually_exclusive(*y_bounds_a, *y_bounds_b),
+        two_set_mutually_exclusive(*z_bounds_a, *z_bounds_b),
+    ]).any(axis=0)
+
+
+def get_overlaps_asymmetric(exclusivity_matrix) -> np.ndarray:
+    """
+    Get the indices of the bounding boxes that are overlapping. The overlap matrix is the
+    element-wise negation of the exclusivity matrix. This function returns the 2-D
+    indices of non-zero elements.
+
+    Parameters
+    ----------
+    exclusivity_matrix:
+        The matrix denoting whether each bounding box overlap with other bounding boxes,
+        generated by :func:`~check_two_sets_bb_non_interference`.
+
+    Returns
+    -------
+    indices:
+        2D array of integers, each row is a pair of indices of i<j
+    """
+    i, j = np.where(~exclusivity_matrix)
+    return np.array([i, j]).T
+
+
+def is_mutually_exclusive(
+    min_: np.ndarray[float], max_: np.ndarray[float]
+) -> np.ndarray:
+    """
+    Given a list of bounds, (e.g. x-bounds, showing .xmin() and .xmax() for each cell),
+    find whether each cell is mutually exclusive (i.e. does NOT overlap) with other
+    cells. This forms a 2D exclusivity matrix.
+
+    Parameters
+    ----------
+    min_:
+        lower bound for each cell, a 1D array.
+    max_:
+        upper bound for each cell, a 1D array.
+
+    Returns
+    -------
+    :
+        A 2D exclusivity matrix showing True where they're NOT overlapping, False if
+        overlapping. The main-diagonal of this matrix can be ignored.
+
+    Note
+    ----
+    Must have the property that all(min_<=max_)==True.
+    """
+    len_ = len(min_)
+    matrix_min = np.broadcast_to(min_, (len_, len_)).T
+    matrix_max = np.broadcast_to(max_, (len_, len_)).T
+    return np.logical_or(matrix_max < min_, matrix_min > max_)
+
+
+# Brancheless implementation
+def check_bb_non_interference(tensor_3d: np.ndarray) -> np.ndarray:
+    """
+    Check which bounding box do not interfere/overlap with which other bounding box.
+
+    Parameters
+    ----------
+    tensor_3d:
+        An array of 2D arrays (each with shape = (3,2)), each row of the 2D array is
+        the x, y, z bounds (min, max) for that cell.
+
+    Returns
+    -------
+    exclusivity_matrix:
+        A matrix of booleans showing whether the bounding boxes overlap.
+    """
+    x_bounds, y_bounds, z_bounds = tensor_3d.transpose([1, 2, 0])
+
+    return np.array([
+        is_mutually_exclusive(*x_bounds),
+        is_mutually_exclusive(*y_bounds),
+        is_mutually_exclusive(*z_bounds),
+    ]).any(axis=0)
+
+
+def get_overlaps_arr(exclusivity_matrix) -> np.ndarray:
+    """
+    Get the indices of the bounding boxes that are overlapping. The overlap matrix is the
+    element-wise negation of the exclusivity matrix. This function returns the 2-D
+    indices of non-zero elements on the upper-right triangle of this matrix.
+
+    Parameters
+    ----------
+    exclusivity_matrix:
+        The matrix denoting whether each bounding box overlap with other bounding boxes,
+        generated by :func:`~check_bb_non_interference`.
+
+    Returns
+    -------
+    indices:
+        2D array of integers, each row is a pair of indices of i<j
+    """
+    i, j = np.where(~exclusivity_matrix)
+    # only return the upper-triangle part of the matrix.
+    duplicates = i >= j
+    i, j = i[~duplicates], j[~duplicates]
+    return np.array([i, j]).T
+
+
+def scale_points_from_centroid(points, scale_factor):
+    """
+    Scale a set of points from their centroid by a given scale factor.
+
+    Parameters
+    ----------
+    points : np.ndarray
+        An array of shape (n, 3) representing the x, y, z coordinates of the points.
+    scale_factor : float
+        The scale factor by which to scale the points.
+
+    Returns
+    -------
+    scaled_points : np.ndarray
+        An array of shape (n, 3) representing the scaled x, y, z
+        coordinates of the points.
+    """
+    # Calculate the centroid
+    centroid = np.mean(points, axis=0)
+
+    # Translate points to the origin
+    translated_points = points - centroid
+
+    # Scale the points
+    scaled_points = translated_points * scale_factor
+
+    # Translate points back to the original position
+    scaled_points += centroid
+
+    return scaled_points
+
+
+def find_approx_overlapping_pairs(
+    solids: Iterable[BluemiraSolid], *, use_cgal: bool = True
+):
+    """Finds the pairs of solids that are approximately overlapping.
+
+    This function uses bounding boxes to quickly eliminate non-overlapping pairs,
+    and then refines the results using a numpy (or CGAL, if installed) based apporach
+    to refind the number of overlapping pairs taking into the geometry,
+    in a confirmal manner.
+
+    Parameters
+    ----------
+    solids:
+        An iterable of BluemiraSolid objects to check for overlaps between.
+
+    use_cgal:
+        If True, use CGAL to check for overlaps. Otherwise, use numpy.
+        If CGAL is not available, this will default to numpy (even if True).
+
+    Returns
+    -------
+        A list of tuples of indices of overlapping pairs.
+
+    """
+
+    def to_bb_matrix(bb: BoundingBox):
+        return np.array([
+            [bb.x_min, bb.x_max],
+            [bb.y_min, bb.y_max],
+            [bb.z_min, bb.z_max],
+        ])
+
+    def bb_of_tri_coords(pt_a, pt_b, pt_c):
+        points = np.array([pt_a, pt_b, pt_c])
+        tri_bb = np.array([
+            [np.min(points[:, 0]), np.max(points[:, 0])],
+            [np.min(points[:, 1]), np.max(points[:, 1])],
+            [np.min(points[:, 2]), np.max(points[:, 2])],
+        ])
+        tri_bb[np.isclose(tri_bb, 0, atol=D_TOLERANCE)] = 0
+        return tri_bb
+
+    aabbs = []
+    approx_geometry = []
+
+    if use_cgal and not cgal.cgal_available():
+        use_cgal = False
+        bluemira_debug(
+            "CGAL is not available. Falling back to numpy based overlap checking."
+            "`pip install cgal` for a faster more accurate result."
+        )
+
+    for solid in solids:
+        aabbs.append(to_bb_matrix(solid.bounding_box))
+
+        tsl = solid._tessellate(1)
+        points = tsl[0]
+        tris = tsl[1]
+        if use_cgal:
+            # we scale the geometry by 1.1 to slightly over-approximate
+            # intersections later (need to error on the side of caution).
+            approx_geometry.append(cgal.tri_mesh_to_cgal_mesh(points, tris, scale=1.1))
+        else:
+            # we build bbs around each triangle
+            # which creates an approximation of the geometry.
+            # bbs can quickly be checked for intersection.
+            approx_geometry.append(
+                np.array([
+                    bb_of_tri_coords(
+                        points[tri[0]],
+                        points[tri[1]],
+                        points[tri[2]],
+                    )
+                    for tri in tris
+                ])
+            )
+
+    aabbs = np.array(aabbs)
+    ex_mat = check_bb_non_interference(aabbs)
+    overlap_idxs = get_overlaps_arr(ex_mat)
+
+    def refine_overlap(i, j):
+        geo_i = approx_geometry[i]
+        geo_j = approx_geometry[j]
+        itc = (
+            cgal.do_polys_collide(geo_i, geo_j)
+            if use_cgal
+            else np.any(check_two_sets_bb_non_interference(geo_i, geo_j))
+        )
+        return (i, j) if itc else None
+
+    with ThreadPoolExecutor() as executor:
+        refined_overlap_idxs = list(
+            filter(None, executor.map(lambda pair: refine_overlap(*pair), overlap_idxs))
+        )
+
+    return refined_overlap_idxs  # noqa: RET504
diff --git a/bluemira/geometry/tools.py b/bluemira/geometry/tools.py
index 86073d87cc..8380798192 100644
--- a/bluemira/geometry/tools.py
+++ b/bluemira/geometry/tools.py
@@ -1628,7 +1628,7 @@ def save_cad(
     shapes:
         shapes to save
     filename:
-        Full path filename of the STP assembly
+        Full path filename for the CAD file
     cad_format:
         file format to save as
     names:
diff --git a/conda/environment.yml b/conda/environment.yml
index 4028731df1..2f6d6848a7 100644
--- a/conda/environment.yml
+++ b/conda/environment.yml
@@ -32,8 +32,9 @@ dependencies:
   - pybind11-global==2.13.6
   - pytools==2025.1.2
   - vtk==9.3.1
+  - pythonocc-core
   - pip
   - pip:
       - -r ../requirements/uv/all.txt
       - -r ../requirements/conda.txt
-      - -e ../[openmc]
+      - -e ../[openmc,dagmc]
diff --git a/pyproject.toml b/pyproject.toml
index f0adc003b6..f7c39973e4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -88,6 +88,7 @@ openmc = ["openmc>=0.14.0", "openmc_data==2.2.12"]
 process = ["process==3.1.0"]
 polyscope = ["polyscope"]
 radiation = ["cherab"]
+dagmc = ["cgal>=6.0.1"]
 
 [build-system]
 requires = ["setuptools>=62", "setuptools_scm[toml]>=7.0"]
diff --git a/tests/base/test_reactor.py b/tests/base/test_reactor.py
index 3e88cf6f4a..7893bb30c5 100644
--- a/tests/base/test_reactor.py
+++ b/tests/base/test_reactor.py
@@ -77,6 +77,30 @@ def test_show_cad_displays_all_components(self, dim):
         call_arg = mock_show.call_args[0][0]
         assert all(isinstance(component, BluemiraGeo) for component in call_arg)
 
+    def test_show_3d_cad_displays_components_set_by_with_components(self):
+        with patch("bluemira.display.displayer.show_cad") as mock_show:
+            self.reactor.show_cad(
+                "xyz",
+                construction_params={
+                    "with_components": [self.reactor.plasma, self.reactor.vvts]
+                },
+            )
+
+        call_arg = mock_show.call_args[0][0]
+        assert len(call_arg) == 2
+
+    def test_show_3d_cad_displays_components_set_by_without_components(self):
+        with patch("bluemira.display.displayer.show_cad") as mock_show:
+            self.reactor.show_cad(
+                "xyz",
+                construction_params={"without_components": [self.reactor.vvts]},
+            )
+
+        call_arg = mock_show.call_args[0][0]
+        # would be len(call_arg) == 1,
+        # but because it's a single component, gets extracted
+        assert isinstance(call_arg, BluemiraGeo)
+
     @pytest.mark.parametrize("bad_dim", ["not_a_dim", 1, ["x"]])
     def test_ComponentError_given_invalid_plotting_dimension(self, bad_dim):
         with pytest.raises(ComponentError):
diff --git a/tests/codes/test_python_occ.py b/tests/codes/test_python_occ.py
new file mode 100644
index 0000000000..56a33c0803
--- /dev/null
+++ b/tests/codes/test_python_occ.py
@@ -0,0 +1,76 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+from copy import deepcopy
+from unittest import mock
+
+import pytest
+
+from bluemira.codes import python_occ
+from bluemira.geometry.face import BluemiraFace
+from bluemira.geometry.tools import extrude_shape, make_polygon
+
+
+class TestImprintSolids:
+    @pytest.mark.parametrize("use_cgal", [True, False])
+    @pytest.mark.parametrize(
+        ("translate_x", "translate_y", "t_imprints", "a_faces", "bc_faces"),
+        [
+            (0.6, 0.6, 2, 8, 7),
+            (0.3, 0.5, 3, 9, 8),
+            (2.5, 2.5, 0, 6, 6),
+        ],
+    )
+    def test_imprint_solids(
+        self,
+        use_cgal: bool,  # noqa: FBT001
+        translate_x: float,
+        translate_y: float,
+        t_imprints: int,
+        a_faces: int,
+        bc_faces: int,
+    ):
+        box_a = BluemiraFace(
+            make_polygon(
+                [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 0]], closed=True
+            )
+        )
+        box_a = extrude_shape(box_a, [0, 0, 1])
+        box_b = deepcopy(box_a)
+        box_b.translate([translate_x, translate_y, 1])
+        box_c = deepcopy(box_a)
+        box_c.translate([-translate_x, -translate_y, 1])
+
+        pre_imps = [box_a, box_b, box_c]
+        imp_result = python_occ.imprint_solids(pre_imps, use_cgal=use_cgal)
+
+        imps = imp_result.imprintables
+        imp_solids = imp_result.solids
+
+        assert imp_result.total_imprints == t_imprints
+        assert len(imp_solids) == 3
+        assert len(imp_solids[0].faces) == a_faces
+        assert imps[0]._has_imprinted if t_imprints > 0 else not imps[0]._has_imprinted
+        assert len(imp_solids[1].faces) == bc_faces
+        assert imps[1]._has_imprinted if t_imprints > 0 else not imps[0]._has_imprinted
+        assert len(imp_solids[2].faces) == bc_faces
+        assert imps[2]._has_imprinted if t_imprints > 0 else not imps[0]._has_imprinted
+
+        # Rerun the tests mocking cgal as not available
+        if use_cgal:
+            with mock.patch(
+                "bluemira.geometry.overlap_checking.cgal.cgal_available",
+                return_value=False,
+            ):
+                imp_result = python_occ.imprint_solids(pre_imps, use_cgal=use_cgal)
+
+        assert imp_result.total_imprints == t_imprints
+        assert len(imp_solids) == 3
+        assert len(imp_solids[0].faces) == a_faces
+        assert imps[0]._has_imprinted if t_imprints > 0 else not imps[0]._has_imprinted
+        assert len(imp_solids[1].faces) == bc_faces
+        assert imps[1]._has_imprinted if t_imprints > 0 else not imps[0]._has_imprinted
+        assert len(imp_solids[2].faces) == bc_faces
+        assert imps[2]._has_imprinted if t_imprints > 0 else not imps[0]._has_imprinted


Reviews:
user: login: je-cook
body: As we've discussed I'm a bit uncomfortable putting low level cad api stuff in the user facing geometry module. I am wonder about whether some of this should live in codes for that reason.
That would possibly then reduce the higher level developer facing code here (ie in geometry) quite substantially.
Is there anything a user/developer will interact with other than the `imprint_solids` function for imprint_solids.py?
Same conversation with the CGAL stuff I feel like that should just be exposed as an interface from codes with `find_approx_overlapping_pairs` and possibly a few others being exposed at the geometry level.

Could we also have a few more individual tests, it will help with working out what is going on in future (especially with overlap checking). 

The code itself looks fine (as much as I can join together in my head atm), I think its just a question of where we put it.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:30:36Z
user: login: je-cook
body: Yeah just the one comment. I'll approve after we've chatted about environment dependency stuff
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:03:35Z
user: login: je-cook
body: Few more but much less major
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T12:54:03Z
user: login: oliverfunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T12:55:52Z
user: login: je-cook
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T09:58:10Z

Review comments:
diff_hunk: @@ -1640,10 +1640,23 @@ def save_cad(
         shapes = list(shapes) if isinstance(shapes, Iterable) else [shapes]
     if names is not None and not isinstance(names, list):
         names = [names]
+    filename = Path(filename).as_posix()
 
+    if cad_format == "dagmc":
+        # for now
+        raise NotImplementedError(
+            "DAGMC export is not yet implemented in Bluemira. "
+            "Please use the FreeCAD GUI to export to DAGMC."
path: bluemira/geometry/tools.py
user: login: je-cook
body: Can this be done or should this really say
```suggestion
            "This will be enabled soon."
```
created_at: 2025-05-06T14:13:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,311 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+Imprinting solids together.
+"""
+
+from collections.abc import Iterable
+from typing import Any
+
+from rich.progress import track
+
+from bluemira.base.look_and_feel import bluemira_print
+from bluemira.geometry.overlap_checking import find_approx_overlapping_pairs
+from bluemira.geometry.solid import BluemiraSolid
+
+try:
+    from OCC.Core.BOPAlgo import BOPAlgo_MakeConnected
path: bluemira/geometry/imprint_solids.py
user: login: je-cook
body: Maybe a note about the use of pythonocc here
```suggestion
    # TODO @oliverfunk: pythonocc exposes functionality that cant be accessed 
    # through the freecad API. Should it become available this 
    # should be moved to freecadapi
    from OCC.Core.BOPAlgo import BOPAlgo_MakeConnected
```
created_at: 2025-05-06T14:17:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+"""
+API guard for the OCC package.
+"""
+
+try:
+    import OCC  # noqa: F401
+
+    occ_available = True
+except ImportError:
+    occ_available = False
+
+
+def guard_occ_available(f):
path: bluemira/codes/python_occ/_guard.py
user: login: je-cook
body: With both this and the cgal one it could be generic and use a decorator that has an argument see https://github.com/Fusion-Power-Plant-Framework/bluemira/blob/5d55ea28b1203960a2548b7f7866012f6bc5125c/bluemira/utilities/tools.py#L1141 for an example
created_at: 2025-05-12T09:59:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+API guard for the CGAL package.
+"""
+
+from bluemira.codes.utilities import code_guard, is_code_available
+
+
+def cgal_available():
+    is_code_available("CGAL")
path: bluemira/codes/cgal_ext/_guard.py
user: login: je-cook
body: this does nothing atm is there meant to be a return? Unless it actually puts the import into sys.modules (I dont think it does?)
created_at: 2025-05-14T12:48:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+from copy import deepcopy
+
+import pytest
+
+from bluemira.codes import python_occ
+from bluemira.geometry.face import BluemiraFace
+from bluemira.geometry.tools import extrude_shape, make_polygon
+
+
+class TestImprintSolids:
+    @pytest.mark.parametrize(
path: tests/codes/test_python_occ.py
user: login: je-cook
body: will probably need to add a skip if import not available, we do it for a few other tests with openmc polyscope and process

https://docs.pytest.org/en/8.1.x/how-to/skipping.html#skipping-on-a-missing-import-dependency
created_at: 2025-05-14T12:53:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2021-present M. Coleman, J. Cook, F. Franza
+# SPDX-FileCopyrightText: 2021-present I.A. Maione, S. McIntosh
+# SPDX-FileCopyrightText: 2021-present J. Morris, D. Short
+#
+# SPDX-License-Identifier: LGPL-2.1-or-later
+"""
+API guard for the CGAL package.
+"""
+
+from bluemira.codes.utilities import code_guard, is_code_available
+
+
+def cgal_available():
+    is_code_available("CGAL")
path: bluemira/codes/cgal_ext/_guard.py
user: login: oliverfunk
body: Oops yeah!
created_at: 2025-05-14T12:55:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-01T10:23:44Z
body: ## [Codecov](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) Report
:x: Patch coverage is `90.90909%` with `27 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 77.15%. Comparing base ([`5d55ea2`](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/commit/5d55ea28b1203960a2548b7f7866012f6bc5125c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework)) to head ([`8a73cb2`](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/commit/8a73cb2985efc5f211028e79a7b466082f9a7e11?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework)).
:warning: Report is 49 commits behind head on develop.

| [Files with missing lines](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) | Patch % | Lines |
|---|---|---|
| [bluemira/codes/python\_occ/imprint\_solids.py](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&filepath=bluemira%2Fcodes%2Fpython_occ%2Fimprint_solids.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework#diff-Ymx1ZW1pcmEvY29kZXMvcHl0aG9uX29jYy9pbXByaW50X3NvbGlkcy5weQ==) | 91.50% | [9 Missing :warning: ](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) |
| [bluemira/geometry/overlap\_checking.py](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&filepath=bluemira%2Fgeometry%2Foverlap_checking.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework#diff-Ymx1ZW1pcmEvZ2VvbWV0cnkvb3ZlcmxhcF9jaGVja2luZy5weQ==) | 90.41% | [7 Missing :warning: ](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) |
| [bluemira/codes/python\_occ/imprintable\_solid.py](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&filepath=bluemira%2Fcodes%2Fpython_occ%2Fimprintable_solid.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework#diff-Ymx1ZW1pcmEvY29kZXMvcHl0aG9uX29jYy9pbXByaW50YWJsZV9zb2xpZC5weQ==) | 88.63% | [5 Missing :warning: ](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) |
| [bluemira/codes/utilities.py](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&filepath=bluemira%2Fcodes%2Futilities.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework#diff-Ymx1ZW1pcmEvY29kZXMvdXRpbGl0aWVzLnB5) | 81.25% | [3 Missing :warning: ](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) |
| [bluemira/codes/cgal\_ext/collision\_detection.py](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&filepath=bluemira%2Fcodes%2Fcgal_ext%2Fcollision_detection.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework#diff-Ymx1ZW1pcmEvY29kZXMvY2dhbF9leHQvY29sbGlzaW9uX2RldGVjdGlvbi5weQ==) | 94.59% | [2 Missing :warning: ](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) |
| [bluemira/base/reactor.py](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&filepath=bluemira%2Fbase%2Freactor.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework#diff-Ymx1ZW1pcmEvYmFzZS9yZWFjdG9yLnB5) | 85.71% | [1 Missing :warning: ](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework) |

<details><summary>Additional details and impacted files</summary>



```diff
@@             Coverage Diff             @@
##           develop    #3905      +/-   ##
===========================================
+ Coverage    77.01%   77.15%   +0.14%     
===========================================
  Files          232      240       +8     
  Lines        28613    28909     +296     
===========================================
+ Hits         22037    22306     +269     
- Misses        6576     6603      +27     
```
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Fusion-Power-Plant-Framework/bluemira/pull/3905?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fusion-Power-Plant-Framework).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-01T10:38:22Z
body: ### ⚠️ Warning Report

Found 3 warnings.

<details>

  <summary>All warnings (3)</summary>

#### On config

- `<frozen importlib._bootstrap>:241: DeprecationWarning: builtin type swigvarlink has no __module__ attribute`
- `<frozen importlib._bootstrap>:241: DeprecationWarning: builtin type SwigPyObject has no __module__ attribute`
- `<frozen importlib._bootstrap>:241: DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute`

</details>
author_association: CONTRIBUTOR
user: login: oliverfunk
created_at: 2025-05-05T15:23:24Z
body: There's an interesting tool I tried called Haystack to help review this PR, check it out here:

https://alb15.haystackserver.com/Fusion-Power-Plant-Framework/bluemira/3905/?folder=/home/coder/repo
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-15T09:44:22Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Fusion-Power-Plant-Framework_bluemira&pullRequest=3905) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4 New issues](https://sonarcloud.io/project/issues?id=Fusion-Power-Plant-Framework_bluemira&pullRequest=3905&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Fusion-Power-Plant-Framework_bluemira&pullRequest=3905&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Fusion-Power-Plant-Framework_bluemira&pullRequest=3905&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Fusion-Power-Plant-Framework_bluemira&pullRequest=3905&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Fusion-Power-Plant-Framework_bluemira&pullRequest=3905&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Fusion-Power-Plant-Framework_bluemira&pullRequest=3905)


author_association: NONE