diff --git a/bluemira/base/tools.py b/bluemira/base/tools.py index 13de20b8ae..398f64694f 100644 --- a/bluemira/base/tools.py +++ b/bluemira/base/tools.py @@ -30,8 +30,8 @@ ) from bluemira.display.displayer import ComponentDisplayer from bluemira.display.plotter import ComponentPlotter +from bluemira.geometry.compound import BluemiraCompound from bluemira.geometry.tools import ( - make_compound, revolve_shape, save_cad, serialise_shape, @@ -42,7 +42,6 @@ import bluemira.codes._freecadapi as cadapi from bluemira.base.reactor import ComponentManager - from bluemira.geometry.compound import BluemiraCompound _T = TypeVar("_T") @@ -121,7 +120,6 @@ def wrapper(*args, **kwargs): def create_compound_from_component(comp: Component) -> BluemiraCompound: """ Creates a BluemiraCompound from the children's shapes of a component. - This BluemiraCompound has it's constituents set to the shapes of comp. Parameters ---------- @@ -135,7 +133,7 @@ def create_compound_from_component(comp: Component) -> BluemiraCompound: """ shapes = get_properties_from_components(comp, ("shape")) - return make_compound(shapes, comp.name, set_constituents=True) + return BluemiraCompound(label=comp.name, boundary=shapes) def circular_pattern_xyz_components( diff --git a/bluemira/geometry/base.py b/bluemira/geometry/base.py index 960c1af509..9fc2be68c0 100644 --- a/bluemira/geometry/base.py +++ b/bluemira/geometry/base.py @@ -13,7 +13,6 @@ import copy import enum from abc import ABC, abstractmethod -from collections.abc import Iterable from typing import TYPE_CHECKING, TypeVar from bluemira.codes import _freecadapi as cadapi @@ -27,114 +26,6 @@ from bluemira.geometry.placement import BluemiraPlacement -class BluemiraShape(meshing.Meshable): - """ - Abstract base class representing a CAD shape defining it's creation. - """ - - def __init__(self, shape: cadapi.apiShape | None): - self._set_shape(shape) - super().__init__() - - @property - def shape(self) -> cadapi.apiShape | None: - """ - The CAD shape of the object. - """ - return self._shape - - def _set_shape(self, shape: cadapi.apiShape | None) -> None: - """ - Set the shape of the object. - """ - self._shape = shape - - @property - def length(self) -> float: - """ - The shape's length. - """ - return cadapi.length(self.shape) - - @property - def area(self) -> float: - """ - The shape's area. - """ - return cadapi.area(self.shape) - - @property - def volume(self) -> float: - """ - The shape's volume. - """ - return cadapi.volume(self.shape) - - @property - def center_of_mass(self) -> np.ndarray: - """ - The shape's center of mass. - """ - return cadapi.center_of_mass(self.shape) - - @property - def bounding_box(self) -> BoundingBox: - """ - The bounding box of the shape. - - Notes - ----- - If your shape is complicated, this has the potential to not be very accurate. - Consider using :meth:`~bluemira.geometry.base.get_optimal_bounding_box`. - """ - x_min, y_min, z_min, x_max, y_max, z_max = cadapi.bounding_box(self.shape) - return BoundingBox(x_min, x_max, y_min, y_max, z_min, z_max) - - def is_null(self) -> bool: - """ - Check if the shape is null. - - Returns - ------- - : - A boolean for if the shape is null. - """ - return cadapi.is_null(self.shape) - - def is_closed(self) -> bool: - """ - Check if the shape is closed. - - Returns - ------- - : - A boolean for if the shape is closed. - """ - return cadapi.is_closed(self.shape) - - def is_valid(self) -> bool: - """ - Check if the shape is valid. - - Returns - ------- - : - A boolean for if the shape is valid. - """ - return cadapi.is_valid(self.shape) - - def is_same(self, obj: BluemiraGeo) -> bool: - """ - Check if obj has the same shape as self - - Returns - ------- - : - A boolean for if the obj is the same shape as self. - """ - return cadapi.is_same(self.shape, obj.shape) - - class _Orientation(enum.Enum): FORWARD = "Forward" REVERSED = "Reversed" @@ -143,7 +34,7 @@ class _Orientation(enum.Enum): BluemiraGeoT = TypeVar("BluemiraGeoT", bound="BluemiraGeo") -class BluemiraGeo(ABC, BluemiraShape): +class BluemiraGeo(ABC, meshing.Meshable): """ Abstract base class for geometry. @@ -163,43 +54,39 @@ def __init__( label: str = "", boundary_classes: list[type[BluemiraGeoT]] | None = None, ): + super().__init__() self._boundary_classes = boundary_classes or [] self.__orientation = _Orientation.FORWARD self.label = label - self._set_boundary(boundary, replace_shape=False) - super().__init__(self._create_shape() if self._boundary else None) - - @abstractmethod - def _create_shape(self) -> cadapi.apiShape: - """ - Returns the created CAD shape - """ - pass + self._set_boundary(boundary) @property - def boundary(self) -> tuple: - """ - The shape's boundary. + def _orientation(self): + return self.__orientation + + @_orientation.setter + def _orientation(self, value): + self.__orientation = _Orientation(value) + + def _check_reverse(self, obj): + if self._orientation != _Orientation(obj.Orientation): + obj.reverse() + self._orientation = _Orientation(obj.Orientation) + return obj + + @staticmethod + def _converter(func): """ - return tuple(self._boundary) + Function used in __getattr__ to modify the added functions. - def _set_boundary( - self, - boundary: BluemiraGeoT | list[BluemiraGeoT], - *, - replace_shape: bool = True, - ): - self._boundary = self._check_boundary(boundary) - if replace_shape: - if self._boundary is None: - self._set_shape(None) - else: - self._set_shape(self._create_shape()) + Returns + ------- + : + Function used in __getattr__ to modify the added functions. + """ + return func - def _check_boundary( - self, - boundary: BluemiraGeoT | list[BluemiraGeoT], - ) -> list[BluemiraGeoT] | None: + def _check_boundary(self, objs): """ Check if objects objs can be used as boundaries. @@ -215,49 +102,97 @@ def _check_boundary( : The objects that can be used as boundaries. """ - if boundary is None: - return boundary + if objs is None: + return objs - if not isinstance(boundary, Iterable): - boundary = [boundary] + if not hasattr(objs, "__len__"): + objs = [objs] check = False for c in self._boundary_classes: # # in case of obj = [], this check returns True instead of False # check = check or (all(isinstance(o, c) for o in objs)) - for o in boundary: + for o in objs: check = check or isinstance(o, c) if check: - return boundary + return objs raise TypeError( f"Only {self._boundary_classes} objects can be used for {self.__class__}" ) @property - def _orientation(self): - return self.__orientation + def boundary(self) -> tuple: + """ + The shape's boundary. + """ + return tuple(self._boundary) - @_orientation.setter - def _orientation(self, value): - self.__orientation = _Orientation(value) + def _set_boundary(self, objs, *, replace_shape: bool = True): + self._boundary = self._check_boundary(objs) + if replace_shape: + if self._boundary is None: + self._set_shape(None) + else: + self._set_shape(self._create_shape()) - def _check_reverse(self, obj): - if self._orientation != _Orientation(obj.Orientation): - obj.reverse() - self._orientation = _Orientation(obj.Orientation) - return obj + @abstractmethod + def _create_shape(self): + """ + Create the shape from the boundary + """ + # Note: this is the "hidden" connection with primitive shapes - @staticmethod - def _converter(func): + @property + def shape(self) -> cadapi.apiShape: """ - Function used in __getattr__ to modify the added functions. + The primitive shape of the object. + """ + # Note: this is the "hidden" connection with primitive shapes + return self._shape - Returns - ------- - : - Function used in __getattr__ to modify the added functions. + def _set_shape(self, value: cadapi.apiShape): + self._shape = value + + @property + def length(self) -> float: """ - return func + The shape's length. + """ + return cadapi.length(self.shape) + + @property + def area(self) -> float: + """ + The shape's area. + """ + return cadapi.area(self.shape) + + @property + def volume(self) -> float: + """ + The shape's volume. + """ + return cadapi.volume(self.shape) + + @property + def center_of_mass(self) -> np.ndarray: + """ + The shape's center of mass. + """ + return cadapi.center_of_mass(self.shape) + + @property + def bounding_box(self) -> BoundingBox: + """ + The bounding box of the shape. + + Notes + ----- + If your shape is complicated, this has the potential to not be very accurate. + Consider using :meth:`~bluemira.geometry.base.get_optimal_bounding_box`. + """ + x_min, y_min, z_min, x_max, y_max, z_max = cadapi.bounding_box(self.shape) + return BoundingBox(x_min, x_max, y_min, y_max, z_min, z_max) def get_optimal_bounding_box(self, tolerance: float = 1.0) -> BoundingBox: """ @@ -279,71 +214,49 @@ def get_optimal_bounding_box(self, tolerance: float = 1.0) -> BoundingBox: auto_copy._tessellate(tolerance) return auto_copy.bounding_box - def __repr__(self) -> str: # noqa: D105 - return ( - f"([{type(self).__name__}] = Label: {self.label}, " - f"length: {self.length}, " - f"area: {self.area}, " - f"volume: {self.volume})" - ) - - def __deepcopy__(self, memo): - """Deepcopy for BluemiraGeo. - - FreeCAD shapes cannot be deepcopied on versions >=0.21 + def is_null(self) -> bool: + """ + Check if the shape is null. Returns ------- : - A deepcopy of the BluemiraGeo. + A boolean for if the shape is null. """ - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - for k, v in self.__dict__.items(): - if k not in {"_shape", "_boundary"}: - setattr( - result, - k, - copy.deepcopy(v, memo), - ) + return cadapi.is_null(self.shape) - result._shape = self._shape.copy() - result._boundary = [n.copy() for n in self._boundary] + def is_closed(self) -> bool: + """ + Check if the shape is closed. - return result + Returns + ------- + : + A boolean for if the shape is closed. + """ + return cadapi.is_closed(self.shape) - def copy(self, label: str | None = None) -> BluemiraGeo: + def is_valid(self) -> bool: """ - Make a copy of the BluemiraGeo. + Check if the shape is valid. Returns ------- : - A copy of the BluemiraGeo. + A boolean for if the shape is valid. """ - geo_copy = copy.copy(self) - if label is not None: - geo_copy.label = label - else: - geo_copy.label = self.label - return geo_copy + return cadapi.is_valid(self.shape) - def deepcopy(self, label: str | None = None) -> BluemiraGeo: + def is_same(self, obj: BluemiraGeo) -> bool: """ - Make a deepcopy of the BluemiraGeo. + Check if obj has the same shape as self Returns ------- : - A deepcopy of the BluemiraGeo. + A boolean for if the obj is the same shape as self. """ - geo_copy = copy.deepcopy(self) - if label is not None: - geo_copy.label = label - else: - geo_copy.label = self.label - return geo_copy + return cadapi.is_same(self.shape, obj.shape) def search(self, label: str) -> list[BluemiraGeo]: """ @@ -472,6 +385,72 @@ def change_placement(self, placement: BluemiraPlacement) -> None: cadapi.change_placement(o, placement._shape) cadapi.change_placement(self.shape, placement._shape) + def __repr__(self) -> str: # noqa: D105 + return ( + f"([{type(self).__name__}] = Label: {self.label}, " + f"length: {self.length}, " + f"area: {self.area}, " + f"volume: {self.volume})" + ) + + def __deepcopy__(self, memo): + """Deepcopy for BluemiraGeo. + + FreeCAD shapes cannot be deepcopied on versions >=0.21 + + Returns + ------- + : + A deepcopy of the BluemiraGeo. + """ + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in self.__dict__.items(): + if k not in {"_shape", "_boundary"}: + setattr( + result, + k, + copy.deepcopy(v, memo), + ) + + result._shape = self._shape.copy() + result._boundary = [n.copy() for n in self._boundary] + + return result + + def copy(self, label: str | None = None) -> BluemiraGeo: + """ + Make a copy of the BluemiraGeo. + + Returns + ------- + : + A copy of the BluemiraGeo. + """ + geo_copy = copy.copy(self) + if label is not None: + geo_copy.label = label + else: + geo_copy.label = self.label + return geo_copy + + def deepcopy(self, label: str | None = None) -> BluemiraGeo: + """ + Make a deepcopy of the BluemiraGeo. + + Returns + ------- + : + A deepcopy of the BluemiraGeo. + """ + geo_copy = copy.deepcopy(self) + if label is not None: + geo_copy.label = label + else: + geo_copy.label = self.label + return geo_copy + @property @abstractmethod def vertexes(self) -> Coordinates: diff --git a/bluemira/geometry/compound.py b/bluemira/geometry/compound.py index f5b94957d3..f99b6cd808 100644 --- a/bluemira/geometry/compound.py +++ b/bluemira/geometry/compound.py @@ -15,10 +15,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING - import bluemira.codes._freecadapi as cadapi -from bluemira.geometry.base import BluemiraShape +from bluemira.geometry.base import BluemiraGeo from bluemira.geometry.coordinates import Coordinates from bluemira.geometry.error import GeometryError from bluemira.geometry.face import BluemiraFace @@ -26,11 +24,8 @@ from bluemira.geometry.solid import BluemiraSolid from bluemira.geometry.wire import BluemiraWire -if TYPE_CHECKING: - from collections.abc import Iterable - -class BluemiraCompound(BluemiraShape): +class BluemiraCompound(BluemiraGeo): """ Bluemira Compound class. @@ -44,23 +39,28 @@ class BluemiraCompound(BluemiraShape): def __init__( self, - compound_obj: cadapi.apiCompound, + boundary: list[BluemiraGeo], label: str = "", *, - constituents: Iterable[BluemiraShape] | None = None, + compound_obj: cadapi.apiCompound | None = None, ): - self.label = label - self._constituents = constituents - super().__init__(compound_obj) + boundary_classes = [BluemiraGeo] + self._compound_obj = compound_obj + super().__init__(boundary, label, boundary_classes) + + def _create_shape(self) -> cadapi.apiCompound: + """ + Returns + ------- + apiCompound: + Shape of the object as a single compound. + """ + if self._compound_obj: + return self._compound_obj + return cadapi.apiCompound([s.shape for s in self.boundary]) @classmethod - def _create( - cls, - obj: cadapi.apiCompound, - label="", - *, - constituents: Iterable[BluemiraShape] | None = None, - ) -> BluemiraCompound: + def _create(cls, obj: cadapi.apiCompound, label="") -> BluemiraCompound: if not isinstance(obj, cadapi.apiCompound): raise TypeError( f"Only apiCompound objects can be used to create a {cls} instance" @@ -68,7 +68,28 @@ def _create( if not obj.isValid(): raise GeometryError(f"Compound {obj} is not valid.") - return cls(obj, label, constituents=constituents) + topo_compound_shapes = [] + if cadapi.solids(obj): + topo_compound_shapes = [ + BluemiraSolid._create(solid) for solid in cadapi.solids(obj) + ] + elif cadapi.shells(obj): + topo_compound_shapes = [ + BluemiraShell._create(shell) for shell in cadapi.shells(obj) + ] + elif cadapi.faces(obj): + topo_compound_shapes = [ + BluemiraFace._create(face) for face in cadapi.faces(obj) + ] + elif cadapi.wires(obj): + topo_compound_shapes = [BluemiraWire(wire) for wire in cadapi.wires(obj)] + else: + topo_compound_shapes = [ + BluemiraWire(wire) + for wire in [cadapi.apiWire(o) for o in cadapi.edges(obj)] + ] + + return cls(topo_compound_shapes, label=label, compound_obj=obj) @property def vertexes(self) -> Coordinates: @@ -111,12 +132,3 @@ def solids(self) -> tuple[BluemiraSolid, ...]: The solids of the compound. """ return tuple(BluemiraSolid._create(o) for o in cadapi.solids(self.shape)) - - @property - def constituents(self) -> tuple[BluemiraShape, ...]: - """ - The constituents of the compound. - """ - if self._constituents: - return tuple(self._constituents) - return self.solids + self.shells + self.faces + (self.wires or self.edges) diff --git a/tests/base/test_reactor.py b/tests/base/test_reactor.py index 7e8c0cf30b..06918bdd48 100644 --- a/tests/base/test_reactor.py +++ b/tests/base/test_reactor.py @@ -65,7 +65,8 @@ def test_show_cad_displays_all_components(self, dim): with patch("bluemira.display.displayer.show_cad") as mock_show: self.reactor.show_cad(dim) - assert len(mock_show.call_args[0][0]) == 1 + call_arg = mock_show.call_args[0][0] + 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):