diff --git a/CHANGELOG.md b/CHANGELOG.md index 2208a9a3..e43a6dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added a base `BlockModel`. +* Added reference to model `Element.model` to `Element`. +* Added `Element.modelgeometry` as the cached geometry of an element in model coordinates, taking into account the modifying effect of interactions with other elements. +* Added `Element.modeltransformation` as the cached transformation from element to model coordinates. +* Added `Element.compute_elementgeometry()`. +* Added `Element.compute_modelgeometry()` to replace `Element.compute_geometry()`. +* Added `Element.compute_modeltransformation()` to replace `Element.compute_worldtransformation()`. +* Added `Element.is_dirty`. + ### Changed +* Changed `Element.graph_node` to `Element.graphnode`. +* Changed `Element.tree_node` to `Element.treenode`. +* Changed `blockmodel_interfaces` to use the bestfit frame shared by two aligned interfaces instead of the frame of first face of the pair. + ### Removed +* Removed model reference `ElementTree.model` from `ElementTree`. +* Removed `InterfaceElement` from elements. + ## [0.4.5] 2024-12-11 @@ -35,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Removed `compas_model.models.groupnode.GroupNode`. + ## [0.4.4] 2024-06-13 ### Added diff --git a/README.md b/README.md index 3f18ab3b..764ed71e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ pip install -e ".[dev]" ## Documentation For further "getting started" instructions, a tutorial, examples, and an API reference, -please check out the online documentation here: [COMPAS DR docs](https://blockresearchgroup.github.io/compas_model) +please check out the online documentation here: [COMPAS MODEL docs](https://blockresearchgroup.github.io/compas_model) ## Issue Tracker diff --git a/docs/_static/PLACEHOLDER b/docs/_static/PLACEHOLDER deleted file mode 100644 index f611256e..00000000 --- a/docs/_static/PLACEHOLDER +++ /dev/null @@ -1 +0,0 @@ -# container for static files, e.g. logo, banner images, javascript, stylesheets, ... diff --git a/docs/api/compas_model.interactions.rst b/docs/api/compas_model.interactions.rst index 19b03170..55158c80 100644 --- a/docs/api/compas_model.interactions.rst +++ b/docs/api/compas_model.interactions.rst @@ -4,6 +4,16 @@ compas_model.interactions .. currentmodule:: compas_model.interactions +This module provides classes for defining the type of interaction that exists between two elements. +The interaction type could determine, for example, how forces are transferred from one element to the other. +The interaction type could also determine whether an interaction is permanent or temporary; +for example, for designing construction sequences. +The different types of interactions will have to be interpreted by the context in which the model is used. + +Interactions do not define the geometry of a joint or interface, but rather how the elements are connected. +In the case of a wood joint, for example, an interaction could define whether the joinery is dry, glued, or mechanical, +and what the properties of this connection are. + Classes ======= diff --git a/scripts/test_blockmodel_arch.py b/scripts/test_blockmodel_arch.py index af2c9d70..b47171eb 100644 --- a/scripts/test_blockmodel_arch.py +++ b/scripts/test_blockmodel_arch.py @@ -3,18 +3,19 @@ import compas from compas.colors import Color from compas_assembly.geometry import Arch -from compas_model.algorithms import blockmodel_interfaces +from compas_viewer import Viewer + +from compas_model.algorithms import model_interfaces from compas_model.analysis import cra_penalty_solve from compas_model.elements import BlockElement from compas_model.interactions import ContactInterface from compas_model.models import Model -from compas_viewer import Viewer # ============================================================================= # Block model # ============================================================================= -template = Arch(rise=3, span=10, thickness=0.2, depth=0.5, n=30) +template = Arch(rise=3, span=10, thickness=0.2, depth=0.5, n=200) model = Model() @@ -25,13 +26,13 @@ # Interfaces # ============================================================================= -blockmodel_interfaces(model, amin=0.01) +model_interfaces(model, amin=0.01) # ============================================================================= # Equilibrium # ============================================================================= -elements: list[BlockElement] = sorted(model.elements(), key=lambda e: e.geometry.centroid().z)[:2] +elements: list[BlockElement] = sorted(model.elements(), key=lambda e: e.modelgeometry.centroid.z)[:2] for element in elements: element.is_support = True @@ -61,7 +62,8 @@ color = Color(0.8, 0.8, 0.8) show_faces = False - viewer.scene.add(element.geometry, show_points=False, show_faces=show_faces, facecolor=color) + viewer.scene.add(element.modelgeometry, show_points=False, show_faces=show_faces, facecolor=color) + for interaction in model.interactions(): interaction: ContactInterface diff --git a/src/compas_model/algorithms/__init__.py b/src/compas_model/algorithms/__init__.py index b0965aa2..91509f80 100644 --- a/src/compas_model/algorithms/__init__.py +++ b/src/compas_model/algorithms/__init__.py @@ -1,9 +1,11 @@ from .collisions import is_aabb_aabb_collision from .collisions import is_box_box_collision from .collisions import is_face_to_face_collision -from .collisions import get_collision_pairs -from .interfaces import blockmodel_interfaces +from .collisions import get_collision_pairs # rename to model_collisions +from .interfaces import model_interfaces +from .intersections import model_intersections +from .overlaps import model_overlaps __all__ = [ @@ -11,5 +13,7 @@ "is_box_box_collision", "is_face_to_face_collision", "get_collision_pairs", - "blockmodel_interfaces", + "model_interfaces", + "model_intersections", + "model_overlaps", ] diff --git a/src/compas_model/algorithms/interfaces.py b/src/compas_model/algorithms/interfaces.py index a2998090..44110772 100644 --- a/src/compas_model/algorithms/interfaces.py +++ b/src/compas_model/algorithms/interfaces.py @@ -6,14 +6,16 @@ from compas.geometry import Plane from compas.geometry import Polygon from compas.geometry import Transformation +from compas.geometry import Vector +from compas.geometry import bestfit_frame_numpy from compas.geometry import centroid_polygon from compas.geometry import is_colinear from compas.geometry import is_coplanar +from compas.geometry import is_parallel_vector_vector from compas.geometry import transform_points from compas.itertools import window from shapely.geometry import Polygon as ShapelyPolygon -# from compas_model.elements import BlockElement from compas_model.elements import BlockGeometry from compas_model.interactions import ContactInterface from compas_model.models import Model @@ -21,7 +23,15 @@ from .nnbrs import find_nearest_neighbours -def blockmodel_interfaces( +def invert(self): + self._yaxis = self._yaxis * -1 + self._zaxis = self._zaxis * -1 + + +Frame.invert = invert + + +def model_interfaces( model: Model, nmax: int = 10, tmax: float = 1e-6, @@ -49,11 +59,11 @@ def blockmodel_interfaces( node_index = {node: index for index, node in enumerate(model.graph.nodes())} index_node = {index: node for index, node in enumerate(model.graph.nodes())} - blocks: List[BlockGeometry] = [model.graph.node_element(node).geometry for node in model.graph.nodes()] + blocks: List[BlockGeometry] = [model.graph.node_element(node).modelgeometry for node in model.graph.nodes()] nmax = min(nmax, len(blocks)) - block_cloud = [block.centroid() for block in blocks] + block_cloud = [block.centroid for block in blocks] block_nnbrs = find_nearest_neighbours(block_cloud, nmax, dims=nnbrs_dims) model.graph.edge = {node: {} for node in model.graph.nodes()} @@ -86,8 +96,8 @@ def blockmodel_interfaces( def mesh_mesh_interfaces( - a: BlockGeometry, - b: BlockGeometry, + a: Mesh, + b: Mesh, tmax: float = 1e-6, amin: float = 1e-1, ) -> list[ContactInterface]: @@ -106,27 +116,50 @@ def mesh_mesh_interfaces( ------- List[:class:`ContactInterface`] + Notes + ----- + For equilibrium calculations with CRA, it is important that interface frames are aligned + with the direction of the (interaction) edges on which they are stored. + + This means that if the + """ world = Frame.worldXY() interfaces = [] - frames = a.frames() for face in a.faces(): - points = a.face_coordinates(face) - frame = frames[face] - matrix = Transformation.from_change_of_basis(world, frame) - projected = transform_points(points, matrix) - p0 = ShapelyPolygon(projected) + a_points = a.face_coordinates(face) + a_normal = a.face_normal(face) for test in b.faces(): - points = b.face_coordinates(test) - projected = transform_points(points, matrix) - p1 = ShapelyPolygon(projected) + b_points = b.face_coordinates(test) + b_normal: Vector = b.face_normal(test) + + if not is_parallel_vector_vector(a_normal, b_normal): + continue + + # this ensures that a shared frame is used to do the interface calculations + # the frame should be oriented along the normal of the "a" face + # this will align the interface frame with the resulting interaction edge + # whgich is important for calculations with solvers such as CRA + frame = Frame(*bestfit_frame_numpy(a_points + b_points)) + if frame.zaxis.dot(a_normal) < 0: + frame.invert() + + matrix = Transformation.from_change_of_basis(world, frame) + + a_projected = transform_points(a_points, matrix) + p0 = ShapelyPolygon(a_projected) + + b_projected = transform_points(b_points, matrix) + p1 = ShapelyPolygon(b_projected) + + projected = a_projected + b_projected if not all(fabs(point[2]) < tmax for point in projected): continue - if p1.area < amin: + if p0.area < amin or p1.area < amin: continue if not p0.intersects(p1): diff --git a/src/compas_model/algorithms/intersections.py b/src/compas_model/algorithms/intersections.py new file mode 100644 index 00000000..d4bacc31 --- /dev/null +++ b/src/compas_model/algorithms/intersections.py @@ -0,0 +1,2 @@ +def model_intersections(): + pass diff --git a/src/compas_model/algorithms/overlaps.py b/src/compas_model/algorithms/overlaps.py new file mode 100644 index 00000000..77ef6135 --- /dev/null +++ b/src/compas_model/algorithms/overlaps.py @@ -0,0 +1,137 @@ +from compas.geometry import Brep +from compas.tolerance import TOL +from compas_occ.brep import OCCBrepFace as BrepFace + +from compas_model.interactions import ContactInterface +from compas_model.models import Model + +from .nnbrs import find_nearest_neighbours + + +def model_overlaps( + model: Model, + deflection=None, + tolerance=1, + nmax: int = 10, + tmax: float = 1e-6, + amin: float = 1e-2, + nnbrs_dims: int = 3, +): + """Identify the interfaces between the blocks of an assembly. + + Parameters + ---------- + assembly : compas_assembly.datastructures.Assembly + An assembly of discrete blocks. + nmax : int, optional + Maximum number of neighbours per block. + tmax : float, optional + Maximum deviation from the perfectly flat interface plane. + amin : float, optional + Minimum area of a "face-face" interface. + + Returns + ------- + :class:`Assembly` + + """ + deflection = deflection or TOL.lineardeflection + + node_index = {node: index for index, node in enumerate(model.graph.nodes())} + index_node = {index: node for index, node in enumerate(model.graph.nodes())} + + geometries: list[Brep] = [model.graph.node_element(node).modelgeometry for node in model.graph.nodes()] + + nmax = min(nmax, len(geometries)) + cloud = [geometry.centroid for geometry in geometries] + nnbrs = find_nearest_neighbours(cloud, nmax, dims=nnbrs_dims) + + model.graph.edge = {node: {} for node in model.graph.nodes()} + + for u in model.graph.nodes(): + i = node_index[u] + A = geometries[i] + + nbrs = nnbrs[i][1] + + for j in nbrs: + v = index_node[j] + + if u == v: + continue + + if model.graph.has_edge((u, v), directed=False): + continue + + B = geometries[j] + + overlaps = brep_brep_overlaps(A, B, deflection=deflection, tolerance=tolerance, tmax=tmax, amin=amin) + + if overlaps: + model.graph.add_edge(u, v, interactions=overlaps) + + return model + + +def brep_brep_overlaps( + A: Brep, + B: Brep, + deflection=None, + tolerance=1, + tmax: float = 1e-6, + amin: float = 1e-2, +) -> list[ContactInterface]: + """Compute all face-face contact interfaces between two meshes. + + Parameters + ---------- + a : :class:`Block` + b : :class:`Block` + tmax : float, optional + Maximum deviation from the perfectly flat interface plane. + amin : float, optional + Minimum area of a "face-face" interface. + + Returns + ------- + List[:class:`ContactInterface`] + + Notes + ----- + For equilibrium calculations with CRA, it is important that interface frames are aligned + with the direction of the (interaction) edges on which they are stored. + + This means that if the + + """ + faces_A, faces_B = A.overlap(B, deflection=deflection, tolerance=tolerance) + faces_A: list[BrepFace] + faces_B: list[BrepFace] + + overlaps: list[ContactInterface] = [] + + if faces_A and faces_B: + for face_A in faces_A: + brep_A = Brep.from_brepfaces([face_A]) + + if brep_A.area < amin: + continue + + for face_B in faces_B: + brep_B = Brep.from_brepfaces([face_B]) + + if brep_B.area < amin: + continue + + brep_C: Brep = Brep.from_boolean_intersection(brep_A, brep_B) + + if brep_C.area < amin: + continue + + poly_C = brep_C.to_polygons()[0] + mesh_C = brep_C.to_tesselation()[0] + + overlap = ContactInterface(points=poly_C.points, mesh=mesh_C) + overlaps.append(overlap) + + return overlaps diff --git a/src/compas_model/analysis/cra.py b/src/compas_model/analysis/cra.py index b26434e7..c5bc2b43 100644 --- a/src/compas_model/analysis/cra.py +++ b/src/compas_model/analysis/cra.py @@ -20,10 +20,10 @@ def cra_penalty_solve( element_block = {} for element in model.elements(): - block: Block = element.geometry.copy(cls=Block) + block: Block = element.modelgeometry.copy(cls=Block) x, y, z = block.centroid() node = assembly.add_block(block, x=x, y=y, z=z, is_support=element.is_support) - element_block[element.graph_node] = node + element_block[element.graphnode] = node for edge in model.graph.edges(): interactions: list[ContactInterface] = model.graph.edge_interactions(edge) diff --git a/src/compas_model/elements/__init__.py b/src/compas_model/elements/__init__.py index 03b18ef2..d150ec1a 100644 --- a/src/compas_model/elements/__init__.py +++ b/src/compas_model/elements/__init__.py @@ -4,8 +4,6 @@ from .block import BlockElement from .block import BlockFeature from .block import BlockGeometry -from .interface import InterfaceElement -from .interface import InterfaceFeature from .plate import PlateElement from .plate import PlateFeature @@ -17,8 +15,6 @@ "BlockElement", "BlockFeature", "BlockGeometry", - "InterfaceElement", - "InterfaceFeature", "PlateElement", "PlateFeature", ] diff --git a/src/compas_model/elements/block.py b/src/compas_model/elements/block.py index a35b51a7..16a2ea48 100644 --- a/src/compas_model/elements/block.py +++ b/src/compas_model/elements/block.py @@ -1,64 +1,37 @@ -import compas.datastructures # noqa: F401 +from typing import Optional +from typing import Union + from compas.datastructures import Mesh from compas.geometry import Box from compas.geometry import Frame from compas.geometry import Point +from compas.geometry import Transformation +from compas.geometry import bestfit_frame_numpy from compas.geometry import bounding_box from compas.geometry import centroid_points from compas.geometry import centroid_polyhedron -from compas.geometry import cross_vectors from compas.geometry import dot_vectors from compas.geometry import oriented_bounding_box from compas.geometry import volume_polyhedron +from compas.geometry.brep.brep import Brep from compas_model.elements import Element from compas_model.elements import Feature -class BlockGeometry(Mesh): - def centroid(self): - """Compute the centroid of the block. - - Returns - ------- - :class:`compas.geometry.Point` - - """ - x, y, z = centroid_points([self.vertex_coordinates(key) for key in self.vertices()]) - return Point(x, y, z) - - def frames(self): - """Compute the local frame of each face of the block. +def invert(self): + self._yaxis = self._yaxis * -1 + self._zaxis = self._zaxis * -1 - Returns - ------- - dict - A dictionary mapping face identifiers to face frames. - """ - return {face: self.frame(face) for face in self.faces()} +Frame.invert = invert - def frame(self, face): - """Compute the frame of a specific face. - Parameters - ---------- - face : int - The identifier of the frame. - - Returns - ------- - :class:`compas.geometry.Frame` - - """ - xyz = self.face_coordinates(face) - o = self.face_center(face) - w = self.face_normal(face) - u = [xyz[1][i] - xyz[0][i] for i in range(3)] # align with longest edge instead? - v = cross_vectors(w, u) - return Frame(o, u, v) +class BlockGeometry(Mesh): + """Geometric representation of a block using a mesh.""" - def top(self): + @property + def top(self) -> int: """Identify the *top* face of the block. Returns @@ -72,7 +45,20 @@ def top(self): normals = [self.face_normal(face) for face in faces] return sorted(zip(faces, normals), key=lambda x: dot_vectors(x[1], z))[-1][0] - def center(self): + @property + def centroid(self) -> Point: + """Compute the centroid of the block. + + Returns + ------- + :class:`compas.geometry.Point` + + """ + x, y, z = centroid_points([self.vertex_coordinates(key) for key in self.vertices()]) + return Point(x, y, z) + + @property + def center(self) -> Point: """Compute the center of mass of the block. Returns @@ -86,7 +72,8 @@ def center(self): x, y, z = centroid_polyhedron((vertices, faces)) return Point(x, y, z) - def volume(self): + @property + def volume(self) -> float: """Compute the volume of the block. Returns @@ -101,6 +88,27 @@ def volume(self): v = volume_polyhedron((vertices, faces)) return v + def face_frame(self, face: int) -> Frame: + """Compute the frame of a specific face. + + Parameters + ---------- + face : int + The identifier of the frame. + + Returns + ------- + :class:`compas.geometry.Frame` + + """ + xyz = self.face_coordinates(face) + normal = self.face_normal(face) + o, u, v = bestfit_frame_numpy(xyz) + frame = Frame(o, u, v) + if frame.zaxis.dot(normal) < 0: + frame.invert() + return frame + # A block could have features like notches, # but we will work on it when we need it... @@ -138,64 +146,84 @@ class BlockElement(Element): """ + elementgeometry: BlockGeometry + modelgeometry: BlockGeometry + @property - def __data__(self): - # type: () -> dict - data = super(BlockElement, self).__data__ + def __data__(self) -> dict: + data = super().__data__ data["shape"] = self.shape data["features"] = self.features data["is_support"] = self.is_support return data - def __init__(self, shape, features=None, is_support=False, frame=None, transformation=None, name=None): - # type: (Mesh | BlockGeometry, list[BlockFeature] | None, bool, compas.geometry.Frame | None, compas.geometry.Transformation | None, str | None) -> None + def __init__( + self, + shape: Union[Mesh, BlockGeometry], + features: Optional[list[BlockFeature]] = None, + is_support: bool = False, + frame: Optional[Frame] = None, + transformation: Optional[Transformation] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(frame=frame, transformation=transformation, features=features, name=name) - super(BlockElement, self).__init__(frame=frame, transformation=transformation, name=name) self.shape = shape if isinstance(shape, BlockGeometry) else shape.copy(cls=BlockGeometry) - self.features = features or [] # type: list[BlockFeature] self.is_support = is_support - # don't like this - # but want to test the collider - @property - def face_polygons(self): - # type: () -> list[compas.geometry.Polygon] - return [self.geometry.face_polygon(face) for face in self.geometry.faces()] # type: ignore - # ============================================================================= # Implementations of abstract methods # ============================================================================= - def compute_geometry(self, include_features=False): + def compute_elementgeometry(self) -> Union[Mesh, Brep]: geometry = self.shape - if include_features: - if self.features: - for feature in self.features: - geometry = feature.apply(geometry) - geometry.transform(self.worldtransformation) + # apply features? + return geometry + + def compute_modelgeometry(self) -> Union[Mesh, Brep]: + if not self.model: + raise Exception + + geometry = self.elementgeometry.transformed(self.modeltransformation) + + # apply effect of interactions? + node = self.graphnode + nbrs = self.model.graph.neighbors_in(node) + for nbr in nbrs: + element = self.model.graph.node_element(nbr) + if element: + for interaction in self.model.graph.edge_interactions((nbr, node)): + # example interactions are + # cutters, boolean operations, slicers, ... + if hasattr(interaction, "apply"): + try: + interaction.apply(geometry) + except Exception: + pass + return geometry - def compute_aabb(self, inflate=0.0): - points = self.geometry.vertices_attributes("xyz") # type: ignore + def compute_aabb(self) -> Box: + points = self.modelgeometry.vertices_attributes("xyz") box = Box.from_bounding_box(bounding_box(points)) - box.xsize += inflate - box.ysize += inflate - box.zsize += inflate + box.xsize += self.inflate_aabb + box.ysize += self.inflate_aabb + box.zsize += self.inflate_aabb return box - def compute_obb(self, inflate=0.0): - points = self.geometry.vertices_attributes("xyz") # type: ignore + def compute_obb(self) -> Box: + points = self.modelgeometry.vertices_attributes("xyz") box = Box.from_bounding_box(oriented_bounding_box(points)) - box.xsize += inflate - box.ysize += inflate - box.zsize += inflate + box.xsize += self.inflate_obb + box.ysize += self.inflate_obb + box.zsize += self.inflate_obb return box - def compute_collision_mesh(self): + def compute_collision_mesh(self) -> Mesh: # TODO: (TvM) make this a pluggable with default implementation in core and move import to top from compas.geometry import convex_hull_numpy - points = self.geometry.vertices_attributes("xyz") # type: ignore + points = self.modelgeometry.vertices_attributes("xyz") # type: ignore vertices, faces = convex_hull_numpy(points) vertices = [points[index] for index in vertices] # type: ignore return Mesh.from_vertices_and_faces(vertices, faces) @@ -205,8 +233,7 @@ def compute_collision_mesh(self): # ============================================================================= @classmethod - def from_box(cls, box): - # type: (compas.geometry.Box) -> BlockElement + def from_box(cls, box: Box) -> "BlockElement": shape = box.to_mesh() block = cls(shape=shape) return block diff --git a/src/compas_model/elements/element.py b/src/compas_model/elements/element.py index 053fa779..b4902311 100644 --- a/src/compas_model/elements/element.py +++ b/src/compas_model/elements/element.py @@ -1,30 +1,35 @@ -import compas - -if not compas.IPY: - from typing import TYPE_CHECKING - from typing import Union # noqa: F401 - - if TYPE_CHECKING: - from compas_model.models import ElementNode # noqa: F401 - from functools import reduce from functools import wraps from operator import mul +from typing import TYPE_CHECKING +from typing import Optional +from typing import Union -import compas.datastructures # noqa: F401 -import compas.geometry from compas.data import Data +from compas.datastructures import Mesh +from compas.geometry import Box +from compas.geometry import Brep +from compas.geometry import Frame +from compas.geometry import Shape from compas.geometry import Transformation +from compas_model.materials import Material + +if TYPE_CHECKING: + from compas_model.models import ElementNode + from compas_model.models import Model + def reset_computed(f): @wraps(f) def wrapper(*args, **kwargs): - self = args[0] + self: Element = args[0] self._aabb = None self._obb = None self._collision_mesh = None self._geometry = None + self._modelgeometry = None + self._modeltransformation = None return f(*args, **kwargs) return wrapper @@ -41,16 +46,14 @@ class Feature(Data): """ @property - def __data__(self): - # type: () -> dict + def __data__(self) -> dict: return {} - def __init__(self, name=None): - # type: (str | None) -> None - super(Feature, self).__init__(name=name) + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) - def apply(self, shape): - # type: (compas.datastructures.Mesh | compas.geometry.Brep) -> compas.datastructures.Mesh | compas.geometry.Brep + def apply(self, shape: Union[Mesh, Brep]) -> Union[Mesh, Brep]: + """Apply the feature to the given shape, which represents the base geometry of the host element of the feature.""" raise NotImplementedError @@ -68,9 +71,9 @@ class Element(Data): Attributes ---------- - graph_node : int + graphnode : int The identifier of the corresponding node in the interaction graph of the parent model. - tree_node : :class:`compas.datastructures.TreeNode` + treenode : :class:`compas.datastructures.TreeNode` The node in the hierarchical element tree of the parent model. frame : :class:`compas.geometry.Frame` The local coordinate frame of the element. @@ -90,12 +93,17 @@ class Element(Data): Scaling factor to inflate the AABB with. inflate_obb : float Scaling factor to inflate the OBB with. + is_dirty : bool + Flag to indicate whether the element needs to be updated. """ + model: "Model" + treenode: "ElementNode" + graphnode: int + @property - def __data__(self): - # type: () -> dict + def __data__(self) -> dict: # note that the material can/should not be added here, # because materials should be added by/in the context of a model # and becaue this would also require a custom "from_data" classmethod. @@ -107,118 +115,126 @@ def __data__(self): def __init__( self, - geometry=None, # type: compas.geometry.Shape | compas.geometry.Brep | compas.datastructures.Mesh | None - frame=None, # type: compas.geometry.Frame | None - transformation=None, # type: compas.geometry.Transformation | None - name=None, # type: str | None - ): # type: (...) -> None - super(Element, self).__init__(name=name) - self.graph_node = None # type: int | None - self.tree_node = None # type: ElementNode | None - self._aabb = None - self._obb = None - self._collision_mesh = None - self._geometry = geometry + geometry: Optional[Union[Shape, Brep, Mesh]] = None, + frame: Optional[Frame] = None, + transformation: Optional[Transformation] = None, + features: Optional[list[Feature]] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(name=name) + + self.model = None + self.treenode = None + self.graphnode = None + self._frame = frame self._transformation = transformation - self._worldtransformation = None + self._geometry = geometry # this is same as elementgeometry + self._features = features or [] self._material = None - self.features = [] # type: list[Feature] + + self._aabb = None + self._obb = None + self._collision_mesh = None + self._modelgeometry = None + self._modeltransformation = None + self.include_features = False self.inflate_aabb = 0.0 self.inflate_obb = 0.0 + self._is_dirty = True + # this is not entirely correct - def __repr__(self): - # type: () -> str - return "Element(frame={!r}, name={})".format(self.frame, self.name) + def __repr__(self) -> str: + return f"Element(frame={self.frame!r}, name={self.name})" - def __str__(self): - # type: () -> str - return "".format(self.name) + def __str__(self) -> str: + return f"" @property - def frame(self): - # type: () -> compas.geometry.Frame | None + def frame(self) -> Union[Frame, None]: return self._frame @frame.setter - def frame(self, frame): - self._worldtransformation = None - self._aabb = None - self._obb = None - self._collision_mesh = None - self._geometry = None + @reset_computed + def frame(self, frame: Frame) -> None: self._frame = frame @property - def transformation(self): - # type: () -> compas.geometry.Transformation | None - """ - Transformation of the element wrt its own frame. - When this property is changed, all computed properties have to be recomputed. - """ + def transformation(self) -> Union[Transformation, None]: return self._transformation @transformation.setter - def transformation(self, transformation): - self._worldtransformation = None - self._aabb = None - self._obb = None - self._collision_mesh = None - self._geometry = None + @reset_computed + def transformation(self, transformation: Transformation) -> None: self._transformation = transformation @property - def material(self): + def material(self) -> Union[Material, None]: return self._material @property - def parent(self): - return self.tree_node.parent + def parent(self) -> "ElementNode": + return self.treenode.parent + + @property + def features(self) -> list[Feature]: + return self._features + + @property + def is_dirty(self): + return self._is_dirty + + @is_dirty.setter + def is_dirty(self, value): + self._is_dirty = value + + if value: + elements = list(self.model.elements()) + for neighbor in self.model.graph.neighbors_out(self.graphnode): + elements[neighbor].is_dirty = value # ========================================================================== # Computed attributes # ========================================================================== - # it might be easier to just always compute these + @property + def elementgeometry(self) -> Union[Brep, Mesh]: + if self._geometry is None: + self._geometry = self.compute_elementgeometry() + return self._geometry @property - def worldtransformation(self): - # type: () -> compas.geometry.Transformation - if self._worldtransformation is None: - self._worldtransformation = self.compute_worldtransformation() - return self._worldtransformation + def modeltransformation(self) -> Transformation: + if self._modeltransformation is None: + self._modeltransformation = self.compute_modeltransformation() + return self._modeltransformation @property - def geometry(self): - # type: () -> ... - if self._geometry is None: - self._geometry = self.compute_geometry() - return self._geometry + def modelgeometry(self) -> Union[Brep, Mesh]: + if self._modelgeometry is None: + self._modelgeometry = self.compute_modelgeometry() + return self._modelgeometry @property - def aabb(self): - # type: () -> compas.geometry.Box + def aabb(self) -> Box: if not self._aabb: self._aabb = self.compute_aabb() return self._aabb @property - def obb(self): - # type: () -> compas.geometry.Box + def obb(self) -> Box: if not self._obb: self._obb = self.compute_obb() return self._obb @property - def dimensions(self): - # type: () -> tuple[float, float, float] + def dimensions(self) -> tuple[float, float, float]: return self.obb.width, self.obb.height, self.obb.depth @property - def collision_mesh(self): - # type: () -> compas.datastructures.Mesh + def collision_mesh(self) -> Mesh: if not self._collision_mesh: self._collision_mesh = self.compute_collision_mesh() return self._collision_mesh @@ -227,9 +243,31 @@ def collision_mesh(self): # Abstract methods # ========================================================================== - def compute_worldtransformation(self): - # type: () -> compas.geometry.Transformation - """Compute the transformation to world coordinates of this element + def compute_elementgeometry(self) -> Union[Brep, Mesh]: + """Compute the geometry of the element in local coordinates. + + This is the parametric representation of the element, + without considering its location in the model or its interaction(s) with connected elements. + + Returns + ------- + :class:`compas.datastructures.Mesh` | :class:`compas.geometry.Brep` + + """ + raise NotImplementedError + + def apply_features(self) -> None: + """Apply the features to the (base) geometry. + + Returns + ------- + None + + """ + raise NotImplementedError + + def compute_modeltransformation(self) -> Transformation: + """Compute the transformation to model coordinates of this element based on its position in the spatial hierarchy of the model. Returns @@ -250,41 +288,31 @@ def compute_worldtransformation(self): frame_stack.append(parent.element.frame) parent = parent.parent - frame = self.tree_node.tree.model.frame - if frame: - frame_stack.append(frame) - matrices = [Transformation.from_frame(f) for f in frame_stack] if matrices: - worldtransformation = reduce(mul, matrices[::-1]) + modeltransformation = reduce(mul, matrices[::-1]) else: - worldtransformation = Transformation() + modeltransformation = Transformation() if self.transformation: - worldtransformation = worldtransformation * self.transformation - - return worldtransformation + modeltransformation = modeltransformation * self.transformation - def compute_geometry(self): - # type: () -> compas.datastructures.Mesh | compas.geometry.Brep - """Compute the geometry of the element. + return modeltransformation - Implementations of this method should transform the geometry to world coordinates, - using `self.worldtransformation`. + def compute_modelgeometry(self) -> Union[Brep, Mesh]: + """Compute the geometry of the element in model coordinates and taking into account the effect of interactions with connected elements. Returns ------- :class:`compas.datastructures.Mesh` | :class:`compas.geometry.Brep` """ + self.is_dirty = False raise NotImplementedError - def compute_aabb(self): - # type: (float | None) -> compas.geometry.Box - """Computes the Axis Aligned Bounding Box (AABB) of the element. - - Implementations of this method should base the computation on the geometry in world coordinates. + def compute_aabb(self) -> Box: + """Computes the Axis Aligned Bounding Box (AABB) of the geometry of the element. Returns ------- @@ -294,11 +322,8 @@ def compute_aabb(self): """ raise NotImplementedError - def compute_obb(self): - # type: (float | None) -> compas.geometry.Box - """Computes the Oriented Bounding Box (OBB) of the element. - - Implementations of this method should base the computation on the geometry in world coordinates. + def compute_obb(self) -> Box: + """Computes the Oriented Bounding Box (OBB) of the geometry of the element. Returns ------- @@ -308,11 +333,8 @@ def compute_obb(self): """ raise NotImplementedError - def compute_collision_mesh(self): - # type: () -> compas.datastructures.Mesh - """Computes the collision geometry of the element. - - Implementations of this method should base the computation on the geometry in world coordinates. + def compute_collision_mesh(self) -> Mesh: + """Computes the collision geometry of the geometry of the element. Returns ------- @@ -326,8 +348,7 @@ def compute_collision_mesh(self): # Transformations # ========================================================================== - def transform(self, transformation): - # type: (compas.geometry.Transformation) -> None + def transform(self, transformation: Transformation) -> None: """Transforms the element. Parameters @@ -342,8 +363,7 @@ def transform(self, transformation): """ self.transformation = transformation - def transformed(self, transformation): - # type: (compas.geometry.Transformation) -> Element + def transformed(self, transformation: Transformation) -> "Element": """Creates a transformed copy of the element. Parameters @@ -356,7 +376,7 @@ def transformed(self, transformation): :class:`compas_model.elements.Element` """ - element = self.copy() # type: Element + element: Element = self.copy() element.transform(transformation) return element @@ -364,8 +384,7 @@ def transformed(self, transformation): # Methods # ========================================================================== - def add_feature(self, feature): - # type: (Feature) -> None + def add_feature(self, feature: Feature) -> None: """Add a feature to the list of features of the lement. Parameters @@ -378,4 +397,4 @@ def add_feature(self, feature): None """ - self.features.append(feature) + self._features.append(feature) diff --git a/src/compas_model/elements/interface.py b/src/compas_model/elements/interface.py deleted file mode 100644 index 88869579..00000000 --- a/src/compas_model/elements/interface.py +++ /dev/null @@ -1,101 +0,0 @@ -import compas.datastructures # noqa: F401 -from compas.datastructures import Mesh -from compas.geometry import Box -from compas.geometry import bounding_box -from compas.geometry import oriented_bounding_box - -from compas_model.elements import Element -from compas_model.elements import Feature - - -class InterfaceFeature(Feature): - pass - - -class InterfaceElement(Element): - """Class representing a phyisical interface between two other elements. - - Parameters - ---------- - polygon : :class:`compas.geometry.Polygon` - A polygon that represents the outline of the interface. - thickness : float - The thickness of the interface. - features : list[:class:`InterfaceFeature`], optional - Additional interface features. - name : str, optional - The name of the element. - - Attributes - ---------- - shape : :class:`compas.datastructure.Mesh` - The base shape of the interface. - features : list[:class:`BlockFeature`] - A list of additional interface features. - - Notes - ----- - The shape of the interface is calculated automatically from the input polygon and thickness. - The frame of the element is the frame of the polygon. - - """ - - @property - def __data__(self): - # type: () -> dict - data = super(InterfaceElement, self).__data__ - return data - - def __init__(self, polygon, thickness, features=None, frame=None, name=None): - # type: (compas.geometry.Polygon, float, list[InterfaceFeature] | None, compas.geometry.Frame | None, str | None) -> None - - frame = frame or polygon.frame - - super(InterfaceElement, self).__init__(frame=frame, name=name) - self._polygon = polygon - self._thickness = thickness - self.shape = self.compute_shape() - self.features = features or [] # type: list[InterfaceFeature] - - def compute_shape(self): - # type: () -> compas.datastructures.Mesh - mesh = Mesh.from_polygons([self._polygon]) - return mesh.thickened(self._thickness) - - # ============================================================================= - # Implementations of abstract methods - # ============================================================================= - - def compute_geometry(self, include_features=False): - geometry = self.shape - if include_features: - if self.features: - for feature in self.features: - geometry = feature.apply(geometry) - geometry.transform(self.worldtransformation) - return geometry - - def compute_aabb(self, inflate=0.0): - points = self.geometry.vertices_attributes("xyz") # type: ignore - box = Box.from_bounding_box(bounding_box(points)) - box.xsize += inflate - box.ysize += inflate - box.zsize += inflate - return box - - def compute_obb(self, inflate=0.0): - points = self.geometry.vertices_attributes("xyz") # type: ignore - box = Box.from_bounding_box(oriented_bounding_box(points)) - box.xsize += inflate - box.ysize += inflate - box.zsize += inflate - return box - - def compute_collision_mesh(self): - # TODO: (TvM) make this a pluggable with default implementation in core and move import to top - from compas.geometry import convex_hull_numpy - - points = self.geometry.vertices_attributes("xyz") # type: ignore - vertices, faces = convex_hull_numpy(points) - vertices = [points[index] for index in vertices] # type: ignore - return Mesh.from_vertices_and_faces(vertices, faces) diff --git a/src/compas_model/interactions/__init__.py b/src/compas_model/interactions/__init__.py index 4430bf14..2f9cc1e6 100644 --- a/src/compas_model/interactions/__init__.py +++ b/src/compas_model/interactions/__init__.py @@ -1,14 +1,3 @@ -"""This module provides classes for defining the type of interaction that exists between two elements. -The interaction type could determine, for example, how forces are transferred from one element to the other. -The interaction type could also determine whether an interaction is permanent or temporary; -for example, for designing construction sequences. -The different types of interactions will have to be interpreted by the context in which the model is used. - -Interactions do not define the geometry of a joint or interface, but rather how the elements are connected. -In the case of a wood joint, for example, an interaction could define whether the joinery is dry, glued, or mechanical, -and what the properties of this connection are. -""" - from .interaction import Interaction from .contact import ( ContactInterface, diff --git a/src/compas_model/interactions/contact.py b/src/compas_model/interactions/contact.py index 2f399690..3ccab172 100644 --- a/src/compas_model/interactions/contact.py +++ b/src/compas_model/interactions/contact.py @@ -1,3 +1,7 @@ +from typing import Annotated +from typing import Optional +from typing import Union + from compas.datastructures import Mesh from compas.geometry import Frame from compas.geometry import Line @@ -35,6 +39,14 @@ def sum_matrices(A, B): return M +def invert(self): + self._yaxis = self._yaxis * -1 + self._zaxis = self._zaxis * -1 + + +Frame.invert = invert + + class ContactInterface(Interaction): """Class representing an interaction between two elements through surface-to-surface contact. @@ -90,8 +102,7 @@ class ContactInterface(Interaction): """ @property - def __data__(self): - # type: () -> dict + def __data__(self) -> dict: return { "points": self.points, "size": self.size, @@ -103,14 +114,15 @@ def __data__(self): def __init__( self, - points=None, - frame=None, - size=None, - forces=None, - mesh=None, - name=None, + points: Optional[list[Point]] = None, + frame: Optional[Frame] = None, + size: Optional[float] = None, + forces: Optional[list[float]] = None, + mesh: Optional[Mesh] = None, + name: Optional[str] = None, ): - super(ContactInterface, self).__init__(name) + super().__init__(name) + self._mesh = None self._size = None self._points = None @@ -130,55 +142,58 @@ def geometry(self): return self.polygon @property - def points(self): + def points(self) -> Union[list[Point], None]: return self._points @points.setter - def points(self, items): + def points(self, items: Union[list[Point], list[list[float]]]) -> None: self._points = [] for item in items: self._points.append(Point(*item)) @property - def polygon(self): + def polygon(self) -> Polygon: if self._polygon is None: self._polygon = Polygon(self.points) return self._polygon @property - def frame(self): + def frame(self) -> Frame: if self._frame is None: from compas.geometry import bestfit_frame_numpy self._frame = Frame(*bestfit_frame_numpy(self.points)) + if self._frame.zaxis.dot(self.polygon.normal) < 0: + self._frame.invert() + return self._frame @property - def mesh(self): + def mesh(self) -> Mesh: if not self._mesh: self._mesh = Mesh.from_polygons([self.polygon]) return self._mesh @mesh.setter - def mesh(self, mesh): + def mesh(self, mesh: Mesh) -> None: self._mesh = mesh @property - def points2(self): + def points2(self) -> list[Point]: if not self._points2: X = Transformation.from_frame_to_frame(self.frame, Frame.worldXY()) self._points2 = [Point(*point) for point in transform_points(self.points, X)] return self._points2 @property - def polygon2(self): + def polygon2(self) -> Polygon: if not self._polygon2: X = Transformation.from_frame_to_frame(self.frame, Frame.worldXY()) self._polygon2 = self.polygon.transformed(X) return self._polygon2 @property - def M0(self): + def M0(self) -> float: m0 = 0 for a, b in pairwise(self.points2 + self.points2[:1]): d = b - a @@ -187,7 +202,7 @@ def M0(self): return 0.5 * m0 @property - def M1(self): + def M1(self) -> Point: m1 = Point(0, 0, 0) for a, b in pairwise(self.points2 + self.points2[:1]): d = b - a @@ -197,7 +212,7 @@ def M1(self): return m1 / 6 @property - def M2(self): + def M2(self) -> Annotated[list[Annotated[list[float], 3]], 3]: m2 = outer_product([0, 0, 0], [0, 0, 0]) for a, b in pairwise(self.points2 + self.points2[:1]): d = b - a @@ -225,7 +240,7 @@ def stressdistribution(self): raise NotImplementedError @property - def normalforces(self): + def normalforces(self) -> list[Line]: lines = [] if not self.forces: return lines @@ -239,7 +254,7 @@ def normalforces(self): return lines @property - def compressionforces(self): + def compressionforces(self) -> list[Line]: lines = [] if not self.forces: return lines @@ -254,7 +269,7 @@ def compressionforces(self): return lines @property - def tensionforces(self): + def tensionforces(self) -> list[Line]: lines = [] if not self.forces: return lines @@ -269,7 +284,7 @@ def tensionforces(self): return lines @property - def frictionforces(self): + def frictionforces(self) -> list[Line]: lines = [] if not self.forces: return lines @@ -283,7 +298,7 @@ def frictionforces(self): return lines @property - def resultantpoint(self): + def resultantpoint(self) -> list[float]: if not self.forces: return [] normalcomponents = [f["c_np"] - f["c_nn"] for f in self.forces] @@ -291,7 +306,7 @@ def resultantpoint(self): return Point(*centroid_points_weighted(self.points, normalcomponents)) @property - def resultantforce(self): + def resultantforce(self) -> list[Line]: if not self.forces: return [] normalcomponents = [f["c_np"] - f["c_nn"] for f in self.forces] diff --git a/src/compas_model/interactions/interaction.py b/src/compas_model/interactions/interaction.py index a4dac5e8..95b8f6de 100644 --- a/src/compas_model/interactions/interaction.py +++ b/src/compas_model/interactions/interaction.py @@ -1,3 +1,5 @@ +from typing import Optional + from compas.data import Data @@ -12,13 +14,11 @@ class Interaction(Data): """ @property - def __data__(self): - # type: () -> dict + def __data__(self) -> dict: return {"name": self.name} - def __init__(self, name=None): - # type: (str | None) -> None - super(Interaction, self).__init__(name=name) + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) - def __repr__(self): - return '{}(name="{}")'.format(self.__class__.__name__, self.name) + def __repr__(self) -> str: + return f'{self.__class__.__name__}(name="{self.name}")' diff --git a/src/compas_model/loads/__init__.py b/src/compas_model/loads/__init__.py index 94779bc3..e69de29b 100644 --- a/src/compas_model/loads/__init__.py +++ b/src/compas_model/loads/__init__.py @@ -1 +0,0 @@ -# this is a temporary module to explore the remodelling of COMPAS FEA2 diff --git a/src/compas_model/materials/concrete.py b/src/compas_model/materials/concrete.py index 34bca16e..68d94544 100644 --- a/src/compas_model/materials/concrete.py +++ b/src/compas_model/materials/concrete.py @@ -1,3 +1,6 @@ +from typing import Literal +from typing import Optional + from .material import Material @@ -86,9 +89,8 @@ class Concrete(Material): } @property - def __data__(self): - # type: () -> dict - data = super(Concrete, self).__data__ + def __data__(self) -> dict: + data = super().__data__ data.update( { "fck": self.fck, @@ -102,8 +104,19 @@ def __data__(self): ) return data - def __init__(self, fck, fck_cube=None, fcm=None, fctm=None, Ecm=None, density=2400, poisson=0.2, name=None): - super(Concrete, self).__init__(name=name) + def __init__( + self, + fck: float, + fck_cube: Optional[float] = None, + fcm: Optional[float] = None, + fctm: Optional[float] = None, + Ecm: Optional[float] = None, + density: float = 2400, + poisson: float = 0.2, + name: Optional[str] = None, + ): + super().__init__(name=name) + self.fck = fck self.fck_cube = fck_cube or 1.25 * fck self.fcm = fcm @@ -113,20 +126,19 @@ def __init__(self, fck, fck_cube=None, fcm=None, fctm=None, Ecm=None, density=24 self.poisson = poisson @property - def rho(self): + def rho(self) -> float: return self.density @property - def nu(self): + def nu(self) -> float: return self.poisson @property - def G(self): + def G(self) -> float: return self.Ecm / (2 * (1 + self.nu)) @classmethod - def from_strength_class(cls, strength_class): - # type: (str) -> Concrete + def from_strength_class(cls, strength_class: Literal["C10", "C15", "C20", "C25", "C30", "C35"]) -> "Concrete": """Construct a concrete material from a strength class. Parameters diff --git a/src/compas_model/materials/material.py b/src/compas_model/materials/material.py index eb01e397..9ebc7b68 100644 --- a/src/compas_model/materials/material.py +++ b/src/compas_model/materials/material.py @@ -1,3 +1,5 @@ +from typing import Optional + from compas.data import Data @@ -12,13 +14,11 @@ class Material(Data): """ @property - def __data__(self): - # type: () -> dict + def __data__(self) -> dict: return {"name": self.name} - def __init__(self, name=None): - # type: (str | None) -> None - super(Material, self).__init__(name=name) + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) - def __repr__(self): - return '{}(name="{}")'.format(self.__class__.__name__, self.name) + def __repr__(self) -> str: + return f'{self.__class__.__name__}(name="{self.name}")' diff --git a/src/compas_model/materials/steel.py b/src/compas_model/materials/steel.py index 46a7ce3c..c4ba5607 100644 --- a/src/compas_model/materials/steel.py +++ b/src/compas_model/materials/steel.py @@ -1,8 +1,11 @@ +from typing import Literal +from typing import Optional + from .material import Material class Steel(Material): - strength_classes = { + STRENGTH_CLASSES: dict[str, dict[str, float]] = { "S235": {"fy": 235, "fu": 360}, "S275": {"fy": 275, "fu": 430}, "S355": {"fy": 355, "fu": 490}, @@ -10,9 +13,8 @@ class Steel(Material): } @property - def __data__(self): - # type: () -> dict - data = super(Steel, self).__data__ + def __data__(self) -> dict: + data = super().__data__ data.update( { "fy": self.fy, @@ -21,8 +23,9 @@ def __data__(self): ) return data - def __init__(self, fy, fu, name=None): - super(Steel, self).__init__(name=name) + def __init__(self, fy: float, fu: float, name: Optional[str] = None) -> None: + super().__init__(name=name) + self.fy = fy self.fu = fu self.E = 210 @@ -30,20 +33,19 @@ def __init__(self, fy, fu, name=None): self.density = 7850 @property - def rho(self): + def rho(self) -> float: return self.density @property - def nu(self): + def nu(self) -> float: return self.poisson @property - def G(self): + def G(self) -> float: return self.E / (2 * (1 + self.nu)) @classmethod - def from_strength_class(cls, strength_class): - # type: (str) -> Steel + def from_strength_class(cls, strength_class: Literal["S235", "S275", "S355", "S450"]) -> "Steel": """Construct a steel material from a steel strength class. Parameters @@ -57,7 +59,7 @@ def from_strength_class(cls, strength_class): """ strength_class = strength_class.upper() - if strength_class not in cls.strength_classes: + if strength_class not in cls.STRENGTH_CLASSES: raise ValueError("This strength class is not supported: {}".format(strength_class)) - params = cls.strength_classes[strength_class] + params = cls.STRENGTH_CLASSES[strength_class] return cls(**params) diff --git a/src/compas_model/materials/timber.py b/src/compas_model/materials/timber.py index bba7fd16..0ac18a9d 100644 --- a/src/compas_model/materials/timber.py +++ b/src/compas_model/materials/timber.py @@ -1,13 +1,13 @@ +from typing import Optional + from .material import Material class Timber(Material): @property - def __data__(self): - # type: () -> dict - data = super(Timber, self).__data__ - data.update({}) + def __data__(self) -> dict: + data = super().__data__ return data - def __init__(self, name=None): - super(Timber, self).__init__(name=name) + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) diff --git a/src/compas_model/models/__init__.py b/src/compas_model/models/__init__.py index f652fff4..ca79a1c2 100644 --- a/src/compas_model/models/__init__.py +++ b/src/compas_model/models/__init__.py @@ -2,6 +2,7 @@ from .elementtree import ElementTree from .interactiongraph import InteractionGraph from .model import Model +from .blockmodel import BlockModel __all__ = [ @@ -9,4 +10,5 @@ "ElementTree", "InteractionGraph", "Model", + "BlockModel", ] diff --git a/src/compas_model/models/blockmodel.py b/src/compas_model/models/blockmodel.py index e69de29b..039a0569 100644 --- a/src/compas_model/models/blockmodel.py +++ b/src/compas_model/models/blockmodel.py @@ -0,0 +1,80 @@ +from typing import Optional + +from compas.geometry import Brep +from compas.tolerance import Tolerance +from compas_occ.brep import OCCBrepFace as BrepFace + +from compas_model.algorithms.nnbrs import find_nearest_neighbours +from compas_model.elements import Element +from compas_model.interactions import ContactInterface + +from .model import Model + + +class BlockModel(Model): + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name) + + def compute_intersections(self): + pass + + def compute_overlaps(self, deflection=None, tolerance=1, max_distance=50, min_area=0): + pass + + def compute_interfaces(self, deflection=None, tolerance=1, max_distance=50, min_area=0, nmax=10): + deflection = deflection or Tolerance().lineardeflection + + node_index = {node: index for index, node in enumerate(self.graph.nodes())} + index_node = {index: node for index, node in enumerate(self.graph.nodes())} + + geometries: list[Brep] = [self.graph.node_element(node).modelgeometry for node in self.graph.nodes()] + + cloud = [geometry.centroid for geometry in geometries] + nnbrs = find_nearest_neighbours(cloud, nmax, dims=3) + + for u in self.graph.nodes(): + A: Element = self.graph.node_element(u) # type: ignore + + i = node_index[u] + nbrs = nnbrs[i][1] + + for j in nbrs: + v = index_node[j] + + if u == v: + continue + + if self.graph.has_edge((u, v), directed=False): + continue + + B: Element = self.graph.node_element(v) # type: ignore + + faces_A, faces_B = A.modelgeometry.overlap(B.modelgeometry, deflection=deflection, tolerance=tolerance) # type: ignore + faces_A: list[BrepFace] + faces_B: list[BrepFace] + + if faces_A and faces_B: + for face_A in faces_A: + brep_A = Brep.from_brepfaces([face_A]) + + if brep_A.area < min_area: + continue + + for face_B in faces_B: + brep_B = Brep.from_brepfaces([face_B]) + + if brep_B.area < min_area: + continue + + brep_C: Brep = Brep.from_boolean_intersection(brep_A, brep_B) + + if brep_C.area < min_area: + continue + + poly_C = brep_C.to_polygons()[0] + mesh_C = brep_C.to_tesselation()[0] + + interaction = ContactInterface(points=poly_C.points, mesh=mesh_C) + + # do something with the interactions + self.add_interaction(A, B, interaction=interaction) diff --git a/src/compas_model/models/elementnode.py b/src/compas_model/models/elementnode.py index bdcec7ef..f3b82cc5 100644 --- a/src/compas_model/models/elementnode.py +++ b/src/compas_model/models/elementnode.py @@ -1,6 +1,12 @@ +from typing import TYPE_CHECKING +from typing import Optional + from compas.datastructures import TreeNode -from compas_model.elements import Element # noqa: F401 +from compas_model.elements import Element + +if TYPE_CHECKING: + from .elementtree import ElementTree class ElementNode(TreeNode): @@ -23,25 +29,24 @@ class ElementNode(TreeNode): """ + tree: "ElementTree" + @property - def __data__(self): - # type: () -> dict - data = super(ElementNode, self).__data__ + def __data__(self) -> dict: + data = super().__data__ data["element"] = None if not self.element else str(self.element.guid) return data @classmethod - def __from_data__(cls, data): - # type: (dict) -> ElementNode + def __from_data__(cls, data: dict) -> "ElementNode": raise Exception("Serialisation outside model context not allowed.") - def __init__(self, element=None, **kwargs): - # type: (Element | None, str | None) -> None - super(ElementNode, self).__init__(**kwargs) + def __init__(self, element: Optional[Element] = None, **kwargs) -> None: + super().__init__(**kwargs) + if element: - element.tree_node = self + element.treenode = self self.element = element - def __getitem__(self, index): - # type: (int) -> ElementNode + def __getitem__(self, index: int) -> "ElementNode": return self.children[index] diff --git a/src/compas_model/models/elementtree.py b/src/compas_model/models/elementtree.py index 0d8d3484..b5e71391 100644 --- a/src/compas_model/models/elementtree.py +++ b/src/compas_model/models/elementtree.py @@ -1,7 +1,8 @@ +from typing import Optional + from compas.datastructures import Tree -import compas_model.models # noqa: F401 -from compas_model.elements import Element # noqa: F401 +from compas_model.elements import Element from .elementnode import ElementNode @@ -28,30 +29,25 @@ class ElementTree(Tree): """ @property - def __data__(self): - # type: () -> dict - data = super(ElementTree, self).__data__ + def __data__(self) -> dict: + data = super().__data__ return data @classmethod - def __from_data__(cls, data): - # type: (dict) -> ElementTree + def __from_data__(cls, data: dict) -> "ElementTree": raise Exception("Serialisation outside model context not allowed.") - def __init__(self, model, name=None): - # type: (compas_model.models.Model, str | None) -> None - super(ElementTree, self).__init__(name=name) - self.model = model + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) + root = ElementNode(name="root") self.add(root) @property - def elements(self): - # type: () -> list[Element] + def elements(self) -> list[Element]: return [node.element for node in self.nodes if isinstance(node, ElementNode) if node.element] - def find_element_node(self, element): - # type: (Element) -> ElementNode + def find_element_node(self, element: Element) -> ElementNode: """Find the node containing the element. Parameters diff --git a/src/compas_model/models/interactiongraph.py b/src/compas_model/models/interactiongraph.py index 48982a3b..07059a83 100644 --- a/src/compas_model/models/interactiongraph.py +++ b/src/compas_model/models/interactiongraph.py @@ -1,7 +1,5 @@ -import compas - -if not compas.IPY: - from typing import Generator # noqa: F401 +from typing import Generator +from typing import Optional from compas.datastructures import Graph @@ -31,9 +29,9 @@ class InteractionGraph(Graph): """ @property - def __data__(self): - # type: () -> dict - data = super(InteractionGraph, self).__data__ + def __data__(self) -> dict: + data = super().__data__ + for node, attr in data["node"].items(): # this modifies the attributes in place # as a consequence, after accessing the __data__ property of the graph, @@ -45,17 +43,15 @@ def __data__(self): return data @classmethod - def __from_data__(cls, data, guid_element): - # type: (dict, dict) -> InteractionGraph + def __from_data__(cls, data: dict, guid_element: dict[str, Element]) -> "InteractionGraph": graph = super(InteractionGraph, cls).__from_data__(data) for node, attr in graph.nodes(data=True): element = guid_element[attr["element"]] attr["element"] = element # type: ignore - element.graph_node = node + element.graphnode = node return graph - def copy(self): - # type: () -> InteractionGraph + def copy(self) -> "InteractionGraph": # A custom implementation of copy is needed to allow passing the element dictionary to __from_data__. guid_element = {} for _, node in self.nodes(data=True): @@ -63,26 +59,31 @@ def copy(self): guid_element[str(element.guid)] = element return self.__from_data__(self.__data__, guid_element) - def __init__(self, default_node_attributes=None, default_edge_attributes=None, name=None, **kwargs): - # type: (dict | None, dict | None, str | None, dict) -> None - super(InteractionGraph, self).__init__( + def __init__( + self, + default_node_attributes: Optional[dict] = None, + default_edge_attributes: Optional[dict] = None, + name: Optional[str] = None, + **kwargs, + ) -> None: + super().__init__( default_node_attributes=default_node_attributes, default_edge_attributes=default_edge_attributes, name=name, **kwargs, ) + self.update_default_node_attributes(element=None) self.update_default_edge_attributes(interactions=None) - def __str__(self): - # type: () -> str - output = super(InteractionGraph, self).__str__() + def __str__(self) -> str: + output = super().__str__() + output += "\n" output += self._build_interactions_str() return output - def _build_interactions_str(self): - # type: () -> str + def _build_interactions_str(self) -> str: lines = [] for node in self.nodes(): lines.append("{}".format(node)) @@ -98,8 +99,7 @@ def _build_interactions_str(self): ) return "\n".join(lines) + "\n" - def node_element(self, node): - # type: (int) -> Element + def node_element(self, node: int) -> Element: """Get the element associated with the node. Parameters @@ -114,8 +114,7 @@ def node_element(self, node): """ return self.node_attribute(node, "element") # type: ignore - def edge_interactions(self, edge): - # type: (tuple[int, int]) -> list[Interaction] + def edge_interactions(self, edge: tuple[int, int]) -> list[Interaction]: """Get the element associated with the node. Parameters @@ -130,8 +129,7 @@ def edge_interactions(self, edge): """ return self.edge_attribute(edge, "interactions") # type: ignore - def interactions(self): - # type: () -> Generator[Interaction] + def interactions(self) -> Generator[Interaction, None, None]: """Get the interactions in the graph. Yields diff --git a/src/compas_model/models/model.py b/src/compas_model/models/model.py index 3f72d76e..14b2249a 100644 --- a/src/compas_model/models/model.py +++ b/src/compas_model/models/model.py @@ -1,20 +1,16 @@ from collections import OrderedDict from collections import deque +from typing import Generator +from typing import Optional +from typing import Type -import compas - -if not compas.IPY: - from typing import Generator # noqa: F401 - from typing import Type # noqa: F401 - -import compas.datastructures # noqa: F401 -import compas.geometry # noqa: F401 from compas.datastructures import Datastructure from compas.geometry import Frame +from compas.geometry import Transformation -from compas_model.elements import Element # noqa: F401 -from compas_model.interactions import Interaction # noqa: F401 -from compas_model.materials import Material # noqa: F401 +from compas_model.elements import Element +from compas_model.interactions import Interaction +from compas_model.materials import Material from .elementnode import ElementNode from .elementtree import ElementTree @@ -51,12 +47,13 @@ class Model(Datastructure): """ @property - def __data__(self): + def __data__(self) -> dict: # in their data representation, # the element tree and the interaction graph # refer to model elements by their GUID, to avoid storing duplicate data representations of those elements # the elements are stored in a global list data = { + "frame": self.frame, "tree": self._tree.__data__, "graph": self._graph.__data__, "elements": list(self.elements()), @@ -66,19 +63,17 @@ def __data__(self): return data @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict) -> "Model": model = cls() model._guid_material = {str(material.guid): material for material in data["materials"]} model._guid_element = {str(element.guid): element for element in data["elements"]} for e, m in data["element_material"].items(): - element = model._guid_element[e] - material = model._guid_material[m] + element: Element = model._guid_element[e] + material: Material = model._guid_material[m] element._material = material - def add(nodedata, parentnode): - # type: (dict, ElementNode) -> None - + def add(nodedata: dict, parentnode: ElementNode) -> None: if "children" in nodedata: for childdata in nodedata["children"]: guid = childdata["element"] @@ -102,11 +97,12 @@ def add(nodedata, parentnode): return model def __init__(self, name=None): - super(Model, self).__init__(name=name) + super().__init__(name=name) + self._frame = None self._guid_material = {} self._guid_element = OrderedDict() - self._tree = ElementTree(model=self) + self._tree = ElementTree() self._graph = InteractionGraph() self._graph.update_default_node_attributes(element=None) self._graph.update_default_edge_attributes(interactions=None) @@ -140,13 +136,11 @@ def __str__(self): # such that the user can't make unintended and potentially breaking changes @property - def tree(self): - # type: () -> ElementTree + def tree(self) -> ElementTree: return self._tree @property - def graph(self): - # type: () -> InteractionGraph + def graph(self) -> InteractionGraph: return self._graph # A model should have a coordinate system. @@ -158,22 +152,20 @@ def graph(self): # the model can compute the transformations of all of the elements in the tree. @property - def frame(self): - # type: () -> compas.geometry.Frame + def frame(self) -> Frame: if not self._frame: self._frame = Frame.worldXY() return self._frame @frame.setter - def frame(self, frame): + def frame(self, frame: Frame): self._frame = frame # ============================================================================= # Datastructure "abstract" methods # ============================================================================= - def transform(self, transformation): - # type: (compas.geometry.Transformation) -> None + def transform(self, transformation: Transformation) -> None: """Transform the model and all that it contains. Parameters @@ -194,8 +186,7 @@ def transform(self, transformation): # Methods # ============================================================================= - def has_element(self, element): - # type: (Element) -> bool + def has_element(self, element: Element) -> bool: """Returns True if the model contains the given element. Parameters @@ -211,8 +202,7 @@ def has_element(self, element): guid = str(element.guid) return guid in self._guid_element - def has_interaction(self, a, b): - # type: (Element, Element) -> bool + def has_interaction(self, a: Element, b: Element) -> bool: """Returns True if two elements have an interaction set between them. Parameters @@ -227,15 +217,14 @@ def has_interaction(self, a, b): bool """ - edge = a.graph_node, b.graph_node + edge = a.graphnode, b.graphnode result = self.graph.has_edge(edge) if not result: - edge = b.graph_node, a.graph_node + edge = b.graphnode, a.graphnode result = self.graph.has_edge(edge) return result - def has_material(self, material): - # type: (Material) -> bool + def has_material(self, material: Material) -> bool: """Verify that the model contains a specific material. Parameters @@ -251,8 +240,7 @@ def has_material(self, material): guid = str(material.guid) return guid in self._guid_material - def add_element(self, element, parent=None, material=None): - # type: (Element, ElementNode | None, Material | None) -> ElementNode + def add_element(self, element: Element, parent: Optional[ElementNode] = None, material: Optional[Material] = None) -> ElementNode: """Add an element to the model. Parameters @@ -282,17 +270,19 @@ def add_element(self, element, parent=None, material=None): guid = str(element.guid) if guid in self._guid_element: raise Exception("Element already in the model.") + self._guid_element[guid] = element - element.graph_node = self.graph.add_node(element=element) + element.graphnode = self.graph.add_node(element=element) if not parent: parent = self._tree.root if isinstance(parent, Element): - if parent.tree_node is None: + if parent.treenode is None: raise ValueError("The parent element is not part of this model.") - parent = parent.tree_node + + parent = parent.treenode if not isinstance(parent, ElementNode): raise ValueError("Parent should be an Element or ElementNode of the current model.") @@ -306,10 +296,11 @@ def add_element(self, element, parent=None, material=None): if material: self.assign_material(material=material, element=element) + element.model = self + return element_node - def add_elements(self, elements, parent=None): - # type: (list[Element], ElementNode | None) -> list[ElementNode] + def add_elements(self, elements: list[Element], parent: Optional[ElementNode] = None) -> list[ElementNode]: """Add multiple elements to the model. Parameters @@ -330,8 +321,7 @@ def add_elements(self, elements, parent=None): nodes.append(self.add_element(element, parent=parent)) return nodes - def add_material(self, material): - # type: (Material) -> None + def add_material(self, material: Material) -> None: """Add a material to the model. Parameters @@ -347,11 +337,11 @@ def add_material(self, material): guid = str(material.guid) if guid in self._guid_material: raise Exception("Material already in the model.") + # check if a similar material is already in the model self._guid_material[guid] = material - def add_interaction(self, a, b, interaction=None): - # type: (Element, Element, Interaction | None) -> tuple[int, int] + def add_interaction(self, a: Element, b: Element, interaction: Optional[Interaction] = None) -> tuple[int, int]: """Add an interaction between two elements of the model. Parameters @@ -377,8 +367,8 @@ def add_interaction(self, a, b, interaction=None): if not self.has_element(a) or not self.has_element(b): raise Exception("Please add both elements to the model first.") - node_a = a.graph_node - node_b = b.graph_node + node_a = a.graphnode + node_b = b.graphnode if not self.graph.has_node(node_a) or not self.graph.has_node(node_b): raise Exception("Something went wrong: the elements are not in the interaction graph.") @@ -390,10 +380,11 @@ def add_interaction(self, a, b, interaction=None): interactions.append(interaction) self.graph.edge_attribute(edge, name="interactions", value=interactions) + self._guid_element[str(b.guid)].is_dirty = True + return edge - def remove_element(self, element): - # type: (Element) -> None + def remove_element(self, element: Element) -> None: """Remove an element from the model. Parameters @@ -409,13 +400,15 @@ def remove_element(self, element): guid = str(element.guid) if guid not in self._guid_element: raise Exception("Element not in the model.") + + self._guid_element[guid].is_dirty = True + del self._guid_element[guid] - self.graph.delete_node(element.graph_node) - self.tree.remove(element.tree_node) + self.graph.delete_node(element.graphnode) + self.tree.remove(element.treenode) - def remove_interaction(self, a, b, interaction=None): - # type: (Element, Element, Interaction) -> None + def remove_interaction(self, a: Element, b: Element, interaction: Optional[Interaction] = None) -> None: """Remove the interaction between two elements. Parameters @@ -432,18 +425,20 @@ def remove_interaction(self, a, b, interaction=None): if interaction: raise NotImplementedError - edge = a.graph_node, b.graph_node + elements = list(self.elements()) + elements[b.graphnode]._is_dirty = True + + edge = a.graphnode, b.graphnode if self.graph.has_edge(edge): self.graph.delete_edge(edge) return - edge = b.graph_node, a.graph_node + edge = b.graphnode, a.graphnode if self.graph.has_edge(edge): self.graph.delete_edge(edge) return - def assign_material(self, material, element=None, elements=None): - # type: (Material, Element | None, list[Element] | None) -> None + def assign_material(self, material: Material, element: Optional[Element] = None, elements: Optional[list[Element]] = None) -> None: """Assign a material to an element or a list of elements. Parameters @@ -491,8 +486,7 @@ def assign_material(self, material, element=None, elements=None): # Accessors # ============================================================================= - def elements(self): - # type: () -> Generator[Element] + def elements(self) -> Generator[Element, None, None]: """Yield all the elements contained in the model. Yields @@ -502,8 +496,7 @@ def elements(self): """ return iter(self._guid_element.values()) - def materials(self): - # type: () -> Generator[Material] + def materials(self) -> Generator[Material, None, None]: """Yield all the materials contained in the model. Yields @@ -513,8 +506,7 @@ def materials(self): """ return iter(self._guid_material.values()) - def interactions(self): - # type: () -> Generator[Interaction] + def interactions(self) -> Generator[Interaction, None, None]: """Yield all interactions between all elements in the model. Yields @@ -524,8 +516,7 @@ def interactions(self): """ return self._graph.interactions() - def elements_connected_by(self, interaction_type): - # type: (Type[Interaction]) -> list[list[Element]] + def elements_connected_by(self, interaction_type: Type[Interaction]) -> list[list[Element]]: """Find groups of elements connected by a specific type of interaction. Parameters diff --git a/src/compas_model/notebook/scene/__init__.py b/src/compas_model/notebook/scene/__init__.py index 3893b126..e219c18d 100644 --- a/src/compas_model/notebook/scene/__init__.py +++ b/src/compas_model/notebook/scene/__init__.py @@ -18,7 +18,8 @@ def register_scene_objects(): register(BlockElement, ThreeBlockObject, context="Notebook") register(Model, ThreeModelObject, context="Notebook") - print("PyThreeJS Model elements registered.") + + # print("PyThreeJS Model elements registered.") __all__ = [