Repository: compas-dev/compas (language: python)

Issue:
Assembly suggestions
A few small changes to the assembly classes.


### What type of change is this?

- [ ] Bug fix in a **backwards-compatible** manner.
- [ ] New feature in a **backwards-compatible** manner.
- [ ] Breaking change: bug fix or new feature that involve incompatible API changes.
- [ ] Other (e.g. doc update, configuration, etc)

### Checklist

_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._

- [ ] I added a line to the `CHANGELOG.md` file in the `Unreleased` section under the most fitting heading (e.g. `Added`, `Changed`, `Removed`).
- [ ] I ran all tests on my computer and it's all green (i.e. `invoke test`).
- [ ] I ran lint on my computer and there are no errors (i.e. `invoke lint`).
- [ ] I added new functions/classes and made them available on a second-level import, e.g. `compas.datastructures.Mesh`.
- [ ] I have added tests that prove my fix is effective or that my feature works.
- [ ] I have added necessary documentation (if appropriate)



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f593f482f8c..11e015b7026 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 * Added `compas_rhino.DEFAULT_VERSION`.
 * Added `clean` option to `compas_rhino.install` to remove existing symlinks if they cannot be imported from the current environment.
+* Added basic implementation of `compas.datastructures.Assembly`.
 * Added `compas.is_grasshopper`.
 * Added `compas.GH`.
 * Added `compas.artists.Artist.CONTEXT`.
@@ -249,6 +250,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 * Added `compas_rhino.geometry.RhinoCylinder`.
 * Added `compas_rhino.geometry.RhinoPolyline`.
 * Added `compas_rhino.geometry.RhinoSphere`.
+* Added basic implementation of `compas.datastructures.Assembly`.
 * Added `meshes` method to artists of `compas.robots.RobotModel`.
 * Added `FrameArtist` class to `compas_blender`.
 
diff --git a/docs/tutorial/assembly.rst b/docs/tutorial/assembly.rst
new file mode 100644
index 00000000000..ad889ec8eb4
--- /dev/null
+++ b/docs/tutorial/assembly.rst
@@ -0,0 +1,37 @@
+**********
+Assemblies
+**********
+
+.. rst-class:: lead
+
+The :class:`Assembly` data structure provides functionality for modelling and managing
+the connections between the individual components of an ... assembly.
+Each component is either a part defined by geometrical objects, or another assembly.
+
+
+Basic Usage
+===========
+
+.. code-block:: python
+
+    from compas.datastructures import Part
+    from compas.datastructures import Assembly
+    from compas.geometry import Box
+
+    assembly = Assembly()
+
+
+Find Parts
+==========
+
+.. code-block:: python
+
+    pass
+
+
+Access Data
+===========
+
+.. code-block:: python
+
+    pass
diff --git a/docs/tutorial/assembly_blocks.py b/docs/tutorial/assembly_blocks.py
new file mode 100644
index 00000000000..f21a4ea1acf
--- /dev/null
+++ b/docs/tutorial/assembly_blocks.py
@@ -0,0 +1,36 @@
+from math import radians
+
+from compas.datastructures import Assembly
+from compas.datastructures import Part
+from compas.geometry import Box
+from compas.geometry import Cylinder
+from compas.geometry import Circle
+from compas.geometry import Plane
+from compas.geometry import Translation
+from compas.geometry import Rotation
+from compas.geometry import Frame
+
+from compas_view2.app import App
+
+assembly = Assembly()
+
+a = Part(name='A',
+         geometry=Box.from_width_height_depth(1, 1, 1))
+
+b = Part(name='B',
+         frame=Frame([0, 0, 1], [1, 0, 0], [0, 1, 0]),
+         shape=Box.from_width_height_depth(1, 1, 1),
+         features=[(Cylinder(Circle(Plane.worldXY(), 0.2), 1.0), 'difference')])
+
+b.transform(Rotation.from_axis_and_angle([0, 0, 1], radians(45)))
+b.transform(Translation.from_vector([0, 0, 1]))
+
+assembly.add_part(a)
+assembly.add_part(b)
+
+assembly.add_connection(a, b)
+
+viewer = App()
+viewer.add(b.geometry)
+viewer.add(b.frame)
+viewer.show()
diff --git a/docs/tutorial/assembly_robot.py b/docs/tutorial/assembly_robot.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/docs/tutorial/assembly_splines.py b/docs/tutorial/assembly_splines.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/compas/datastructures/__init__.py b/src/compas/datastructures/__init__.py
index b41510cc8fa..0757f07b188 100644
--- a/src/compas/datastructures/__init__.py
+++ b/src/compas/datastructures/__init__.py
@@ -19,6 +19,8 @@
     Mesh
     Network
     VolMesh
+    Assembly
+    Part
 
 
 Functions
@@ -171,8 +173,16 @@
     volmesh_transformed
 
 
+Assembly
+--------
+
+.. autosummary::
+    :toctree: generated/
+    :nosignatures:
+
 """
 from __future__ import absolute_import
+
 import compas
 
 from .datastructure import Datastructure
@@ -284,6 +294,11 @@
     volmesh_transformed
 )
 
+from .assembly import (
+    Assembly,
+    Part
+)
+
 if not compas.IPY:
     from .network import (
         network_adjacency_matrix,
@@ -421,6 +436,9 @@
     'volmesh_bounding_box',
     'volmesh_transform',
     'volmesh_transformed',
+    # Assemblies
+    'Assembly',
+    'Part',
 ]
 
 if not compas.IPY:
diff --git a/src/compas/datastructures/assembly/__init__.py b/src/compas/datastructures/assembly/__init__.py
new file mode 100644
index 00000000000..700d7769c21
--- /dev/null
+++ b/src/compas/datastructures/assembly/__init__.py
@@ -0,0 +1,6 @@
+from __future__ import print_function
+from __future__ import absolute_import
+from __future__ import division
+
+from .assembly import Assembly  # noqa: F401 F403
+from .part import Part  # noqa: F401 F403
diff --git a/src/compas/datastructures/assembly/assembly.py b/src/compas/datastructures/assembly/assembly.py
new file mode 100644
index 00000000000..23d15fc7deb
--- /dev/null
+++ b/src/compas/datastructures/assembly/assembly.py
@@ -0,0 +1,166 @@
+from __future__ import print_function
+from __future__ import absolute_import
+from __future__ import division
+
+from ..datastructure import Datastructure
+from ..graph import Graph
+from .exceptions import AssemblyError
+
+
+class Assembly(Datastructure):
+    """A data structure for managing the connections between different parts of an assembly.
+
+    Attributes
+    ----------
+    attributes: dict
+        General attributes of the assembly that will be included in the data dict.
+    graph: :class:`compas.datastructures.Graph`
+        The graph that is used under the hood to store the parts and their connections.
+    """
+
+    @property
+    def DATASCHEMA(self):
+        import schema
+        return schema.Schema({
+            "attributes": dict,
+            "graph": Graph,
+        })
+
+    @property
+    def JSONSCHEMANAME(self):
+        return 'assembly'
+
+    def __init__(self, name=None, **kwargs):
+        super(Assembly, self).__init__()
+        self.attributes = {'name': name or 'Assembly'}
+        self.attributes.update(kwargs)
+        self.graph = Graph()
+        self._parts = {}
+
+    def __str__(self):
+        tpl = "<Assembly with {} parts and {} connections>"
+        return tpl.format(self.graph.number_of_nodes(), self.graph.number_of_edges())
+
+    @property
+    def name(self):
+        """str : The name of the assembly."""
+        return self.attributes.get('name') or self.__class__.__name__
+
+    @name.setter
+    def name(self, value):
+        self.attributes['name'] = value
+
+    @property
+    def data(self):
+        """dict : A data dict representing the assembly data structure for serialization.
+        """
+        data = {
+            'attributes': self.attributes,
+            'graph': self.graph.data,
+        }
+        return data
+
+    @data.setter
+    def data(self, data):
+        self.attributes.update(data['attributes'] or {})
+        self.graph.data = data['graph']
+
+    def add_part(self, part, key=None, **kwargs):
+        """Add a part to the assembly.
+
+        Parameters
+        ----------
+        part: :class:`compas.datastructures.Part`
+            The part to add.
+        key: int or str, optional
+            The identifier of the part in the assembly.
+            Note that the key is unique only in the context of the current assembly.
+            Nested assemblies may have the same ``key`` value for one of their parts.
+            Default is ``None`` in which case the key will be automatically assigned integer value.
+        kwargs: dict
+            Additional named parameters collected in a dict.
+
+        Returns
+        -------
+        int or str
+            The identifier of the part in the current assembly graph.
+
+        """
+        if part.guid in self._parts:
+            raise AssemblyError('Part already added to the assembly')
+        key = self.graph.add_node(key=key, part=part, **kwargs)
+        part.key = key
+        self._parts[part.guid] = part
+        return key
+
+    def add_connection(self, a, b, **kwargs):
+        """Add a connection between two parts.
+
+        Parameters
+        ----------
+        a: :class:`compas.datastructures.Part`
+            The "from" part.
+        b: :class:`compas.datastructures.Part`
+            The "to" part.
+        kwargs: dict
+            Additional named parameters collected in a dict.
+
+        Returns
+        -------
+        tuple of str or int
+            The tuple of node identifiers that identifies the connection.
+
+        Raises
+        ------
+        :class:`AssemblyError`
+            If ``a`` and/or ``b`` are not in the assembly.
+        """
+        if a.key is None or b.key is None:
+            raise AssemblyError('Both parts have to be added to the assembly before a connection can be created.')
+        if not self.graph.has_node(a.key) or not self.graph.has_node(b.key):
+            raise AssemblyError('Both parts have to be added to the assembly before a connection can be created.')
+        return self.graph.add_edge(a.key, b.key, **kwargs)
+
+    def parts(self):
+        """The parts of the assembly.
+
+        Yields
+        ------
+        :class:`compas.datastructures.Part`
+            The individual parts of the assembly.
+        """
+        for node in self.graph.nodes():
+            yield self.graph.node_attribute(node, 'part')
+
+    def connections(self, data=False):
+        """Iterate over the connections between the parts.
+
+        Parameters
+        ----------
+        data : bool, optional
+            If ``True``, yield both the identifier and the attributes of each connection.
+
+        Yields
+        ------
+        tuple
+            The next connection identifier (u, v), if ``data`` is ``False``.
+            Otherwise, the next connector identifier and its attributes as a ((u, v), attr) tuple.
+        """
+        return self.graph.edges(data)
+
+    def find(self, guid):
+        """Find a part in the assembly by its GUID.
+
+        Parameters
+        ----------
+        guid: str
+            A globally unique identifier.
+            This identifier is automatically assigned when parts are created.
+
+        Returns
+        -------
+        :class:`compas.datastructures.Part` or None
+            The identified part, if any.
+
+        """
+        return self._parts.get(guid)
diff --git a/src/compas/datastructures/assembly/exceptions.py b/src/compas/datastructures/assembly/exceptions.py
new file mode 100644
index 00000000000..a1070c180d5
--- /dev/null
+++ b/src/compas/datastructures/assembly/exceptions.py
@@ -0,0 +1,10 @@
+class AssemblyError(Exception):
+    pass
+
+
+class FrameError(Exception):
+    pass
+
+
+class FeatureError(Exception):
+    pass
diff --git a/src/compas/datastructures/assembly/part.py b/src/compas/datastructures/assembly/part.py
new file mode 100644
index 00000000000..a6d3b3ac76d
--- /dev/null
+++ b/src/compas/datastructures/assembly/part.py
@@ -0,0 +1,198 @@
+from __future__ import print_function
+from __future__ import absolute_import
+from __future__ import division
+
+from collections import deque
+# from functools import reduce
+
+from compas.geometry import Frame
+from compas.geometry import Polyhedron as Shape
+from compas.geometry import Transformation
+# from compas.geometry import multiply_matrices
+from compas.geometry import boolean_union_mesh_mesh
+from compas.geometry import boolean_difference_mesh_mesh
+from compas.geometry import boolean_intersection_mesh_mesh
+
+from ..datastructure import Datastructure
+from ..mesh import Mesh
+
+from .exceptions import FeatureError
+
+
+class Part(Datastructure):
+    """A data structure for representing assembly parts.
+
+    Parameters
+    ----------
+    name : str, optional
+        The name of the part.
+        The name will be stored in :attr:`Part.attributes`.
+    frame : :class:`compas.geometry.Frame`, optional
+        The local coordinate system of the part.
+    shape : :class:`compas.geometry.Shape`, optional
+        The base shape of the part geometry.
+    features : list of tuple(:class:`compas.geometry.Shape`, str), optional
+        The features to be added to the base shape of the part geometry.
+
+    Attributes
+    ----------
+    attributes : dict
+        General object attributes that will be included in the data dict.
+    key : int or str
+        The identifier of the part in the connectivity graph of the parent assembly.
+    frame : :class:`compas.geometry.Frame`
+        The local coordinate system of the part.
+    shape : :class:`compas.geometry.Shape`
+        The base shape of the part geometry.
+    features : list of tuple(:class:`compas.geometry.Shape`, str)
+        The features added to the base shape of the part geometry.
+    transformations : deque of :class:`compas.geometry.Transformation`
+        The stack of transformations applied to the part geometry.
+        The most recent transformation is on the left of the stack.
+        All transformations are with respect to the local coordinate system.
+    """
+
+    operations = {
+        'union': boolean_union_mesh_mesh,
+        'difference': boolean_difference_mesh_mesh,
+        'intersection': boolean_intersection_mesh_mesh
+    }
+
+    @property
+    def DATASCHEMA(self):
+        import schema
+        return schema.Schema({
+            "attributes": dict,
+            "key": int,
+            "frame": Frame,
+            "shape": Shape,
+            "features": list,
+            "transformations": list,
+        })
+
+    @property
+    def JSONSCHEMANAME(self):
+        return 'part'
+
+    def __init__(self, name=None, frame=None, shape=None, features=None, **kwargs):
+        super(Part, self).__init__()
+        self._frame = None
+        self.attributes = {'name': name or 'Part'}
+        self.attributes.update(kwargs)
+        self.key = None
+        self.frame = frame
+        self.shape = shape or Shape([], [])
+        self.features = features or []
+        self.transformations = deque()
+
+    def __str__(self):
+        tpl = "<Part with shape {} and features {}>"
+        return tpl.format(self.shape, self.features)
+
+    @property
+    def name(self):
+        """str : The name of the part."""
+        return self.attributes.get('name') or self.__class__.__name__
+
+    @name.setter
+    def name(self, value):
+        self.attributes['name'] = value
+
+    @property
+    def data(self):
+        """dict : A data dict representing the part attributes, the assembly graph identifier, the local coordinate system,
+        the base shape, the shape features, and the transformation tack wrt to the local coordinate system.
+        """
+        data = {
+            'attributes': self.attributes,
+            "key": self.key,
+            "frame": self.frame.data,
+            "shape": self.shape.data,
+            "features": [(shape.data, operation) for shape, operation in self.features],
+            "transformations": [T.data for T in self.transformations],
+        }
+        return data
+
+    @data.setter
+    def data(self, data):
+        self.attributes.update(data['attributes'] or {})
+        self.key = data['key']
+        self.frame.data = data['frame']
+        self.shape.data = data['shape']
+        self.features = [(Shape.from_data(shape), operation) for shape, operation in data['features']]
+        self.transformations = deque([Transformation.from_data(T) for T in data['transformations']])
+
+    @property
+    def frame(self):
+        if not self._frame:
+            self._frame = Frame.worldXY()
+        return self._frame
+
+    @frame.setter
+    def frame(self, frame):
+        self._frame = frame
+
+    @property
+    def geometry(self):
+        # TODO: this is a temp solution
+        # TODO: add memoization or some other kind of caching
+        A = Mesh.from_shape(self.shape)
+        for shape, operation in self.features:
+            A.quads_to_triangles()
+            B = Mesh.from_shape(shape)
+            B.quads_to_triangles()
+            A = Part.operations[operation](A.to_vertices_and_faces(), B.to_vertices_and_faces())
+        geometry = Shape(*A)
+        T = Transformation.from_frame_to_frame(Frame.worldXY(), self.frame)
+        geometry.transform(T)
+        return geometry
+
+    def transform(self, T):
+        """Transform the part with respect to the local cooordinate system.
+
+        Parameters
+        ----------
+        T : :class:`compas.geometry.Transformation`
+        """
+        self.transformations.appendleft(T)
+        self.shape.transform(T)
+        for shape, operation in self.features:
+            shape.transform(T)
+
+    def add_feature(self, shape, operation):
+        """Add a feature to the shape of the part and the operation through which it should be integrated.
+
+        Parameters
+        ----------
+        shape : :class:`compas.geometry.Shape`
+            The shape of the feature.
+        operation : {'union', 'difference', 'intersection'}
+            The boolean operation through which the feature should be integrated in the base shape.
+        """
+        if operation not in Part.operations:
+            raise FeatureError
+        self.features.append((shape, operation))
+
+    # def apply_transformations(self):
+    #     """Apply all transformations to the part shape."""
+    #     X = Transformation.from_frame(self.frame)
+    #     transformations = self.transformations[:]
+    #     transformations.append(X)
+    #     T = reduce(multiply_matrices, transformations)
+    #     self.shape.transform(T)
+
+    def to_mesh(self, cls=None):
+        """Convert the part geometry to a mesh.
+
+        Parameters
+        ----------
+        cls : :class:`compas.datastructures.Mesh`, optional
+            The type of mesh to be used for the conversion.
+
+        Returns
+        -------
+        :class:`compas.datastructures.Mesh`
+            The resulting mesh.
+        """
+        cls = cls or Mesh
+        return cls.from_shape(self.geometry)


Test patch:
diff --git a/tests/compas/datastructures/test_assembly.py b/tests/compas/datastructures/test_assembly.py
new file mode 100644
index 00000000000..943f906b41f
--- /dev/null
+++ b/tests/compas/datastructures/test_assembly.py
@@ -0,0 +1,43 @@
+from compas.datastructures import Assembly, assembly
+from compas.datastructures import Part
+
+
+def test_init():
+    assembly = Assembly(name='abc')
+    assert assembly.name == 'abc'
+
+    assembly = Assembly(attr1='value', attr2=3.14)
+    assert assembly.attributes['attr1'] == 'value'
+    assert assembly.attributes['attr2'] == 3.14
+
+
+def test_add_parts():
+    assembly = Assembly()
+
+    for _ in range(3):
+        assembly.add_part(Part())
+
+    assert len(list(assembly.parts())) == 3
+
+
+def test_add_connections():
+    assembly = Assembly()
+    parts = [Part() for i in range(3)]
+
+    for part in parts:
+        assembly.add_part(part)
+
+    assembly.add_connection(parts[0], parts[1])
+    assembly.add_connection(parts[1], parts[2])
+    assembly.add_connection(parts[2], parts[0])
+
+    assert list(assembly.connections()) == [(0, 1), (1, 2), (2, 0)]
+
+
+def test_find():
+    assembly = Assembly()
+    part = Part()
+    assert assembly.find(part.guid) is None
+
+    assembly.add_part(part)
+    assert assembly.find(part.guid) == part