Repository: magpylib/magpylib (language: python)

Issue:
Nesting Collection behavior
Consider the following code:

```
x = magpy.Sensor()
coll = magpy.Collection(x)
super = magpy.Collection(coll)

# now moving super:
super.move((1,1,1))
```
**bad result:**
x.position is (1,1,1)
coll.position is (0,0,0)
super.position is (1,1,1)

`coll` was added to `super` but it did not move with it.

Following our *compound philosophy* i think that the `coll` collection base geo should have been manipulated !!!

**proposal**
- allow collections as children (maybe hidden ? maybe in a separate list ?)
- but these collection will be ignored for field computation

When this issue is fixed, fix the doku example: examples_03_collections.html




Discussion / hints:
Are you sure about the [4.0](https://github.com/magpylib/magpylib/milestone/1) Milestone?

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 989ed98e4..63b7cd6d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ This is a major update that includes
 - New `CylinderSegment` class with dimension `(r1,r2,h,phi1,phi2)` with the inner radius `r1`, the outer radius `r2` the height `h` and the cylinder section angles `phi1 < phi2`. ([#386](https://github.com/magpylib/magpylib/issues/386), [#385](https://github.com/magpylib/magpylib/issues/385), [#484](https://github.com/magpylib/magpylib/pull/484), [#480](https://github.com/magpylib/magpylib/issues/480))
 - New `CustomSource` class for user defined field functions ([#349](https://github.com/magpylib/magpylib/issues/349), [#409](https://github.com/magpylib/magpylib/issues/409), [#411](https://github.com/magpylib/magpylib/pull/411))
 - All Magpylib objects can now be initialized without excitation and dimension attributes.
+- All classes now have the `parent` attribute to reference to a collection they are part of. Any object can only have a single parent.
+- All classes have the `describe` method which gives a quick object property overview.
 
 ### Field computation changes/fixes:
 - New computation core. Added top level subpackage `magpylib.core` where all field implementations can be accessed directly without the position/orienation interface. ([#376](https://github.com/magpylib/magpylib/issues/376))
@@ -60,6 +62,8 @@ This is a major update that includes
 ### New documentation:
 - Completely new structure and layout. ([#399](https://github.com/magpylib/magpylib/issues/399), [#294](https://github.com/magpylib/magpylib/issues/294))
 - Binder links and live code. ([#389](https://github.com/magpylib/magpylib/issues/389))
+- Example galleries with practical user examples
+- Guidelines for advanced subclassing of `Collection` to form complex dynamic compound objects that seamlessly integrate into the MAgpylib interface.
 
 ### Geometry interface modification
 - Added all scipy Rotation forms as rotation object methods. ([#427](https://github.com/magpylib/magpylib/pull/427))
@@ -70,12 +74,16 @@ This is a major update that includes
 - Removed `increment` argument from `move` and `rotate` functions ([#438](https://github.com/magpylib/magpylib/discussions/438), [#444](https://github.com/magpylib/magpylib/issues/444))
 
 ### Modifications to the `Collection` class
-- Collections can now have `sources`, `sensors` or both types. The `getB` and `getH` functions accommodate for all cases. ([#410](https://github.com/magpylib/magpylib/issues/410), [#415](https://github.com/magpylib/magpylib/pull/415), [#297](https://github.com/magpylib/magpylib/issues/297))
-- Instead of `Collection.sources` there is now `Collection.children`, `Collection.sources` and `Collection.sensors`. ([#446](https://github.com/magpylib/magpylib/issues/446))
-- `Collection` has it's own `position`, `orientation` and `style`. This is useful to build **compound objects**. ([#444](https://github.com/magpylib/magpylib/issues/444), [#461](https://github.com/magpylib/magpylib/issues/461))
+- Collections can now contain source, `Sensor` and other `Collection` objects and can function as source and observer inputs in `getB` and `getH`. ([#410](https://github.com/magpylib/magpylib/issues/410), [#415](https://github.com/magpylib/magpylib/pull/415), [#297](https://github.com/magpylib/magpylib/issues/297))
+- Instead of the property `Collection.sources` there are now the `Collection.children`, `Collection.sources`, `Collection.sensors` and `Collection.collections` properties. Setting these collection properties will automatically override parents. ([#446](https://github.com/magpylib/magpylib/issues/446), [#502](https://github.com/magpylib/magpylib/pull/502))
+- `Collection` has it's own `position`, `orientation` and `style`. ([#444](https://github.com/magpylib/magpylib/issues/444), [#461](https://github.com/magpylib/magpylib/issues/461))
+- All methods applied to a collection maintain relative child-positions in the local reference frame.
 - Added `__len__` dunder for `Collection`, so that `Collection.children` length is returned. ([#383](https://github.com/magpylib/magpylib/issues/383))
-- Added `__radd__` dunder to build collections with `sum`. [#468](https://github.com/magpylib/magpylib/pull/468)
-- `move` and `rotate` methods maintain collection geometry when applied to a collection.
+- `-` operation was removed.
+- `+` operation now functions as `a + b = Collection(a, b)`. Warning: `a + b + c` now creates a nested collection !
+- Allowed `Collection`, `add` and  `remove` input is now only `*args` or a single flat list or tuple of Magpylib objects.
+- `add` and `remove` have some additional functionality related to child-parent relations.
+- The `describe` method gives a great Collection tree overview.
 
 ### Other changes/fixes:
 - Magpylib error message improvement. Msg will now tell you what input is expected.
diff --git a/docs/_pages/page_01_introduction.md b/docs/_pages/page_01_introduction.md
index e2df39474..f9f99216b 100644
--- a/docs/_pages/page_01_introduction.md
+++ b/docs/_pages/page_01_introduction.md
@@ -15,7 +15,7 @@ kernelspec:
 
 # Introduction
 
-This section gives an introduction to the Magpylib API. Many practical examples how to use Magpylib can be found in the example galleries. Detailed package, class, method and function documentations are found in the {ref}`modindex`.
+This section gives an introduction to the Magpylib API. More detailed information and practical examples how to use Magpylib can be found in the example galleries. The package documentation is found in the {ref}`modindex`.
 
 ## Contents
 
@@ -36,9 +36,8 @@ This section gives an introduction to the Magpylib API. Many practical examples
 Magpylib provides fast and accurate magnetostatic field computation based on **analytical solutions** to permanent magnet and current problems. The field computation is coupled to a **position and orientation interface** that makes it easy to work with relative object positioning. The idea behind the main object oriented interface is:
 
 1. Sensors, magnets, currents, etc. are created as Python objects with defined position and orientation in a global coordinate system.
-2. After initialization, the Magpylib objects can easily be manipulated, grouped and moved around.
-3. All objects can be displayed graphically using Matplotlib or Plotly 3D plotting.
-4. Finally, the magnetic field generated by the source objects is computed at given observers.
+2. After initialization, the Magpylib objects can easily be manipulated, grouped, moved around and displayed graphically using Matplotlib or Plotly 3D plotting.
+3. When all objects are set up, the magnetic field generated by the source objects is computed at the observer objects.
 
 The following example code outlines this functionality:
 
@@ -52,19 +51,19 @@ cube = magpy.magnet.Cuboid(magnetization=(0,0,100), dimension=(1,1,1))
 loop = magpy.current.Loop(current=5, diameter=3, position=(0,0,-3))
 sensor = magpy.Sensor(position=(0,0,2), style_size=1.8)
 
-# 2. move objects around, define object paths
+# 2. move objects and display graphically
+sensor.rotate_from_rotvec((0,0,225), degrees=True)
 cube.position = np.linspace((-3,0,0), (3,0,0), 50)
-cube.rotate_from_angax(angle=np.linspace(0,180,50), axis='z', start=0)
 loop.move(np.linspace((0,0,0), (0,0,6), 50), start=0)
-sensor.rotate_from_rotvec((0,0,225), degrees=True)
 
-# 3. display system graphically
 magpy.show(loop, cube, sensor, backend='plotly', animation=2, style_path_show=False)
 
-# 4. compute field at sensor (and plot with Matplotlib)
+# 3. compute field at sensor (and plot with Matplotlib)
 B = sensor.getB(cube, loop, sumup=True)
 
-plt.plot(B)
+plt.plot(B, label=['Bx', 'By', 'Bz'])
+plt.legend()
+plt.grid(color='.8')
 plt.show()
 ```
 
@@ -74,45 +73,45 @@ For users who would like to avoid the object oriented interface, the field imple
 
 ## When can you use Magpylib ?
 
-The analytical solutions are exact when there is no material response and natural boundary conditions can be assumed. In general, Magpylib is at its best when dealing with air-coils (no eddy currents) and high grade permanent magnet assemblies (Ferrite, NdFeB, SmCo or similar materials).
+The analytical solutions are exact when there is no material response and natural boundary conditions can be assumed. In general, Magpylib is at its best when dealing with air-coils (no eddy currents) and high grade permanent magnets (Ferrite, NdFeB, SmCo or similar materials).
 
-When magnet permeabilities are below $\mu_r < 1.1$ the error typically undercuts 1-5 % (long magnet shapes are better, large distance from magnet is better). Demagnetization factors are not automatically included at this point. The line current solutions give the exact same field as outside of a wire that carries a homogenous current. For more details check out the {ref}`physComp` section.
+When **magnet** permeabilities are below $\mu_r < 1.1$ the error typically undercuts 1-5 % (long magnet shapes are better, large distance from magnet is better). Demagnetization factors are not automatically included at this point. The line **current** solutions give the exact same field as outside of a wire that carries a homogenous current. For more details check out the {ref}`physComp` section.
 
-Magpylib only provides solutions for simple geometric forms (cuboids, cylinders, lines, ...). How complex shapes can be constructed from these simple base shapes is described in {ref}`examples-complex-forms`.
+Magpylib only provides solutions for simple geometric forms (cuboids, cylinders, lines, ...). How **complex shapes** can be constructed from these simple base shapes is described in {ref}`examples-complex-forms`.
 
 (intro-magpylib-objects)=
 
 ## The Magpylib objects
 
-The most convenient way for working with Magpylib is through the **object oriented interface**. Magpylib objects represent magnetic field sources, sensors and collections with various defining attributes and methods. The following classes are implemented:
+The most convenient way of working with Magpylib is through the **object oriented interface**. Magpylib objects represent magnetic field sources, sensors and collections with various defining attributes and methods. By default all objects are initialized with `position=(0,0,0)` and `orientation=None`. The following classes are implemented:
 
 **Magnets**
 
-All magnet objects have the `magnetization` attribute which must be of the format $(m_x, m_y, m_z)$ and denotes the homogeneous magnetization vector in units of \[mT\]. It is often referred to as the remanence ($B_r=\mu_0 M$) in material data sheets. All magnets can be used as Magpylib `sources` input.
+All magnet objects have the `magnetization` attribute which must be of the format $(m_x, m_y, m_z)$ and denotes the homogeneous magnetization vector in the local object coordinates in units of \[mT\]. It is often referred to as the remanence ($B_r=\mu_0 M$) in material data sheets. All magnets can be used as Magpylib `sources` input.
 
-- **`Cuboid`**`(magnetization, dimension, position, orientation, style)` represents a magnet with cuboid shape. `dimension` has the format $(a,b,c)$ and denotes the sides of the cuboid in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the cuboid lies in the origin of the global coordinates, and the sides are parallel to the coordinate axes.
+- **`Cuboid`**`(magnetization, dimension, position, orientation)` represents a magnet with cuboid shape. `dimension` has the format $(a,b,c)$ and denotes the sides of the cuboid in units of \[mm\]. By default the center of the cuboid lies in the origin of the global coordinates, and the sides are parallel to the coordinate axes.
 
-- **`Cylinder`**`(magnetization, dimension, position, orientation, style)` represents a magnet with cylindrical shape. `dimension` has the format $(d,h)$ and denotes diameter and height of the cylinder in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis.
+- **`Cylinder`**`(magnetization, dimension, position, orientation)` represents a magnet with cylindrical shape. `dimension` has the format $(d,h)$ and denotes diameter and height of the cylinder in units of \[mm\]. By default the center of the cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis.
 
-- **`CylinderSegment`**`(magnetization, dimension, position, orientation, style)` represents a magnet with the shape of a cylindrical ring section. `dimension` has the format $(r_1,r_2,h,\varphi_1,\varphi_2)$ and denotes inner radius, outer radius and height in units of \[mm\] and the two section angles $\varphi_1<\varphi_2$ in \[deg\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the full cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis.
+- **`CylinderSegment`**`(magnetization, dimension, position, orientation)` represents a magnet with the shape of a cylindrical ring section. `dimension` has the format $(r_1,r_2,h,\varphi_1,\varphi_2)$ and denotes inner radius, outer radius and height in units of \[mm\] and the two section angles $\varphi_1<\varphi_2$ in \[deg\]. By default the center of the full cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis.
 
-- **`Sphere`**`(magnetization, diameter, position, orientation, style)` represents a magnet of spherical shape. `diameter` is the sphere diameter $d$ in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the sphere lies in the origin of the global coordinates.
+- **`Sphere`**`(magnetization, diameter, position, orientation)` represents a magnet of spherical shape. `diameter` is the sphere diameter $d$ in units of \[mm\]. By default the center of the sphere lies in the origin of the global coordinates.
 
 **Currents**
 
 All current objects have the `current` attribute which must be a scalar $i_0$ and denotes the electrical current in units of \[A\]. All currents can be used as Magpylib `sources` input.
 
-- **`Loop`**`(current, diameter, position, orientation, style)` represents a circular current loop where `diameter` is the loop diameter $d$ in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the loop lies in the xy-plane with its center in the origin of the global coordinates.
+- **`Loop`**`(current, diameter, position, orientation)` represents a circular current loop where `diameter` is the loop diameter $d$ in units of \[mm\]. By default the loop lies in the xy-plane with it's center in the origin of the global coordinates.
 
-- **`Line`**`(current, vertices, position, orientation, style)` represents electrical current segments that flow in a straight line from vertex to vertex. By default (`position=(0,0,0)`, `orientation=None`) the locally defined vertices have the same position in the global coordinates.
+- **`Line`**`(current, vertices, position, orientation)` represents electrical current segments that flow in a straight line from vertex to vertex. By default the verticx positions coincide in the local object coordinates and the global coordinates.
 
 **Other**
 
-- **`Dipole`**`(moment, position, orientation, style)` represents a magnetic dipole moment with moment $(m_x,m_y,m_z)$ given in \[mT mm³]. For homogeneous magnets the relation moment=magnetization$\times$volume holds. Can be used as Magpylib `sources` input.
+- **`Dipole`**`(moment, position, orientation)` represents a magnetic dipole moment with moment $(m_x,m_y,m_z)$ given in \[mT mm³]. For homogeneous magnets the relation moment=magnetization$\times$volume holds. Can be used as Magpylib `sources` input.
 
-- **`CustomSource`**`(field_B_lambda, field_H_lambda, position, orientation, style)` can be used to create user defined custom sources. Can be used as Magpylib `sources` input.
+- **`CustomSource`**`(field_B_lambda, field_H_lambda, position, orientation)` is used to create user defined custom sources with their own field functions. Can be used as Magpylib `sources` input.
 
-- **`Sensor`**`(position, pixel, orientation)` represents a 3D magnetic field sensor. The field is evaluated at the given pixel positions, by default `pixel=(0,0,0)`. Can be used as Magpylib `observers` input.
+- **`Sensor`**`(position, pixel, orientation)` represents a 3D magnetic field sensor. The field is evaluated at the given pixel positions. By default (`pixel=(0,0,0)`) the pixel position coincide in the local object coordinates and the global coordinates. Can be used as Magpylib `observers` input.
 
 - **`Collection`**`(*children, position, orientation)` is a group of source and sensor objects (children) that is used for common manipulation. Depending on the children, a collection can be used as Magpylib `sources` and/or `observers` input.
 
@@ -144,7 +143,7 @@ for obj in [magnet1, magnet2, magnet3, magnet4, current1, current2, dipole, cust
 
 ## Position and orientation
 
-All Magpylib objects have the `position` and `orientation` attributes that refer to position and orientation in the global Cartesian coordinate system. The `position` attribute is a numpy ndarray, shape (3,) or (m,3) and denotes the coordinates $(x,y,z)$ in units of [mm]. By default every object is created at `position=(0,0,0)`. The `orientation` attribute is a scipy [Rotation object](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html) and denotes the object rotation (relative to its initial state), e.g. in terms of Euler angles $(\phi, \psi, \theta)$. By default the orientation of a Magpylib object is the unit rotation, `orientation=None`.
+All Magpylib objects have the `position` and `orientation` attributes that refer to position and orientation in the global Cartesian coordinate system. The `position` attribute is a numpy ndarray, shape (3,) or (m,3) and denotes the coordinates $(x,y,z)$ in units of [mm]. By default every object is created at `position=(0,0,0)`. The `orientation` attribute is a scipy [Rotation object](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html) and denotes the object rotation relative to its initial state. By default the orientation of a Magpylib object is the unit rotation, `orientation=None`.
 
 ```python
 import magpylib as magpy
@@ -178,7 +177,7 @@ print(sensor.position)                                     # out: [2. 3. 4.]
 print(sensor.orientation.as_euler('xyz', degrees=True))    # out: [ 0.  0. 90.]
 ```
 
-The attributes `position` and `orientation` can be either of **"scalar"** nature, i.e. a single position or a single rotation like in the examples above, or **"vectors"** when they are arrays of such scalars. The two attributes together define an object **"path"**. Paths should always be used when modelling object motion as the magnetic field is computed on the whole path.
+The attributes `position` and `orientation` can be either of **"scalar"** nature, i.e. a single position or a single rotation like in the examples above, or **"vectors"** when they are arrays of such scalars. The two attributes together define an object **"path"**. Paths should always be used when modelling object motion as the magnetic field is computed on the whole path with increased performance.
 
 With vector inputs, the `move` and `rotate` methods provide *append* and *merge* functionality.  The following example shows how a path `path1` is assigned to a magnet object, how `path2` is appended with `move` and how `path3` is merged on top starting at path index 25.
 
@@ -211,9 +210,9 @@ Notice that when one of the `position` and `orientation` attributes are modified
 
 ## Graphic output
 
-When all Magpylib objects and their paths have been created, **`show`** provides a convenient way to graphically display the geometric arrangement using the Matplotlib (default) and Plotly packages. The `backend` keyword argument is used in `show` to select the graphic backend.
+Once all Magpylib objects and their paths have been created, **`show`** provides a convenient way to graphically display the geometric arrangement using the Matplotlib (default) and Plotly packages. When `show` is called, it generates a new figure which is then automatically displayed.
 
-When `show` is called, it generates a new figure which is then automatically displayed. To bring the output to a given, user-defined figure, the `canvas` kwarg is used.
+The desired graphic backend is selected with the `backend` keyword argument. To bring the output to a given, user-defined figure, the `canvas` kwarg is used. This is demonstrated in {ref}`examples-backends-canvas`.
 
 The following example shows the graphical representation of various Magpylib objects and their paths using the default Matplotlib graphic backend.
 
@@ -224,7 +223,7 @@ from magpylib.magnet import Cuboid, Cylinder, CylinderSegment, Sphere
 from magpylib.current import Loop, Line
 from magpylib.misc import Dipole
 
-sources = [
+objects = [
     Cuboid(
         magnetization=(0,100,0),
         dimension=(1,1,1),
@@ -259,36 +258,39 @@ sources = [
         moment=(0,0,100),
         position=(5,0,0),
     ),
+    magpy.Sensor(
+        pixel=[(0,0,z) for z in (-.5,0,.5)],
+        position=(7,0,0),
+    ),
 ]
-sensor = magpy.Sensor(
-    pixel=[(0,0,z) for z in (-.5,0,.5)],
-    position=(7,0,0),
-)
 
-sources[5].move(np.linspace((0,0,0), (0,0,7), 20))
-sources[0].rotate_from_angax(np.linspace(0, -90, 20), 'y', anchor=0)
+objects[5].move(np.linspace((0,0,0), (0,0,7), 20))
+objects[0].rotate_from_angax(np.linspace(0, -90, 20), 'y', anchor=0)
 
-magpy.show(sources, sensor)
+magpy.show(objects)
 ```
 
 Notice that, objects and their paths are automatically assigned different colors, the magnetization vector, current directions and dipole objects are indicated by arrows and sensors are shown as tri-colored coordinate cross with pixel as markers.
 
-How objects are represented graphically (color, line thickness, ect.) is defined by the **style**. The default style, which can be seen above, is stored in the `magpy.defaults.display.style` object. In addition, each object can have its own style attribute that takes precedence over the default setting. Some practical ways to set styles are shown in the next example:
+How objects are represented graphically (color, line thickness, ect.) is defined by the **style**. The default style, which can be seen above, is accessed and manipulated through `magpy.defaults.display.style`. In addition, each object can have an individual style, which takes precedence over the default setting. A local style override is also possible by passing style arguments directly to `show`.
+
+Some practical ways to set styles are shown in the next example:
 
 ```{code-cell} ipython3
 import magpylib as magpy
 from magpylib.magnet import Cuboid
 
+cube1 = Cuboid(magnetization=(0,0,1), dimension=(2,4,4))
+cube2 = cube1.copy(position=(3,0,0))
+cube3 = cube1.copy(position=(6,0,0))
+
 # change the default
 magpy.defaults.display.style.base.color = 'crimson'
-cube1 = Cuboid(magnetization=(0,0,1), dimension=(2,4,4))
 
-# set individual style through property
-cube2 = cube1.copy(position=(3,0,0))
+# set individual style through properties
 cube2.style.color = 'orangered'
 
-# set individual style through update with dictionary
-cube3 = cube1.copy(position=(6,0,0))
+# set individual style using update with style dictionary
 cube3.style.update({'color': 'gold'})
 
 # set individual style at initialization with underscore magic
@@ -298,7 +300,7 @@ cube4 = cube1.copy(position=(9,0,0), style_color='linen')
 magpy.show(cube1, cube2, cube3, cube4, style_magnetization_show=False)
 ```
 
-A local style override is possible by passing style arguments directly to `show`. The hierarchy that decides about the final graphic object representation, a list of all styles and all other styling options for tuning the `show`-output are described in {ref}`examples-graphic-styles` and {ref}`examples-animation`.
+The hierarchy that decides about the final graphic object representation, a list of all style parameters and other options for tuning the `show`-output are described in {ref}`examples-graphic-styles` and {ref}`examples-animation`.
 
 (intro-field-computation)=
 
@@ -309,22 +311,20 @@ Magnetic field computation in Magpylib is achieved through:
 - **`getB`**`(sources, observers)` computes the B-field seen by `observers` generated by `sources` in units of \[mT\]
 - **`getH`**`(sources, observers)` computes the H-field seen by `observers` generated by `sources` in units of \[kA/m\]
 
-The argument `sources` can be any Magpylib **source object** or a list thereof. The argument `observers` can be an array_like of position vectors with shape $(n_1,n_2,n_3,...,3)$, any Magpylib **observer object** or a list thereof. `getB` and `getH` will compute the field for all input combinations of sources, observers and paths.
+The argument `sources` can be any Magpylib **source object** or a flat list thereof. The argument `observers` can be an array_like of position vectors with shape $(n_1,n_2,n_3,...,3)$, any Magpylib **observer object** or a flat list thereof. `getB` and `getH` return the field for all combinations of sources, observers and paths.
 
-```{note}
 The output of the most general field computation `getB(sources, observers)` is an ndarray of shape `(l, m, k, n1, n2, n3, ..., 3)` where `l` is the number of input sources, `m` the (maximal) object path length, `k` the number of sensors, `n1,n2,n3,...` the sensor pixel shape or the shape of the observer position vector input and `3` the three magnetic field components $(B_x, B_y, B_z)$.
-```
 
 **Example 1:** As expressed by the old v2 slogan *"The magnetic field is only three lines of code away"*, this example demonstrates the most fundamental field computation:
 
 ```{code-cell} ipython3
 import magpylib as magpy
-src = magpy.current.Loop(current=1, diameter=2)
-B = magpy.getB(src, (1,2,3))
+loop = magpy.current.Loop(current=1, diameter=2)
+B = magpy.getB(loop, (1,2,3))
 print(B)
 ```
 
-**Example 2:** When dealing with multiple observer positions, `getB` and `getH` will return the field in the shape of the observer input. In the following example, B- and H-field of a cuboid magnet are computed on a position grid, and then displayed using Matplotlib:
+**Example 2:** When handed with multiple observer positions, `getB` and `getH` will return the field in the shape of the observer input. In the following example, B- and H-field of a cuboid magnet are computed on a position grid, and then displayed using Matplotlib:
 
 ```{code-cell} ipython3
 import numpy as np
@@ -338,9 +338,9 @@ ts = np.linspace(-3, 3, 30)
 grid = np.array([[(x,0,z) for x in ts] for z in ts])
 
 # compute B- and H-fields of a cuboid magnet on the grid
-src = magpy.magnet.Cuboid(magnetization=(500,0,500), dimension=(2,2,2))
-B = src.getB(grid)
-H = src.getH(grid)
+cube = magpy.magnet.Cuboid(magnetization=(500,0,500), dimension=(2,2,2))
+B = cube.getB(grid)
+H = cube.getH(grid)
 
 # display field with Pyplot
 ax1.streamplot(grid[:,:,0], grid[:,:,2], B[:,:,0], B[:,:,2], density=2,
@@ -364,16 +364,18 @@ import numpy as np
 import plotly.graph_objects as go
 import magpylib as magpy
 
+# reset defaults set in previous example
+magpy.defaults.reset()
+
 # setup plotly figure and subplots
 fig = go.Figure().set_subplots(rows=1, cols=2, specs=[[{"type": "scene"}, {"type": "xy"}]])
 
 # define sensor and source
-sensor = magpy.Sensor(pixel=[(0,0,-.1), (0,0,.1)], style_size=1.5)
+sensor = magpy.Sensor(pixel=[(0,0,-.2), (0,0,.2)], style_size=1.5)
 magnet = magpy.magnet.Cylinder(magnetization=(100,0,0), dimension=(1,2))
 
 # define paths
 sensor.position = np.linspace((0,0,-3), (0,0,3), 40)
-
 magnet.position = (4,0,0)
 magnet.rotate_from_angax(angle=np.linspace(0, 300, 40)[1:], axis='z', anchor=0)
 
@@ -391,7 +393,7 @@ for i,plab in enumerate(['pixel1', 'pixel2']):
 fig.show()
 ```
 
-**Example 4:** The last example demonstrates the most general form of a `getB` computation with multiple source and sensor inputs. Specifically, 3 sources, one with path length 11, and two sensors, each with pixel shape (4,5). Note that, when input objects have different path lengths, all shorter paths are padded (= objects remain "static") beyond their end.
+**Example 4:** The last example demonstrates the most general form of a `getB` computation with multiple source and sensor inputs. Specifically, 3 sources, one with path length 11, and two sensors, each with pixel shape (4,5). Note that, when input objects have different path lengths, objects with shorter paths are treated as static beyond their path end.
 
 ```{code-cell} ipython3
 import magpylib as magpy
@@ -416,15 +418,15 @@ In terms of **performance** it must be noted that Magpylib automatically vectori
 
 ## Direct interface and core
 
-The **direct interface** allows users to bypass the object oriented functionality of Magpylib. The magnetic field is computed for a set of arbitrary input instances by providing the top level functions `getB` and `getH` with
+The **direct interface** allows users to bypass the object oriented functionality of Magpylib. The magnetic field is computed for a set of $n$ arbitrary input instances by providing the top level functions `getB` and `getH` with
 
-1. a string denoting the source type for the `sources` argument,
-2. an array_like of shape (3,) or (n,3) giving the positions for the `observers` argument,
-3. a dictionary with array_likes of shape (x,) or (n,x) for all other inputs.
+1. `sources`: a string denoting the source type
+2. `observers`: array_like of shape (3,) or (n,3) giving the positions
+3. `kwargs`: a dictionary with array_likes of shape (x,) or (n,x) for all other inputs
 
-All "scalar" inputs of shape (x,) are automatically tiled up to shape (n,x), and for every of the n given instances the field is computed and returned with shape (n,3). The allowed source types are similar to the Magpylib source class names (see {ref}`intro-magpylib-objects`), and the required dictionary inputs are the respective class inputs.
+All "scalar" inputs of shape (x,) are automatically tiled up to shape (n,x), and for every of the $n$ given instances the field is computed and returned with shape (n,3). The allowed source types are similar to the Magpylib source class names (see {ref}`intro-magpylib-objects`), and the required dictionary inputs are the respective class inputs.
 
-In the following example we compute the cuboid field for 5 different position and dimension input instances and "constant" magnetization:
+In the following example we compute the cuboid field for 5 input instances, each with different position and orientation and similar magnetization:
 
 ```{code-cell} ipython3
 import magpylib as magpy
@@ -459,65 +461,87 @@ print(B)
 
 ## Collections
 
-The top level class `Collection` allows users to group sources by reference for common manipulation. Objects that are part of a collection are called **children** of that collection. All operations acting on a collection are individually applied to its children.
+The top level class `Collection` allows users to group objects by reference for common manipulation. Objects that are part of a collection become **children** of that collection, and the collection itself becomes their **parent**. An object can only have a single parent. The child-parent relation is demonstrated with the `describe` method in the following example:
 
-```python
+```{code-cell} ipython3
 import magpylib as magpy
 
-sphere = magpy.magnet.Sphere(magnetization=(1,2,3), diameter=1, position=(2,0,0))
-loop = magpy.current.Loop(current=1, diameter=1, position=(-2,0,0))
-coll = magpy.Collection(src1, src2)
-
-print(sphere.position)   # out: [ 2.  0.  0.]
-print(loop.position)     # out: [-2.  0.  0.]
-print(coll.position)     # out: [ 0.  0.  0.]
+sens = magpy.Sensor(style_label='sens')
+loop = magpy.current.Loop(style_label='loop')
+line = magpy.current.Line(style_label='line')
+cube = magpy.magnet.Cuboid(style_label='cube')
 
-coll.move(((0,0,2)))
+coll1 = magpy.Collection(sens, loop, line, style_label='coll1')
+coll2 = cube + coll1
 
-print(sphere.position)   # out: [ 2.  0.  2.]
-print(loop.position)     # out: [-2.  0.  2.]
-print(coll.position)     # out: [ 0.  0.  2.]
+coll2.describe(format='type,label')
 ```
 
-Collections function primarily like groups. Magpylib objects can be part of multiple collections. After being added to a collection, it is still possible to manipulate the individual objects by reference, and also by collection index:
+A detailed review of collection properties and construction is provided in the example gallery {ref}`examples-collections-construction`. It is specifically noteworthy in the above example, that any two Magpylib objects can simply be added up to form a collection.
 
-```python
-sphere.move((2,0,0))
-coll[1].move((-2,0,0))
-
-print(sphere.position)   # out: [ 4.  0.  2.]
-print(loop.position)     # out: [-4.  0.  2.]
-print(coll.position)     # out: [ 0.  0.  2.]
-```
+A collection object has its own `position` and `orientation` attributes and spans a local reference frame for all its children. An operation applied to a collection moves the frame, and is individually applied to all children such that their relative position in the local reference frame is maintained. This means that the collection functions only as a container for manipulation, but child position and orientation are always updated in the global coordinate system. After being added to a collection, it is still possible to manipulate the individual children, which will also move them to a new relative position in the collection frame.
 
-A detailed review of collection properties and construction is provided in the example gallery {ref}`examples-collections-construction`.
-
-Magpylib collections follow a *compound object philosophy*, which means that a collection behaves itself like a single object. Notice in the examples above, that `coll` has its own `position` and `orientation` attributes. Geometric operations acting on a collection object are individually applied to all child objects - but in such a way that the geometric compound structure is maintained. For example, applying `rotate` with `anchor=None` rotates all children about `collection.position` instead of the individual child positions. This is demonstrated in the following example:
+This enables user-friendly manipulation of groups, sub-groups and individual objects, which is demonstrated in the following example:
 
 ```{code-cell} ipython3
 import numpy as np
 import magpylib as magpy
+from magpylib.current import Loop
 
-cube1 = magpy.magnet.Cuboid((0,0,1), (1,1,1), (2,0,0))
-cube2 = cube1.copy(position=(-2,0,0))
-sensor = magpy.Sensor()
-coll = cube1 + cube2 + sensor
+# construct two coil collections from windings
+coil1 = magpy.Collection(style_label='coil1')
+for z in np.linspace(-.5, .5, 5):
+    coil1.add(Loop(current=1, diameter=20, position=(0,0,z)))
+coil1.position = (0,0,-5)
+coil2 = coil1.copy(position=(0,0,5))
+
+# helmholtz consists of two coils
+helmholtz = coil1 + coil2
+
+# move the helmholz
+helmholtz.position = np.linspace((0,0,0), (10,0,0), 30)
+helmholtz.rotate_from_angax(np.linspace(0,180,30), 'x', start=0)
+
+# move the coils
+coil1.move(np.linspace((0,0,0), ( 5,0,0), 30))
+coil2.move(np.linspace((0,0,0), (-5,0,0), 30))
+
+# move the windings
+for coil in [coil1, coil2]:
+    for i,wind in enumerate(coil):
+        wind.move(np.linspace((0,0,0), (0,0,2-i), 20))
 
-coll.move(np.linspace((0,0,0), (0,2,0), 30))
-coll.rotate_from_angax(np.linspace(0, 180, 30), 'y')
+magpy.show(*helmholtz, backend='plotly', animation=4, style_path_show=False)
+```
+
+Notice, that collections have their own `style` attributes, their paths are displayed in `show`, and all children are automatically assigned their parent color.
+
+For magnetic field computation a collection with source children behaves like a single source object, and a collection with sensor children behaves like a flat list of it's sensors when provided as `sources` and `observers` input respectively. This is demonstrated in the following continuation of the previous helmholtz example:
 
-coll.show(animation=True, style_path_show=False, backend='plotly')
+```{code-cell} ipython3
+import matplotlib.pyplot as plt
+
+B = helmholtz.getB((10,0,0))
+plt.plot(B, label=['Bx', 'By', 'Bz'])
+
+plt.gca().set(
+    title='B-field [mT] at position (10,0,0)',
+    xlabel='helmholtz path position index'
+)
+plt.gca().grid(color='.9')
+plt.gca().legend()
+plt.show()
 ```
 
-It should be noted that collections have their own `style` attributes. Their paths are displayed in `show` and all children are automatically given their parent color. An advanced tutorial how to create dynamic compound objects is given in {ref}`examples-collections-compound`.
+One central motivation behind the `Collection` class is enabling users to build **compound objects**, which refer to custom classes that inherit `Collection`. They can represent complex magnet structures like magnetic encoders, motor parts, halbach arrays, and other arrangments, and will naturally integrate into the Magpylib interface. An advanced tutorial how to sub-class `Collection` with dynamic properties and custom 3D models is given in {ref}`examples-compounds`.
 
 (intro-customization)=
 
 ## Customization
 
-**User-defined 3D models** (traces) that will be displayed by `show`, can be stored in `style.model3d.data`. A trace itself is a dictionary that contains all information necessary for plotting, and can be added with the method `style.model3d.data.add_trace`. In the example gallery {ref}`examples-3d-models` it is explained how to create custom traces with standard plotting backends such as `scatter3d` or `mesh3d` in Plotly, or `plot`, `plot_surface` and `plot_trisurf` in Matplotlib. Some pre-defined models are also provided for easy parts visualization.
+**User-defined 3D models** (traces) for any object that will be displayed by `show`, can be stored in `style.model3d.data`. A trace itself is a dictionary that contains all information necessary for plotting, and can be added with the method `style.model3d.data.add_trace`. In the example gallery {ref}`examples-3d-models` it is explained how to create custom traces with standard plotting backends such as `scatter3d` or `mesh3d` in Plotly, or `plot`, `plot_surface` and `plot_trisurf` in Matplotlib. Some pre-defined models are also provided for easy parts visualization.
 
-**User-defined source classes** are easily realized through the `CustomSource` class. Such a custom source object can be provided with user-defined field computation functions, that are stored in the attributes `field_B_lambda` and `field_H_lambda`, and will be used when `getB` and `getH` are called. The provided functions must accept position arrays with shape (n,3), and return the field with a similar shape. Details on working with custom sources are given in {ref}`examples-custom-source-objects`.
+**User-defined source objects** are easily realized with the `CustomSource` class. Such a custom source object is provided with user-defined field computation functions, that are stored in the attributes `field_B_lambda` and `field_H_lambda`, and will be used when `getB` and `getH` are called. The provided functions must accept position arrays with shape (n,3), and return the field with a similar shape. Details on working with custom sources are given in {ref}`examples-custom-source-objects`.
 
 While each of these features can be used individually, the combination of the two (own source class with own 3D representation) enables a high level of customization in Magpylib. Such user-defined objects will feel like native Magpylib objects and can be used in combination with all other features, which is demonstrated in the following example:
 
@@ -527,14 +551,14 @@ import plotly.graph_objects as go
 import magpylib as magpy
 
 # define B-field function for custom source
-def custom_field(pos):
-    """ easter field"""
+def easter_field(pos):
+    """ points in z-direction and decays with 1/r^3"""
     dist = np.linalg.norm(pos, axis=1)
     return np.c_[np.zeros((len(pos),2)), 1/dist**3]
 
 # create custom source
 egg = magpy.misc.CustomSource(
-    field_B_lambda=custom_field,
+    field_B_lambda=easter_field,
     style=dict(color='orange', model3d_showdefault=False, label='The Egg'),
 )
 
diff --git a/docs/_pages/page_03_installation.md b/docs/_pages/page_03_installation.md
index ad2cd30a3..ca60179bc 100644
--- a/docs/_pages/page_03_installation.md
+++ b/docs/_pages/page_03_installation.md
@@ -2,65 +2,28 @@
 
 # Installation
 
-## Dependencies
-
-Magpylib works with Python 3.7 or later ! The following packages will be automatically installed, or updated. See [Git Hub](https://github.com/magpylib/magpylib) for respective versions. Packages will never be downgraded.
-
-- numpy
-- matplotlib
-- scipy (.spatial.transform, .special)
-
-(plotly-installation)=
-
-## Using a package manager
-
-Magpylib works with PyPI and conda-forge repositories.
-
-Install with [pip](https://pypi.org/project/pip/),
+For fast installation with a **package manager**, Magpylib is uploaded to the PyPI and conda-forge repositories. Install with [pip](https://pypi.org/project/pip/),
 
 ```console
 pip install magpylib
 ```
 
-or with [conda](https://docs.conda.io/en/latest/)
+or with [conda](https://docs.conda.io/en/latest/).
 
 ```console
 conda install magpylib
 ```
 
-## Using Anaconda
-
-Or if you have little experience with Python we recommend using [Anaconda](https://www.anaconda.com).
-
-1. Download & install Anaconda3
-
-2. Start Anaconda Navigator
-
-3. On the interface, go to `Environments` and choose the environment you wish to install magpylib in. For this example, we will use the base environment:
-
-   > ```{image} ../_static/images/install_guide/anaconda0.png
-   > ```
-
-4. Click the arrow, and open the conda terminal
-
-   > ```{image} ../_static/images/install_guide/anaconda1.png
-   > ```
-
-5. Input the following to install from conda-forge:
-
-   > ```console
-   > conda install -c conda-forge magpylib
-   > ```
-
-6. Don't forget to select the proper environment in your IDE.
-
-   > ```{image} ../_static/images/install_guide/anaconda2.png
-   > ```
-
-## Download Sites
-
-Currently magpylib is hosted at:
+Magpylib is hosted for **download** at the following locations:
 
 - [Conda Cloud](https://anaconda.org/conda-forge/magpylib)
 - [Python Package Index](https://pypi.org/project/magpylib/)
 - [GitHub repository](https://github.com/magpylib/magpylib)
+
+The Magpylib **dependencies** are as follows (check on [GitHub](https://github.com/magpylib/magpylib) for the latest version requirements):
+
+- Python 3.7 or later
+- numpy
+- scipy (.spatial.transform, .special)
+- matplotlib
+- plotly
diff --git a/docs/_pages/page_07_physics_computation.md b/docs/_pages/page_07_physics_computation.md
index 934d4481b..c222741cd 100644
--- a/docs/_pages/page_07_physics_computation.md
+++ b/docs/_pages/page_07_physics_computation.md
@@ -8,10 +8,10 @@
 
 Magnetic field computations in Magpylib are based on known analytical solutions (formulas) to permanent magnet and current problems. For Magpylib we have used the following references:
 
-- Field of cuboid magnets: \[1999Yang, 2005Engel-Herbert, 2013Camacho\]
-- Field of cylindrical magnets: \[1994Furlani, 2009Derby, 2021Slanovc\]
-- Field of facet bodies: \[2009Janssen, 2013Rubeck\]
-- Field of circular line current: \[1950Smythe, 2001Simpson, 2017Ortner\]
+- Field of cuboid magnets: \[Yang1999, Engel-Herbert2005, Camacho2013, Cichon2019\]
+- Field of cylindrical magnets: \[Furlani1994, Derby2009, Caciagli2018, Slanovc2021\]
+- Field of facet bodies: \[Janssen2009, Rubeck2013\]
+- Field of circular line current: \[Smythe1950, Simpson2001, Ortner2017\]
 - all others derived by hand
 
 A short reflection on how these formulas can be achieved: In magnetostatics (no currents) the magnetic field becomes conservative (Maxwell: $\nabla \times {\bf H} = 0$) and can thus be expressed through the magnetic scalar potential $\Phi_m$:
@@ -36,7 +36,7 @@ $$
 {\bf B}({\bf r}) = \frac{\mu_0}{4\pi}\int_{V'} {\bf J}({\bf r}')\times \frac{{\bf r}-{\bf r}'}{|{\bf r}-{\bf r}'|^3} dV'
 $$
 
-In some special cases (simple shapes, homogeneous magnetizations and current distributions) the above integrals can be worked out directly to give analytical formulas (or simple, fast converging series). The derivations can be found in the respective references. A noteworthy comparison between the Coulombian approach and the Amperian current model is given in \[2009Ravaud\].
+In some special cases (simple shapes, homogeneous magnetizations and current distributions) the above integrals can be worked out directly to give analytical formulas (or simple, fast converging series). The derivations can be found in the respective references. A noteworthy comparison between the Coulombian approach and the Amperian current model is given in \[Ravaud2009\].
 
 ## Accuracy of the Solutions and Demagnetization
 
@@ -48,25 +48,15 @@ The magnetic field of a wire carrying a homogeneous current density is similar (
 
 The analytical solutions are exact when bodies have a homogeneous magnetization. However, real materials always have a material response which results in an inhomogeneous magnetization even when the initial magnetization is perfectly homogeneous. There is a lot of literature on such [demagnetization effects](https://en.wikipedia.org/wiki/Demagnetizing_field).
 
-Modern high grade permanent magnets (NdFeB, SmCo, Ferrite) have a very weak material responses (local slope of the magnetization curve, remanent permeability) of the order of $\mu_r \approx 1.05$. In this case the analytical solutions provide an excellent approximation with less than 1% error even at close distance from the magnet surface. A detailed error analysis and discussion is presented in the appendix of \[2020Malago\].
+Modern high grade permanent magnets (NdFeB, SmCo, Ferrite) have a very weak material responses (local slope of the magnetization curve, remanent permeability) of the order of $\mu_r \approx 1.05$. In this case the analytical solutions provide an excellent approximation with less than 1% error even at close distance from the magnet surface. A detailed error analysis and discussion is presented in the appendix of \[Malago2020\].
 
 Error estimation as a result of the material response is evaluated in more detail in the appendix of [Malagò2020](https://www.mdpi.com/1424-8220/20/23/6873).
 
-Note on demagnetization factors !!!
+Demagnetization factors can be used to compensate a large part of the demagnetization effect. Analytical expressions for the demagnetization factors of cuboids can be found at [magpar.net](http://www.magpar.net/static/magpar/doc/html/demagcalc.html).
 
 **Soft-Magnetic Materials**
 
-Soft-magnetic materials like iron or steel with large permeabilities $\mu_r \sim 1000$ can in principle not be modeled with Magpylib. However, when the body is static, when there is no strong local interaction with an adjacent magnet and when the body is mostly conformal one can approximate the field using the Magpylib solutions and some empirical magnetization that depends on the shape of the body, the material response and the strength of the magnetizing field.
-
-An example would be the magnetization of a soft-magnetic metal piece in the earth magnetic field. However, even in such a case it is probably more efficient to use a simple dipole approximation.
-
-**Convergence of the diametral Cylinder solution**
-
-The diametral Cylinder solution is based on a converging series and is set to 50 iterations by default, which should be sufficient in most cases. If you want to be more precise, you can change the convergence behavior by setting a new default value `x` with
-
-```python
-magpylib.defaults.itercylinder = x
-```
+Soft-magnetic materials like iron or steel with large permeabilities $\mu_r \sim 1000$ and low remanence fields are dominated by the material response. There are no useful analytical solutions. However, recent developments showed that the Magnetostatic Method of Moments can be a powerful tool in combination with Magpylib to compute such a material response. An integration into Magpylib is planned for the future.
 
 (docu-units-scaling)=
 
@@ -80,10 +70,13 @@ Magpylib uses the following physical units:
 - \[deg\]: for angle inputs by default.
 - \[A\]: for current inputs.
 
-However, the analytical solutions scale in such a way that the magnetic field is the same when the system scales in size. This means that a 1-meter sized magnet in a distance of 1-meter produces the same field as a 1-millimeter sized magnet in a distance of 1-millimeter. The choice of position/length input dimension is therefore not relevant - the Magpylib choice of \[mm\] is a result of history and practical considerations when working with position and orientation systems).
+However, the analytical solutions scale in such a way that the magnetic field is the same when the system scales in size. This means that a 1-meter sized magnet in a distance of 1-meter produces the same field as a 1-millimeter sized magnet in a distance of 1-millimeter. The choice of position/length input dimension is therefore not relevant - the Magpylib choice of \[mm\] is a result of history and practical considerations when working with position and orientation systems.
 
 In addition, `getB` returns the unit of the input magnetization. The Magpylib choice of \[mT\] (theoretical physicists will point out that it is µ0\*M) is historical and convenient. When the magnetization is given in \[mT\], then `getH` returns \[kA/m\] which is simply related by factor of $\frac{10}{4\pi}$. Of course, `getB` also adds the magnet magnetization when computing the field inside the magnet, while `getH` does not.
-## Computation
+
+
+(docu-performance)=
+## Computation and Performance
 
 Magpylib code is fully [vectorized](https://en.wikipedia.org/wiki/Array_programming), written almost completely in numpy native. Magpylib automatically vectorizes the computation with complex inputs (many sources, many observers, paths) and never falls back on using loops.
 
@@ -91,38 +84,29 @@ Magpylib code is fully [vectorized](https://en.wikipedia.org/wiki/Array_programm
 Maximal performance is achieved when `.getB(sources, observers)` is called only a single time in your program. Try not to use loops.
 ```
 
-Of course the objective oriented interface (sensors and sources) comes with an overhead. If you want to achieve maximal performance this overhead can be avoided through direct access to the vectorized field functions with the top level function `magpylib.getB_dict` **FIX THIS**
-
-
-
-(docu-performance)=
-
-## Performance
-
-The analytical solutions provide extreme performance. Single field evaluations take of the order of `100 µs`. For large input arrays (e.g. many observer positions or many similar magnets) the computation time drops below `1 µs` on single state-of-the-art x86 mobile cores (tested on `Intel Core i5-8365U @ 1.60GHz`), depending on the source type.
-
-
-(docu-close-to-surface)=
+The objective oriented interface comes with an overhead. If you want to achieve maximal performance this overhead can be avoided with {ref}`intro-direct-interface`.
 
-## Close to surfaces, edges and corners
+The analytical solutions provide extreme performance. Single field evaluations take of the order of `100 µs`. For large input arrays (e.g. many observer positions or many similar magnets) the computation time drops below `1 µs` per evaluation point on single state-of-the-art x86 mobile cores (tested on `Intel Core i5-8365U @ 1.60GHz`), depending on the source type.
 
-Evaluation of analytical solutions are often limited by numerical precision when approaching singularities or indeterminate forms on magnet surfaces, edges or corners. 64-bit precision limits evaluation to 16 significant digits, but unfortunately many solutions include higher powers of the distances so that the precision limit is quickly approached.
+## Numerical stability
 
-As a result, Magpylib automatically sets solution that lie closer than **1e-8** to problematic surfaces, edges or corners to **0**. The user can adjust this default value simply with the command `magpylib.defaults.edgesize=x`.
+Many expressions provided in the literature have very questionable numerical stability. Most of these issues are fixed in Magpylib, but one should be aware that the results might not have more than 8-10 correct significant figures. A detailed treatsie on this topic is work in progress.
 
 **References**
 
-- \[1999Yang\] Z. J. Yang et al., "Potential and force between a magnet and a bulk Y1Ba2Cu3O7-d superconductor studied by a mechanical pendulum", Superconductor Science and Technology 3(12):591, 1999
-- \[2005 Engel-Herbert\] R. Engel-Herbert et al., Journal of Applied Physics 97(7):074504 - 074504-4 (2005)
-- \[2013 Camacho\] J.M. Camacho and V. Sosa, "Alternative method to calculate the magnetic field of permanent magnets with azimuthal symmetry", Revista Mexicana de Fisica E 59 8–17, 2013
-- \[1994Furlani\] E. P. Furlani, S. Reanik and W. Janson, "A Three-Dimensional Field Solution for Bipolar Cylinders", IEEE Transaction on Magnetics, VOL. 30, NO. 5, 1994
-- \[2009Derby\] N. Derby, "Cylindrical Magnets and Ideal Solenoids", arXiv:0909.3880v1, 2009
-- \[1950Smythe\] W.B. Smythe, "Static and dynamic electricity" McGraw-Hill New York, 1950, vol. 3.
-- \[2001Simpson\] J. Simplson et al., "Simple analytic expressions for the magnetic field of a circular current loop," 2001.
-- \[2017Ortner\] M. Ortner et al., "Feedback of Eddy Currents in Layered Materials for Magnetic Speed Sensing", IEEE Transactions on Magnetics ( Volume: 53, Issue: 8, Aug. 2017)
-- \[2009Janssen\] J.L.G. Janssen, J.J.H. Paulides and E.A. Lomonova, "3D ANALYTICAL FIELD CALCULATION USING TRIANGULAR MAGNET SEGMENTS APPLIED TO A SKEWED LINEAR PERMANENT MAGNET ACTUATOR", ISEF 2009 - XIV International Symposium on Electromagnetic Fields in Mechatronics, Electrical and Electronic Engineering Arras, France, September 10-12, 2009
-- \[2013Rubeck\] C. Rubeck et al., "Analytical Calculation of Magnet Systems: Magnetic Field Created by Charged Triangles and Polyhedra", IEEE Transactions on Magnetics, VOL. 49, NO. 1, 2013
-- \[1999Jackson\] J. D. Jackson, "Classical Electrodynamics", 1999 Wiley, New York
-- \[2009Ravaud\] R. Ravaud and G. Lamarquand, "Comparison of the coulombian and amperian current models for calculating the magnetic field produced by radially magnetized arc-shaped permanent magnets", HAL Id: hal-00412346
-- \[2020Malago\] P. Malagò et al., Magnetic Position System Design Method Applied to Three-Axis Joystick Motion Tracking. Sensors, 2020, 20. Jg., Nr. 23, S. 6873.
-- \[2021Slanovc\] F. Slanovc et al., "Full Analytical Solution for the Magnetic field of Uniformly Magnetized Cylinder Tiles", in preparation
+- \[Yang1999\] Z. J. Yang et al., "Potential and force between a magnet and a bulk Y1Ba2Cu3O7-d superconductor studied by a mechanical pendulum", Superconductor Science and Technology 3(12):591, 1999
+- \[Engel-Herbert2005\] R. Engel-Herbert et al., Journal of Applied Physics 97(7):074504 - 074504-4 (2005)
+- \[Camacho2013\] J.M. Camacho and V. Sosa, "Alternative method to calculate the magnetic field of permanent magnets with azimuthal symmetry", Revista Mexicana de Fisica E 59 8–17, 2013
+- \[Cichon2019\] D. Cichon, R. Psiuk and H. Brauer, "A Hall-Sensor-Based Localization Method With Six Degrees of Freedom Using Unscented Kalman Filter", IEEE Sensors Journal, Vol. 19, No. 7, April 1, 2019.
+- \[Furlani1994\] E. P. Furlani, S. Reanik and W. Janson, "A Three-Dimensional Field Solution for Bipolar Cylinders", IEEE Transaction on Magnetics, VOL. 30, NO. 5, 1994
+- \[Derby2009\] N. Derby, "Cylindrical Magnets and Ideal Solenoids", arXiv:0909.3880v1, 2009
+- \[Caciagli2018\] A. Caciagli, R. J. Baars, A. P. Philipse and B. W. M. Kuipers, "Exact expression for the magnetic field of a finite cylinder with arbitrary uniform magnetization", Journal of Magnetism and Magnetic Materials 456 (2018) 423–432.
+- \[Slanovc2022\] F. Slanovc, M. Ortner, M. Moridi, C. Abert and D. Suess, "Full analytical solution for the magnetic field of uniformly magnetized cylinder tiles", submitted to Journal of Magnetism and Magnetic Materials.
+- \[Smythe1950\] W.B. Smythe, "Static and dynamic electricity" McGraw-Hill New York, 1950, vol. 3.
+- \[Simpson2001\] J. Simplson et al., "Simple analytic expressions for the magnetic field of a circular current loop," 2001.
+- \[Ortner2017\] M. Ortner et al., "Feedback of Eddy Currents in Layered Materials for Magnetic Speed Sensing", IEEE Transactions on Magnetics ( Volume: 53, Issue: 8, Aug. 2017)
+- \[Janssen2009\] J.L.G. Janssen, J.J.H. Paulides and E.A. Lomonova, "3D ANALYTICAL FIELD CALCULATION USING TRIANGULAR MAGNET SEGMENTS APPLIED TO A SKEWED LINEAR PERMANENT MAGNET ACTUATOR", ISEF 2009 - XIV International Symposium on Electromagnetic Fields in Mechatronics, Electrical and Electronic Engineering Arras, France, September 10-12, 2009
+- \[Rubeck2013\] C. Rubeck et al., "Analytical Calculation of Magnet Systems: Magnetic Field Created by Charged Triangles and Polyhedra", IEEE Transactions on Magnetics, VOL. 49, NO. 1, 2013
+- \[Jackson1999\] J. D. Jackson, "Classical Electrodynamics", 1999 Wiley, New York
+- \[Ravaud2009\] R. Ravaud and G. Lamarquand, "Comparison of the coulombian and amperian current models for calculating the magnetic field produced by radially magnetized arc-shaped permanent magnets", HAL Id: hal-00412346
+- \[Malago2020\] P. Malagò et al., Magnetic Position System Design Method Applied to Three-Axis Joystick Motion Tracking. Sensors, 2020, 20. Jg., Nr. 23, S. 6873.
diff --git a/docs/examples/01_fundamentals.md b/docs/examples/01_fundamentals.md
index 90e9dd194..be376f176 100644
--- a/docs/examples/01_fundamentals.md
+++ b/docs/examples/01_fundamentals.md
@@ -21,6 +21,7 @@ Fundamentals
 :maxdepth: 2
 
 examples_02_paths.md
+examples_05_backend_canvas.md
 examples_03_collections.md
 examples_01_complex_forms.md
 examples_04_custom_source.md
diff --git a/docs/examples/02_graphic_output.md b/docs/examples/02_graphic_output.md
index e0f074c3b..949b6f185 100644
--- a/docs/examples/02_graphic_output.md
+++ b/docs/examples/02_graphic_output.md
@@ -25,6 +25,5 @@ Graphic Output
 
 examples_12_styles.md
 examples_13_3d_models.md
-examples_14_adding_cad_model.md
 examples_15_animation.md
 ```
\ No newline at end of file
diff --git a/docs/examples/03_advanced.md b/docs/examples/03_advanced.md
index 0a0b642c7..41f1ba1f1 100644
--- a/docs/examples/03_advanced.md
+++ b/docs/examples/03_advanced.md
@@ -20,6 +20,6 @@ Advanced
 :caption: Advanced Features
 :maxdepth: 2
 
-examples_21_compound_class.md
-examples_22_fem_interpolation.md
+examples_21_compound.md
+examples_22_field_interpolation.md
 ```
\ No newline at end of file
diff --git a/docs/examples/examples_01_complex_forms.md b/docs/examples/examples_01_complex_forms.md
index 2692ced12..304696e0e 100644
--- a/docs/examples/examples_01_complex_forms.md
+++ b/docs/examples/examples_01_complex_forms.md
@@ -15,7 +15,7 @@ kernelspec:
 
 # Complex forms
 
-The [**superposition principle**](https://en.wikipedia.org/wiki/Superposition_principle) states that the net response caused by two or more stimuli is the sum of the responses that would have been caused by each stimulus individually. This principle holds in Magnetostatics when there is no material response, and simply means that the total field created by multiple magnets is the sum of the individual fields.
+The [**superposition principle**](https://en.wikipedia.org/wiki/Superposition_principle) states that the net response caused by two or more stimuli is the sum of the responses caused by each stimulus individually. This principle holds in Magnetostatics when there is no material response, and simply means that the total field created by multiple magnets is the sum of the individual fields.
 
 It is critical to understand that the superposition principle holds for the magnetization itself. When two magnets overlap geometrically, the magnetization in the overlap region is given by the vector sum of the two individual magnetizations.
 
@@ -54,7 +54,7 @@ ring = CylinderSegment(magnetization=(0,0,100), dimension=(2,3,1,0,360))
 magpy.show(ring, sensor, canvas=ax2, style_magnetization_show=False)
 
 # compare field at sensor
-ax3.plot(sensor.getB(coll).T[2], label='Bz from Cuboid')
+ax3.plot(sensor.getB(coll).T[2], label='Bz from Cuboids')
 ax3.plot(sensor.getB(ring).T[2], ls='--', label='Bz from CylinderSegment')
 ax3.grid(color='.9')
 ax3.legend()
@@ -84,10 +84,4 @@ ring1 = inner + outer
 %time print('getB from Cylinder cut-out', ring1.getB((1,2,3)))
 ```
 
-Note that, it is faster to compute the `Cylinder` field two times than computing the complex `CylinderSegment` field one time.
-
-+++
-
-```note
-Cut-out operations cannot be displayed at the moment
-```
+Note that, it is faster to compute the `Cylinder` field two times than computing the complex `CylinderSegment` field one time. Unfortunately, cut-out operations cannot be displayed graphically at the moment, but {ref}`examples-own-3d-models` offer a solution here.
diff --git a/docs/examples/examples_02_paths.md b/docs/examples/examples_02_paths.md
index 5cec2d157..b448ea7bd 100644
--- a/docs/examples/examples_02_paths.md
+++ b/docs/examples/examples_02_paths.md
@@ -69,7 +69,7 @@ print(sensor.position)                                    # to whole path starti
 
 sensor.move([(0,0,10), (0,0,20)], start=1)                # vector input and start=1 merges
 print(sensor.position)                                    # the input with the existing path
-# out: [[ 1.  1.  1.]  [ 3.  3. 13.]  [ 4.  4. 24.]]    # starting at index 1.
+# out: [[ 1.  1.  1.]  [ 3.  3. 13.]  [ 4.  4. 24.]]      # starting at index 1.
 ```
 
 (examples-relative-paths)=
@@ -82,13 +82,13 @@ print(sensor.position)                                    # the input with the e
 import numpy as np
 from magpylib.magnet import Sphere
 
-path1 = np.linspace((0,0,0), (10,0,0), 10)[1:]
-path2 = np.linspace((0,0,0), (0,0,10), 10)[1:]
+x_path = np.linspace((0,0,0), (10,0,0), 10)[1:]
+z_path = np.linspace((0,0,0), (0,0,10), 10)[1:]
 
 sphere = Sphere(magnetization=(0,0,1), diameter=3)
 
 for _ in range(3):
-    sphere.move(path1).move(path2)
+    sphere.move(x_path).move(z_path)
 
 sphere.show()
 ```
@@ -133,7 +133,7 @@ print(sensor.orientation.as_quat())
 
 ## Edge-padding and end-slicing
 
-Magpylib will always make sure that object paths are in the right format, i.e. `position` and `orientation` attributes are of the same length. In addition, when objects with different path lengths are combined, e.g. when computing the field, the shorter paths are adjusted in length to make the computation sensible. Internally, Magpylib follows a philosophy of edge-padding and end-slicing when adjusting paths.
+Magpylib will always make sure that object paths are in the right format, i.e. `position` and `orientation` attributes are of the same length. In addition, when objects with different path lengths are combined, e.g. when computing the field, the shorter paths are treated as static beyond their end to make the computation sensible. Internally, Magpylib follows a philosophy of edge-padding and end-slicing when adjusting paths.
 
 The idea behind **edge-padding** is, that whenever path entries beyond the existing path length are needed the edge-entries of the existing path are returned. This means that the object is considered to be "static" beyond its existing path.
 
diff --git a/docs/examples/examples_03_collections.md b/docs/examples/examples_03_collections.md
index 8baf35fd5..b5618e248 100644
--- a/docs/examples/examples_03_collections.md
+++ b/docs/examples/examples_03_collections.md
@@ -11,120 +11,173 @@ kernelspec:
   name: python3
 ---
 
-# Collections
-
 (examples-collections-construction)=
 
-## Collection construction
+# Collections
 
-The `Collection` class is a versatile way of grouping and manipulating Magpylib  objects. When objects are added to a Collection they are added by reference (**not copied**) to the attributes `children` (list of all objects), `sources` (list of the sources) and `sensors` (list of the sensors). These attributes are ordered lists. New additions are always added at the end.
+## Constructing collections
+
+The `Collection` class is a versatile way of grouping and manipulating Magpylib objects. When objects are added to a Collection they are added by reference (not copied) to the **attributes** `children` (list of all objects), `sources` (list of the sources), `sensors` (list of the sensors) and `collections`.
 
 ```{code-cell} ipython3
 import magpylib as magpy
 
-loop = magpy.current.Loop()
-sensor = magpy.Sensor()
+x1 = magpy.Sensor(style_label='x1')
+s1 = magpy.magnet.Cuboid(style_label='s1')
+c1 = magpy.Collection(style_label='c1')
 
-coll = magpy.Collection(loop, sensor)
+coll = magpy.Collection(x1, s1, c1, style_label='coll')
 
-print(f"children: {coll.children}")
-print(f"sources:  {coll.sources}")
-print(f"sensors:  {coll.sensors}")
+print(f"children:    {coll.children}")
+print(f"sources:     {coll.sources}")
+print(f"sensors:     {coll.sensors}")
+print(f"collections: {coll.collections}")
 ```
 
-To manipulate existing collections, one can use the `add` and `remove` methods:
+These attributes are ordered lists. New additions are always added at the end. Add objects to an existing collection using these parameters, or the **`add`** method.
 
 ```{code-cell} ipython3
-coll.add(magpy.magnet.Cuboid())
-coll.remove(sensor)
 
-print(f"children: {coll.children}")
-print(f"sources:  {coll.sources}")
-print(f"sensors:  {coll.sensors}")
+# automatically adjusts object label
+x2 = x1.copy()
+s2 = s1.copy()
+c2 = c1.copy()
+
+# add objects with add method
+coll.add(x2, s2)
+
+# add objects with parameters
+coll.collections += [c2]
+
+print(f"children:    {coll.children}")
+print(f"sources:     {coll.sources}")
+print(f"sensors:     {coll.sensors}")
+print(f"collections: {coll.collections}")
 ```
 
-The operators `+` and `-` provide a similar functionality,
+The **`describe`** method is a very convenient way to view a Collection structure, especially when the collection is nested, i.e. when containing other collections:
 
 ```{code-cell} ipython3
-coll = coll - loop
-coll = coll + magpy.magnet.Cylinder()
+# add more objects
+c1.add(x2.copy())
+c2.add(s2.copy())
 
-print(f"children: {coll.children}")
-print(f"sources:  {coll.sources}")
-print(f"sensors:  {coll.sensors}")
+coll.describe(format='label')
 ```
 
-However, it must be noted that `+` and `-` result in copies of the collection, while `add` and `remove` do not.
-
-The `+` operator is defined for all Magpylib objects. Adding objects returns a collection.
+For convenience, any two Magpylib object can be added up with `+` to form a collection:
 
 ```{code-cell} ipython3
-what_is_it = loop + sensor
-print(what_is_it)
+import magpylib as magpy
+
+x1 = magpy.Sensor(style_label='x1')
+s1 = magpy.magnet.Cuboid(style_label='s1')
+
+coll = x1 + s1
+
+coll.describe(format='label')
 ```
 
-Collections have `__getitem__` through the attribute `children` defined. This allows using collections as iterators,
+## Child-parent relations
+
+Objects that are part of a collection become children of that collection, and the collection itself becomes their parent. Every Magpylib object has the `parent` attribute, which is `None` by default. 
 
 ```{code-cell} ipython3
-for child in coll:
-    print(child)
+import magpylib as magpy
+
+x1 = magpy.Sensor()
+c1 = magpy.Collection(x1)
+
+print(f"x1.parent:   {x1.parent}")
+print(f"c1.parent:   {c1.parent}")
+print(f"c1.children: {c1.children}")
 ```
 
-and makes it possible to directly reference to a child object:
+Rather than adding objects to a collection, as described above, one can also set the `parent` parameter. A Magpylib object can only have a single parent, i.e. it can only be part of a single collection. As a result, changing the parent will automatically remove the object from it's previous collection.
 
 ```{code-cell} ipython3
-print(coll[0])
-```
+import magpylib as magpy
 
-Finally, it is worth mentioning that collections do not allow duplicate sources. They will automatically be removed. However, sources can be part of multiple collections.
+x1 = magpy.Sensor(style_label='x1')
+c1 = magpy.Collection(style_label='c1')
+c2 = magpy.Collection(c1, style_label='c2')
 
-(examples-collections-compound)=
+print("Two empty, nested collections")
+c2.describe(format='label')
 
-## Compounds
+print("\nSet x1 parent to c1")
+x1.parent = c1
+c2.describe(format='label')
+
+print("\nChange x1 parent to c2")
+x1.parent = c2
+c2.describe(format='label')
+```
 
-Collections follow the *compound philosophy*. The idea is that a compound object, made up of multiple individual sources and sensors can be treated like single object itself.
 
-For this purpose, the collection has itself `position` and `orientation` attributes that span a local coordinate reference frame. Whenever a child is added to the collection it has a `position` and `orientation` in the local frame. All operations acting on the collection will then only move the local frame around, and not change child positions within it. Operations acting directly on the child itself will move the child also in the local frame.
+## Working with collections
+
+Collections have `__getitem__` through the attribute `children` defined which enables using collections directly as iterators,
 
 ```{code-cell} ipython3
-import numpy as np
 import magpylib as magpy
 
-# construct two coil compounds
-coil1 = magpy.Collection()
-for z in np.linspace(-.5, .5, 5):
-    winding = magpy.current.Loop(current=1, diameter=20, position=(0,0,z))
-    coil1.add(winding)
-coil1.position = (0,0,-5)
-coil2 = coil1.copy(position = (0,0,5))
-
-# construct a helmholz compound
-helmholtz = coil1 + coil2
+x1 = magpy.Sensor()
+x2 = magpy.Sensor()
 
-# move the helmholz compound
-helmholtz.position = np.linspace((0,0,0), (10,0,0), 30)
-helmholtz.rotate_from_angax(np.linspace(0,180,30), 'x', start=0)
+coll = x1 + x2
 
-# move the winding objects
-for coil in [coil1, coil2]:
-    for i,w in enumerate(coil):
-        w.move(np.linspace((0,0,0), (0,0,2-i), 20))
+for child in coll:
+    print(child)
+```
 
-# move the modified coil compounds
-coil1.move(np.linspace((0,0,0), ( 5,0,0), 30))
-coil2.move(np.linspace((0,0,0), (-5,0,0), 30))
+and makes it possible to directly reference to a child object by index:
 
-helmholtz.show(backend='plotly', animation=4, style_path_show=False)
+```{code-cell} ipython3
+print(coll[0])
 ```
 
-The compound philosophy is also followed when computing the magnetic field. Collections behave like single source inputs in the functions `getB` and `getH`. The field that is returned is simply the the sum of the fields of all child sources. In the following example the field generated by the helmholtz compound above, and by the individual coils is computed at position $(0,0,0)$:
+How to work with collections in a practical way is demonstrated in the introduction section {ref}`intro-collections`.
+
+How to make complex compound objects is documented in {ref}`examples-compounds`.
+
+(examples-collections-efficient)=
+
+## Efficient 3D models
+
+The Matplotlib and Plotly libraries were not designed for complex 3D graphic outputs. As a result, it becomes often inconvenient and slow when attempting to display many 3D objects. One solution to this problem when dealing with large collections, is to represent the latter by a single encompassing body, and to deactivate the individual 3D models of all children. This is demonstrated in the following example.
 
 ```{code-cell} ipython3
-helmholtz.reset_path()
+import magpylib as magpy
 
-print(magpy.getB(helmholtz, (0,0,0)))
-print(magpy.getB(coil1, (0,0,0)))
-print(magpy.getB(coil2, (0,0,0)))
+# create collection
+coll = magpy.Collection()
+for index in range(10):
+    cuboid = magpy.magnet.Cuboid(
+        magnetization=(0, 0, 1000 * (index%2-.5)),
+        dimension=(10,10,10),
+        position=(index*10,0,0),
+    )
+    coll.add(cuboid)
+
+# add 3D-trace
+plotly_trace = magpy.graphics.model3d.make_Cuboid(
+    backend='matplotlib',
+    dimension=(104, 12, 12),
+    position=(45, 0, 0),
+    alpha=0.5,
+)
+coll.style.model3d.add_trace(plotly_trace)
+
+coll.style.label='Collection with visible children'
+coll.show()
+
+# hide the children default 3D representation
+coll.set_children_styles(model3d_showdefault=False)
+coll.style.label = 'Collection with hidden children'
+coll.show()
 ```
 
-Notice that, `helmholtz.reset_path()` sets the `helmholtz` object to position $(0,0,0)$, however, the final asymmetric structure (coils shifted left and right) remains, so that there is a finite x-component.
+```{note}
+The `Collection` position is set to (0,0,0) at creation time. Any added extra 3D-model will be bound to the local coordinate system of to the `Collection` and `rotated`/`moved` together with its parent object.
+```
diff --git a/docs/examples/examples_04_custom_source.md b/docs/examples/examples_04_custom_source.md
index b24033297..dd6c9c5a2 100644
--- a/docs/examples/examples_04_custom_source.md
+++ b/docs/examples/examples_04_custom_source.md
@@ -14,9 +14,9 @@ kernelspec:
 (examples-custom-source-objects)=
 # Custom source objects
 
-The custom source class `CustomSource` allows users to integrate their own custom-objects into the Magpylib interface. The `field_B_lambda` and `field_H_lambda` arguments can be provided with function that are called with `getB` and `getH`.
+The class `CustomSource` allows users to integrate their own custom-objects into the Magpylib interface. The `field_B_lambda` and `field_H_lambda` arguments can be provided with function that are called with `getB` and `getH`.
 
-These custom field functions are treated like core functions. they must accept position inputs (array_like, shape (n,3)) and return the respective field with a similar shape. A fundamental example how to create a custom source object is:
+These custom field functions are treated like core functions. They must accept position inputs (array_like, shape (n,3)) and return the respective field with a similar shape. A fundamental example how to create a custom source object is:
 
 ```{code-cell} ipython3
 import numpy as np
@@ -26,7 +26,7 @@ import magpylib as magpy
 def custom_field(position):
     """ user defined custom field
     position input: array_like, shape (n,3)
-    returns: ndarray, shape (n,3)
+    returns: B-field, ndarray, shape (n,3)
     """
     return np.array(position)*2
 
diff --git a/docs/examples/examples_05_backend_canvas.md b/docs/examples/examples_05_backend_canvas.md
new file mode 100644
index 000000000..5b9a805d9
--- /dev/null
+++ b/docs/examples/examples_05_backend_canvas.md
@@ -0,0 +1,115 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.6
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-backends-canvas)=
+
+# Backend and canvas
+
+## Graphic backend
+
+Magpylib supports Matplotlib and Plotly as possible graphic backends.
+If a backend is not specified, the library default stored in `magpy.defaults.display.backend` will be used.
+The value can bei either `'matplotlib'` or `'plotly'`.
+
+To select a graphic backend one can
+1. Change the library default with `magpy.defaults.display.backend = 'plotly'`.
+2. Set the `backend` kwarg in the `show` function, `show(..., backend='matplotlib')`.
+
+```{note}
+There is a high level of **feature parity** between the two backends but there are also some key differences, e.g. when displaying magnetization of an object. In addition, some common Matplotlib syntax (e.g. color `'r'`, linestyle `':'`) is automatically translated to Plotly and vice versa.
+```
+
+The following example shows first Matplotlib and then Plotly output:
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# define sources and paths
+loop = magpy.current.Loop(current=1, diameter=1)
+loop.position = np.linspace((0,0,-3), (0,0,3), 40)
+
+cylinder = magpy.magnet.Cylinder(magnetization=(0,-100,0), dimension=(1,2), position=(0,-3,0))
+cylinder.rotate_from_angax(np.linspace(0, 300, 40)[1:], 'z', anchor=0)
+
+# display the system with both backends
+magpy.show(loop, cylinder)
+magpy.show(loop, cylinder, backend='plotly')
+```
+
+## Output in custom figure
+
+When calling `show`, a Matplotlib or Plotly figure is automatically generated and displayed. It is also possible to display the `show` output on a given user-defined canvas (Plotly `Figure` object or Matplotlib `Axis3d` object) with the `canvas` kwarg.
+
+In the following example we show how to combine a 2D field plot with the 3D `show` output in **Matplotlib**:
+
+```{code-cell} ipython3
+import numpy as np
+import matplotlib.pyplot as plt
+import magpylib as magpy
+
+# setup matplotlib figure and subplots
+fig = plt.figure(figsize=(10, 4))
+ax1 = fig.add_subplot(121,)                  # 2D-axis
+ax2 = fig.add_subplot(122, projection="3d")  # 3D-axis
+
+# define sources and paths
+loop = magpy.current.Loop(current=1, diameter=1)
+loop.position = np.linspace((0,0,-3), (0,0,3), 40)
+
+cylinder = magpy.magnet.Cylinder(magnetization=(0,-100,0), dimension=(1,2), position=(0,-3,0))
+cylinder.rotate_from_angax(np.linspace(0, 300, 40)[1:], 'z', anchor=0)
+
+# compute field and plot in 2D-axis
+B = magpy.getB([loop, cylinder], (0,0,0), sumup=True)
+ax1.plot(B)
+
+# display show() output in 3D-axis
+magpy.show(loop, cylinder, canvas=ax2)
+
+# generate figure
+plt.tight_layout()
+plt.show()
+```
+
+A similar example with **Plotly**:
+
+```{code-cell} ipython3
+import numpy as np
+import plotly.graph_objects as go
+import magpylib as magpy
+
+# setup plotly figure and subplots
+fig = go.Figure().set_subplots(rows=1, cols=2, specs=[[{"type": "xy"}, {"type": "scene"}]])
+
+# define sources and paths
+loop = magpy.current.Loop(current=1, diameter=1)
+loop.position = np.linspace((0,0,-3), (0,0,3), 40)
+
+cylinder = magpy.magnet.Cylinder(magnetization=(0,-100,0), dimension=(1,2), position=(0,-3,0))
+cylinder.rotate_from_angax(np.linspace(0, 300, 40)[1:], 'z', anchor=0)
+
+# compute field and plot in 2D-axis
+B = magpy.getB([loop, cylinder], (0,0,0), sumup=True)
+for i,lab in enumerate(['Bx', 'By', 'Bz']):
+    fig.add_trace(go.Scatter(x=np.linspace(0,1,40), y=B[:,i], name=lab))
+
+# display show() output in 3D-axis
+temp_fig = go.Figure()
+magpy.show(loop, cylinder, canvas=temp_fig, backend='plotly')
+fig.add_traces(temp_fig.data, rows=1, cols=2)
+fig.layout.scene.update(temp_fig.layout.scene)
+
+# generate figure
+fig.show()
+```
diff --git a/docs/examples/examples_12_styles.md b/docs/examples/examples_12_styles.md
index cd95d77c3..9fb8fa407 100644
--- a/docs/examples/examples_12_styles.md
+++ b/docs/examples/examples_12_styles.md
@@ -19,8 +19,8 @@ The graphic styles define how Magpylib objects are displayed visually when calli
 There are multiple hierarchy levels that decide about the final graphical representation of the objects:
 
 1. When no input is given, the **default style** will be applied.
-2. Object **individual styles** will take precedence over the default values.
-3. Collections will override the color property of all children.
+2. Collections will override the color property of all children with their own color.
+3. Object **individual styles** will take precedence over these values.
 4. Setting a **local style** in `show()` will take precedence over all other settings.
 
 ## Setting the default style
@@ -113,11 +113,11 @@ magpy.defaults.display.style.magnet.update(
 Any Magpylib object can have its own individual style that will take precedence over the default values when `show` is called. When setting individual styles, the object family specifier such as `magnet` or `current` which is required for the defaults settings, but is implicitly defined by the object type, can be omitted.
 
 ```{warning}
-Users should be aware that specifying style attributes massively increases object initializing time (from <50 to 100-500 $\mu$s).
+Users should be aware that specifying individual style attributes massively increases object initializing time (from <50 to 100-500 $\mu$s).
 While this may not be noticeable for a small number of objects, it is best to avoid setting styles until it is plotting time.
 ```
 
-In the following example the individual style of `cube` is set at initialization, the style of `cylinder` is the default one, and the individual style of `sphere` is set using the style properties.
+In the following example the individual style of `cube` is set at initialization, the style of `cylinder` is the default one, and the individual style of `sphere` is set using the object style properties.
 
 ```{code-cell} ipython3
 import magpylib as magpy
@@ -147,7 +147,7 @@ magpy.show(cube, cylinder, sphere, backend="plotly")
 
 ## Setting style via collections
 
-When displaying collections, the collection object `color` property will be automatically assigned to all its children and override default and individual styles. An example that demonstrates this is {ref}`examples-union-operation`. In addition, it is possible to modify the style of all children with the `set_children_styles` method. Non-matching properties are simply ignored.
+When displaying collections, the collection object `color` property will be automatically assigned to all its children and override the default style. An example that demonstrates this is {ref}`examples-union-operation`. In addition, it is possible to modify the individual style properties of all children with the `set_children_styles` method. Non-matching properties are simply ignored.
 
 In the following example we show how the french magnetization style is applied to all children in a collection,
 
diff --git a/docs/examples/examples_13_3d_models.md b/docs/examples/examples_13_3d_models.md
index da3309389..f111317eb 100644
--- a/docs/examples/examples_13_3d_models.md
+++ b/docs/examples/examples_13_3d_models.md
@@ -13,12 +13,12 @@ kernelspec:
 
 (examples-3d-models)=
 
-# 3D models
+# 3D models and CAD
 
 (examples-own-3d-models)=
 ## Custom 3D models
 
-Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace(trace)` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`.
+Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`.
 
 The input `trace` is a dictionary which includes all necessary information for plotting or a `magpylib.graphics.Trace3d` object. A `trace` dictionary has the following keys:
 
@@ -27,7 +27,7 @@ The input `trace` is a dictionary which includes all necessary information for p
 3. `'args'`: default `None`, positional arguments handed to constructor
 4. `'kwargs'`: default `None`, keyword arguments handed to constructor
 5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the Plotly backend and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend.
-6. `'show'`: default `True`: choose if this trace should be displayed or not
+6. `'show'`: default `True`, toggle if this trace should be displayed
 7. `'scale'`: default 1, object geometric scaling factor
 8. `'updatefunc'`: default `None`, updates the trace parameters when `show` is called. Used to generate  dynamic traces.
 
@@ -234,6 +234,98 @@ obj5.style.model3d.add_trace(trace_arrow)
 magpy.show(obj0, obj1, obj2, obj3, obj4, obj5, backend='plotly')
 ```
 
-## CAD models
+(examples-adding-CAD-model)=
+
+## Adding a CAD model
+
+As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends.
+
+```{note}
+The code below requires installation of the `numpy-stl` package.
+```
+
+```{code-cell} ipython3
+import os
+import tempfile
+import requests
+import numpy as np
+from stl import mesh  # requires installation of numpy-stl
+import magpylib as magpy
+
+
+def get_stl_color(x):
+    """ transform stl_mesh attr to plotly color"""
+    sb = f"{x:015b}"[::-1]
+    r = int(255 / 31 * int(sb[:5], base=2))
+    g = int(255 / 31 * int(sb[5:10], base=2))
+    b = int(255 / 31 * int(sb[10:15], base=2))
+    return f"rgb({r},{g},{b})"
+
+
+def trace_from_stl(stl_file, backend='matplotlib'):
+    """
+    Generates a Magpylib 3D model trace dictionary from an *.stl file.
+    backend: 'matplotlib' or 'plotly'
+    """
+    # load stl file
+    stl_mesh = mesh.Mesh.from_file(stl_file)
+
+    # extract vertices and triangulation
+    p, q, r = stl_mesh.vectors.shape
+    vertices, ixr = np.unique(stl_mesh.vectors.reshape(p * q, r), return_inverse=True, axis=0)
+    i = np.take(ixr, [3 * k for k in range(p)])
+    j = np.take(ixr, [3 * k + 1 for k in range(p)])
+    k = np.take(ixr, [3 * k + 2 for k in range(p)])
+    x, y, z = vertices.T
+
+    # generate and return Magpylib traces
+    if backend == 'matplotlib':
+        triangles = np.array([i, j, k]).T
+        trace = {
+            'backend': 'matplotlib',
+            'constructor': 'plot_trisurf',
+            'args': (x, y, z),
+            'kwargs': {'triangles': triangles},
+        }
+    elif backend == 'plotly':
+        colors = stl_mesh.attr.flatten()
+        facecolor = np.array([get_stl_color(c) for c in colors]).T
+        trace = {
+            'backend': 'plotly',
+            'constructor': 'Mesh3d',
+            'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor),
+        }
+    else:
+        raise ValueError("Backend must be one of ['matplotlib', 'plotly'].")
+
+    return trace
+
+
+# load stl file from online resource
+url = "https://raw.githubusercontent.com/magpylib/magpylib-files/main/PG-SSO-3-2.stl"
+file = url.split("/")[-1]
+with tempfile.TemporaryDirectory() as temp:
+    fn = os.path.join(temp, file)
+    with open(fn, "wb") as f:
+        response = requests.get(url)
+        f.write(response.content)
+
+    # create traces for both backends
+    trace_mpl = trace_from_stl(fn, backend='matplotlib')
+    trace_ply = trace_from_stl(fn, backend='plotly')
+
+# create sensor and add CAD model
+sensor = magpy.Sensor(style_label='PG-SSO-3 package')
+sensor.style.model3d.add_trace(trace_mpl)
+sensor.style.model3d.add_trace(trace_ply)
+
+# create magnet and sensor path
+magnet = magpy.magnet.Cylinder(magnetization=(0,0,100), dimension=(15,20))
+sensor.position = np.linspace((-15,0,8), (-15,0,-4), 21)
+sensor.rotate_from_angax(np.linspace(0, 200, 21), 'z', anchor=0, start=0)
+
+# display with both backends
+magpy.show(sensor, magnet, style_path_frames=5, style_magnetization_show=False)
+magpy.show(sensor, magnet, style_path_frames=5, backend="plotly")
+```
 
-A detailed example how to add complex CAD-file is given in {ref}`examples-adding-CAD-model`.
diff --git a/docs/examples/examples_14_adding_cad_model.md b/docs/examples/examples_14_adding_cad_model.md
deleted file mode 100644
index 63a50ca7c..000000000
--- a/docs/examples/examples_14_adding_cad_model.md
+++ /dev/null
@@ -1,107 +0,0 @@
----
-jupytext:
-  text_representation:
-    extension: .md
-    format_name: myst
-    format_version: 0.13
-    jupytext_version: 1.13.7
-kernelspec:
-  display_name: Python 3 (ipykernel)
-  language: python
-  name: python3
----
-
-(examples-adding-CAD-model)=
-
-# Adding a CAD model
-
-As shown in {ref}`examples-own-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends.
-
-```{note}
-The code below requires installation of the `numpy-stl` package.
-```
-
-```{code-cell} ipython3
-import os
-import tempfile
-import requests
-import numpy as np
-from stl import mesh  # requires installation of numpy-stl
-import magpylib as magpy
-
-
-def get_stl_color(x):
-    """ transform stl_mesh attr to plotly color"""
-    sb = f"{x:015b}"[::-1]
-    r = int(255 / 31 * int(sb[:5], base=2))
-    g = int(255 / 31 * int(sb[5:10], base=2))
-    b = int(255 / 31 * int(sb[10:15], base=2))
-    return f"rgb({r},{g},{b})"
-
-
-def trace_from_stl(stl_file, backend='matplotlib'):
-    """
-    Generates a Magpylib 3D model trace dictionary from an *.stl file.
-    backend: 'matplotlib' or 'plotly'
-    """
-    # load stl file
-    stl_mesh = mesh.Mesh.from_file(stl_file)
-
-    # extract vertices and triangulation
-    p, q, r = stl_mesh.vectors.shape
-    vertices, ixr = np.unique(stl_mesh.vectors.reshape(p * q, r), return_inverse=True, axis=0)
-    i = np.take(ixr, [3 * k for k in range(p)])
-    j = np.take(ixr, [3 * k + 1 for k in range(p)])
-    k = np.take(ixr, [3 * k + 2 for k in range(p)])
-    x, y, z = vertices.T
-
-    # generate and return Magpylib traces
-    if backend == 'matplotlib':
-        triangles = np.array([i, j, k]).T
-        trace = {
-            'backend': 'matplotlib',
-            'constructor': 'plot_trisurf',
-            'args': (x, y, z),
-            'kwargs': {'triangles': triangles},
-        }
-    elif backend == 'plotly':
-        colors = stl_mesh.attr.flatten()
-        facecolor = np.array([get_stl_color(c) for c in colors]).T
-        trace = {
-            'backend': 'plotly',
-            'constructor': 'Mesh3d',
-            'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor),
-        }
-    else:
-        raise ValueError("Backend must be one of ['matplotlib', 'plotly'].")
-
-    return trace
-
-
-# load stl file from online resource
-url = "https://raw.githubusercontent.com/magpylib/magpylib-files/main/PG-SSO-3-2.stl"
-file = url.split("/")[-1]
-with tempfile.TemporaryDirectory() as temp:
-    fn = os.path.join(temp, file)
-    with open(fn, "wb") as f:
-        response = requests.get(url)
-        f.write(response.content)
-
-    # create traces for both backends
-    trace_mpl = trace_from_stl(fn, backend='matplotlib')
-    trace_ply = trace_from_stl(fn, backend='plotly')
-
-# create sensor and add CAD model
-sensor = magpy.Sensor(style_label='PG-SSO-3 package')
-sensor.style.model3d.add_trace(trace_mpl)
-sensor.style.model3d.add_trace(trace_ply)
-
-# create magnet and sensor path
-magnet = magpy.magnet.Cylinder(magnetization=(0,0,100), dimension=(15,20))
-sensor.position = np.linspace((-15,0,8), (-15,0,-4), 21)
-sensor.rotate_from_angax(np.linspace(0, 200, 21), 'z', anchor=0, start=0)
-
-# display with both backends
-magpy.show(sensor, magnet, style_path_frames=5, style_magnetization_show=False)
-magpy.show(sensor, magnet, style_path_frames=5, backend="plotly")
-```
diff --git a/docs/examples/examples_21_compound.md b/docs/examples/examples_21_compound.md
new file mode 100644
index 000000000..011884c34
--- /dev/null
+++ b/docs/examples/examples_21_compound.md
@@ -0,0 +1,218 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3 (ipykernel)
+  language: python
+  name: python3
+---
+
+(examples-compounds)=
+
+# Compounds
+
+The `Collection` class is a powerful tool for grouping and tracking object assemblies.
+However, in many cases it is convenient to have assembly variables themselves (e.g. geometric arrangement) as class properties of new custom classes, which is achieved by sub-classing `Collection`. We refer to such super-classes as **compounds** and show how to seamlessly integrate them into Magpylib.
+
+## Subclassing collections
+
+In the following example we design a compound class `MagnetRing` which represents a ring of cuboid magnets with the parameter `cubes` that should refer to the number of magnets on the ring. The ring will automatically adjust its size when `cubes` is modified. In the spirit of {ref}`examples-collections-efficient` we also add an encompassing 3D model.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+class MagnetRing(magpy.Collection):
+    """ A ring of cuboid magnets
+
+    Parameters
+    ----------
+    cubes: int, default=6
+        Number of cubes on ring.
+    """
+
+    def __init__(self, cubes=6, **style_kwargs):
+        super().__init__(**style_kwargs)             # hand over style args
+        self._update(cubes)
+
+    @property
+    def cubes(self):
+        """ Number of cubes"""
+        return self._cubes
+
+    @cubes.setter
+    def cubes(self, inp):
+        """ set cubes"""
+        self._update(inp)
+
+    def _update(self, cubes):
+        """updates the MagnetRing instance"""
+        self._cubes = cubes
+        ring_radius = cubes/3
+        
+        # construct in temporary Collection for path transfer
+        temp_coll = magpy.Collection()
+        for i in range(cubes):
+            child = magpy.magnet.Cuboid(
+                magnetization=(1000,0,0),
+                dimension=(1,1,1),
+                position=(ring_radius,0,0)
+            )
+            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
+            temp_coll.add(child)
+
+        # transfer path and children
+        temp_coll.position = self.position
+        temp_coll.orientation = self.orientation
+        self.children = temp_coll.children
+
+        # add parameter-dependent 3d trace
+        self.style.model3d.data = []
+        self.style.model3d.add_trace(self._custom_trace3d('plotly'))
+        self.style.model3d.add_trace(self._custom_trace3d('matplotlib'))
+
+        return self
+
+    def _custom_trace3d(self, backend):
+        """ creates a parameter-dependent 3d model"""
+        r1 = self.cubes/3 - .6
+        r2 = self.cubes/3 + 0.6
+        trace = magpy.graphics.model3d.make_CylinderSegment(
+            backend=backend,
+            dimension=(r1, r2, 1.1, 0, 360),
+            vert=150,
+            **{('opacity' if backend=='plotly' else 'alpha') :0.5}
+        )
+        return trace
+```
+
+The new `MagnetRing` objects will seamlessly integrate into Magpylib and make use of the position and orientation interface, field computation and graphic display.
+
+```{code-cell} ipython3
+# add a sensor
+sensor = magpy.Sensor(position=(0, 0, 0))
+
+# create a MagnetRing object
+ring = MagnetRing()
+
+# move ring around
+ring.position = (0,0,10)
+ring.rotate_from_angax(angle=45, axis=(1,-1,0))
+
+# compute field
+print(f"B-field at sensor → {ring.getB(sensor).round(2)}")
+
+# display graphically
+magpy.show(ring, sensor, backend='plotly')
+```
+
+The ring parameter `cubes` can be modified dynamically:
+
+```{code-cell} ipython3
+ring.cubes=15
+
+print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(2)}")
+magpy.show(ring, sensor, backend='plotly')
+```
+
+## Postponed trace construction
+
+Custom traces can be computationally costly to construct. In the above example, the trace is constructed in `_update`, every time the parameter `cubes` is modified. This can lead to an unwanted computational overhead, especially as the construction is only necessary for graphical representation.
+
+To make our compounds ready for heavy computation, it is possible to provide a callable as a trace, which will only be constructed when `show` is called. The following modification of the above example demonstrates this:
+
+```{code-cell} ipython3
+from functools import partial
+import magpylib as magpy
+import numpy as np
+
+class MagnetRingAdv(magpy.Collection):
+    """ A ring of cuboid magnets
+
+    Parameters
+    ----------
+    cubes: int, default=6
+        Number of cubes on ring.
+    """
+
+    def __init__(self, cubes=6, **style_kwargs):
+        super().__init__(**style_kwargs)             # hand over style args
+        self._update(cubes)
+
+        # hand trace over as callable
+        self.style.model3d.add_trace(partial(self._custom_trace3d, 'plotly'))
+        self.style.model3d.add_trace(partial(self._custom_trace3d, 'matplotlib'))
+
+    @property
+    def cubes(self):
+        """ Number of cubes"""
+        return self._cubes
+
+    @cubes.setter
+    def cubes(self, inp):
+        """ set cubes"""
+        self._update(inp)
+
+    def _update(self, cubes):
+        """updates the MagnetRing instance"""
+        self._cubes = cubes
+        ring_radius = cubes/3
+        
+        # construct in temporary Collection for path transfer
+        temp_coll = magpy.Collection()
+        for i in range(cubes):
+            child = magpy.magnet.Cuboid(
+                magnetization=(1000,0,0),
+                dimension=(1,1,1),
+                position=(ring_radius,0,0)
+            )
+            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
+            temp_coll.add(child)
+
+        # transfer path and children
+        temp_coll.position = self.position
+        temp_coll.orientation = self.orientation
+        self.children = temp_coll.children
+
+        return self
+
+    def _custom_trace3d(self, backend):
+        """ creates a parameter-dependent 3d model"""
+        r1 = self.cubes/3 - .6
+        r2 = self.cubes/3 + 0.6
+        trace = magpy.graphics.model3d.make_CylinderSegment(
+            backend=backend,
+            dimension=(r1, r2, 1.1, 0, 360),
+            vert=150,
+            **{('opacity' if backend=='plotly' else 'alpha') :0.5}
+        )
+        return trace
+```
+
+All we have done is, to remove the trace construction from the `_update` method, and instead provide `_custom_trace3d` as callable in `__init__` with the help of `partial`.
+
+```{code-cell} ipython3
+ring0 = MagnetRing() 
+%time for _ in range(100): ring0.cubes=10
+
+ring1 = MagnetRingAdv() 
+%time for _ in range(100): ring1.cubes=10
+```
+
+This example is not very impressive because the provided trace is not very heavy. Finally, we play around with our new compound:
+
+```{code-cell} ipython3
+rings = []
+for i,cub in zip([2,7,12,17,22], [20,16,12,8,4]):
+    ring = MagnetRingAdv(cubes=cub, style_label=f'MagnetRingAdv (x{cub})')
+    ring.rotate_from_angax(angle=np.linspace(0,45,10), axis=(1,-1,0))
+    ring.move(np.linspace((0,0,0), (-i,-i,i), i))
+    rings.append(ring)
+
+magpy.show(rings, animation=2, backend='plotly', style_path_show=False)
+```
+
+
diff --git a/docs/examples/examples_21_compound_class.md b/docs/examples/examples_21_compound_class.md
deleted file mode 100644
index d40878b00..000000000
--- a/docs/examples/examples_21_compound_class.md
+++ /dev/null
@@ -1,226 +0,0 @@
----
-jupytext:
-  text_representation:
-    extension: .md
-    format_name: myst
-    format_version: 0.13
-    jupytext_version: 1.13.7
-kernelspec:
-  display_name: Python 3 (ipykernel)
-  language: python
-  name: python3
----
-
-(examples-own-dynamic-3d-model)=
-
-# Advanced compounds
-
-This tutorial brings the *compound philosophy* of collections to the next level by subclassing the `Collection` class and adding a dynamic 3D representation.
-
-## Efficient 3D models
-
-The Matplotlib and Plotly libraries were not designed for complex 3D graphic outputs. As a result, it becomes often inconvenient and slow when attempting to display many 3D objects. One solution to this problem when dealing with large collections, is to represent the latter by a single encompassing body, and to deactivate the individual 3D models of all children. This is demonstrated in the following example.
-
-```{code-cell} ipython3
-import magpylib as magpy
-
-# create collection
-coll = magpy.Collection()
-for index in range(10):
-    cuboid = magpy.magnet.Cuboid(
-        magnetization=(0, 0, 1000 * (index%2-.5)),
-        dimension=(10,10,10),
-        position=(index*10,0,0),
-    )
-    coll.add(cuboid)
-
-# add 3D-trace
-plotly_trace = magpy.graphics.model3d.make_Cuboid(
-    backend='matplotlib',
-    dimension=(104, 12, 12),
-    position=(45, 0, 0),
-    alpha=0.5,
-)
-coll.style.model3d.add_trace(plotly_trace)
-
-coll.style.label='Collection with visible children'
-coll.show()
-
-# hide the children default 3D representation
-coll.set_children_styles(model3d_showdefault=False)
-coll.style.label = 'Collection with hidden children'
-coll.show()
-```
-
-```{note}
-The `Collection` position is set to (0,0,0) at creation time. Any added extra 3D-model will be bound to the local coordinate system of to the `Collection` and `rotated`/`moved` together with its parent object.
-```
-
-## Subclassing collections
-
-By subclassing the Magpylib `Collection`, we can define special _compound_ objects that have their own new properties, methods and 3d trace. In the following example we build a _magnetic ring_ object which is simply a ring of cuboid magnets. It has the `cubes` property which refers to the number of cuboids in the ring and can be dynamically updated. The `MagnetRing` object itself behaves like a native Magpylib source.
-
-```{code-cell} ipython3
-import magpylib as magpy
-
-class MagnetRing(magpy.Collection):
-    """ A ring of cuboid magnets
-
-    Parameters
-    ----------
-    cubes: int, default=6
-        Number of cubes on ring
-    """
-
-    def __init__(self, cubes=6, **style_kwargs):
-        super().__init__(**style_kwargs)
-        self.update(cubes)
-
-    @property
-    def cubes(self):
-        """ Number of cubes"""
-        return self._cubes
-
-    @cubes.setter
-    def cubes(self, inp):
-        """ set cubes"""
-        self.update(inp)
-
-    def update(self, cubes):
-        """updates the MagnetRing instance"""
-        self._cubes = cubes
-        ring_radius = cubes/3
-
-        # construct MagnetRing in temporary Collection
-        temp_coll = magpy.Collection()
-        for i in range(cubes):
-            child = magpy.magnet.Cuboid(
-                magnetization=(1000,0,0),
-                dimension=(1,1,1),
-                position=(ring_radius,0,0)
-            )
-            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
-            temp_coll.add(child)
-
-        # adjust position/orientation and replace children
-        temp_coll.position = self.position
-        temp_coll.orientation = self.orientation
-        self.children = temp_coll.children
-
-        # add parameter-dependent 3d trace
-        self.style.model3d.data = []
-        self.style.model3d.add_trace(self.create_trace3d('plotly'))
-        self.style.model3d.add_trace(self.create_trace3d('matplotlib'))
-
-        return self
-
-    def create_trace3d(self, backend):
-        """ creates a parameter-dependent 3d model"""
-        r1 = self.cubes/3 - .6
-        r2 = self.cubes/3 + 0.6
-        trace = magpy.graphics.model3d.make_CylinderSegment(
-            backend=backend,
-            dimension=(r1, r2, 1.1, 0, 360)
-        )
-        if backend=='plotly':
-            trace['kwargs']['opacity'] = 0.5
-        return trace
-
-# add a sensor
-sensor = magpy.Sensor(position=(0, 0, 0))
-
-# create a MagnetRing class instance
-ring = MagnetRing()
-
-# treat the Magnetic ring like a native magpylib source object
-ring.position = (0,0,10)
-ring.rotate_from_angax(angle=45, axis=(1,-1,0))
-print(f"B-field at sensor → {ring.getB(sensor).round(2)}")
-magpy.show(ring, sensor, backend='plotly')
-
-# modify object custom attribute
-ring.cubes=15
-print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(2)}")
-magpy.show(ring, sensor, backend='plotly')
-```
-
-## Postponed trace construction
-
-Custom traces might be computationally costly to construct, and in the above example they are recomputed every time a parameter is changed. This can lead to quite some unwanted overhead, as the construction is only necessary once `show` is called.
-
-To make your classes ready for heavy computation, it is possible to provide a callable as a trace, which will only be constructed when `show` is called. The following modification of the above example demonstrates this. All we do is to remove the trace from the `update` method, and instead provide `create_trace3d` as callable `model3d`.
-
-```{code-cell} ipython3
-from functools import partial
-import magpylib as magpy
-import numpy as np
-
-class MagnetRing(magpy.Collection):
-    """ A ring of cuboid magnets
-
-    Parameters
-    ----------
-    cubes: int, default=6
-        Number of cubes on ring
-    """
-
-    def __init__(self, cubes=6, **style_kwargs):
-        super().__init__(**style_kwargs)
-        self.update(cubes)
-        self.style.model3d.add_trace(partial(self.create_trace3d, 'plotly'))
-        self.style.model3d.add_trace(partial(self.create_trace3d, 'matplotlib'))
-
-    @property
-    def cubes(self):
-        """ Number of cubes"""
-        return self._cubes
-
-    @cubes.setter
-    def cubes(self, inp):
-        """ set cubes"""
-        self.update(inp)
-
-    def update(self, cubes):
-        """updates the MagnetRing instance"""
-        self._cubes = cubes
-        ring_radius = cubes/3
-
-        # construct MagnetRing in temporary Collection
-        temp_coll = magpy.Collection()
-        for i in range(cubes):
-            child = magpy.magnet.Cuboid(
-                magnetization=(1000,0,0),
-                dimension=(1,1,1),
-                position=(ring_radius,0,0)
-            )
-            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
-            temp_coll.add(child)
-
-        # adjust position/orientation and replace children
-        temp_coll.position = self.position
-        temp_coll.orientation = self.orientation
-        self.children = temp_coll.children
-
-        return self
-
-    def create_trace3d(self, backend):
-        """ creates a parameter-dependent 3d model"""
-        r1 = self.cubes/3 - .6
-        r2 = self.cubes/3 + 0.6
-        trace = magpy.graphics.model3d.make_CylinderSegment(
-            backend=backend,
-            dimension=(r1, r2, 1.1, 0, 360),
-            **{('opacity' if backend=='plotly' else 'alpha') :0.5}
-        )
-        return trace
-
-# create multiple `MagnetRing` instances and animate paths
-rings = []
-for i,cub in zip([2,7,12,17,22], [20,16,12,8,4]):
-    ring = MagnetRing(cubes=cub, style_label=f'MagnetRing (x{cub})')
-    ring.rotate_from_angax(angle=np.linspace(0,45,10), axis=(1,-1,0))
-    ring.move(np.linspace((0,0,0), (-i,-i,i), i))
-    rings.append(ring)
-
-magpy.show(rings, animation=2, backend='plotly', style_path_show=False)
-```
diff --git a/docs/examples/examples_22_fem_interpolation.md b/docs/examples/examples_22_field_interpolation.md
similarity index 100%
rename from docs/examples/examples_22_fem_interpolation.md
rename to docs/examples/examples_22_field_interpolation.md
diff --git a/docs/examples/examples_30_field_of_a_coil.md b/docs/examples/examples_30_field_of_a_coil.md
index 58ea511b0..b81692056 100644
--- a/docs/examples/examples_30_field_of_a_coil.md
+++ b/docs/examples/examples_30_field_of_a_coil.md
@@ -13,9 +13,9 @@ kernelspec:
 
 # Field of a Coil
 
-A coil consists of large number of windings, each of which can be modeled using `Loop` sources. The individual loops are combined in a `Collection`  which can then be treated like a single magnetic field source itself, following the compound object paradigm, see {ref}`examples-collections-compound`.
+In this example we model the magnetic field of a coil. Teh coil consists of multiple circular current loops, the windings, each of which can be modeled using Magpylib `Loop` source objects. The individual windings are combined in a `Collection` which itself behaves like a single magnetic field source.
 
-One must be careful to take the line-current approximation into consideration. This means that the field diverges when approaching the current, while the field is correct outside a hypothetical wire with homogeneous current distribution.
+One must be careful to take the line-current approximation into consideration. This means that the field diverges when approaching the current line, while the field is correct outside a hypothetical wire with homogeneous current distribution.
 
 ```{code-cell} ipython3
 import numpy as np
@@ -56,3 +56,5 @@ plt.colorbar(sp.lines, ax=ax2, label='[mT]')
 plt.tight_layout()
 plt.show()
 ```
+
+In {ref}`intro-collections` we show a similar example where a Helmholtz coil is modeled using nested collections.
diff --git a/docs/index.md b/docs/index.md
index a411c532b..bf5ddff7f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -69,11 +69,9 @@ _autogen/magpylib
 _changelog.md
 ```
 
+--------------------------
+
 # Index
 
 - {ref}`genindex`
 - {ref}`modindex`
-
-```bash
-
-```
diff --git a/magpylib/__init__.py b/magpylib/__init__.py
index 2687efa9a..115fcbb09 100644
--- a/magpylib/__init__.py
+++ b/magpylib/__init__.py
@@ -12,7 +12,7 @@
 Resources
 ---------
 
-Documentation on Read-the-docs:
+Examples and documentation on Read-the-docs:
 
 https://magpylib.readthedocs.io/en/latest/
 
@@ -24,104 +24,6 @@
 
 https://www.sciencedirect.com/science/article/pii/S2352711020300170
 
-Introduction
-------------
-
-Define magnets, currents and sensors as python objects, set their position and orientation in a global coordinate system and compute the magnetic field.
-
->>> import magpylib as magpy
-
-Define a ``Cuboid`` magnet source object.
-
->>> src1 = magpy.magnet.Cuboid(magnetization=(0,0,1000), dimension=(1,2,3))
->>> print(src1.position)
->>> print(src1.orientation.as_euler('xyz', degrees=True))
-[0. 0. 0.]
-[0. 0. 0.]
-
-Define a sensor object at a specific position.
-
->>> sens1 = magpy.Sensor(position=(1,2,3))
->>> print(sens1.position)
-[1. 2. 3.]
-
-Use the built-in move method to move a second source around.
-
->>> src2 = magpy.current.Loop(current=15, diameter=3)
->>> src2.move((1, 1, 1))
->>> print(src2.position)
-[1. 1. 1.]
-
-Use the built-in rotate methods to move a second sensor around.
-
->>> sens2 = magpy.Sensor(position=(1,0,0))
->>> sens2.rotate_from_angax(angle=45, axis=(0,0,1), anchor=(0,0,0))
->>> print(sens2.position)
->>> print(sens2.orientation.as_euler('xyz', degrees=True))
-[0.70710678 0.70710678  0.        ]
-[0.         0.         45.       ]
-
-Compute the B-field generated by the source `src1` at the sensor `sens1`.
-
->>> B = magpy.getB(src1, sens1)
->>> print(B)
-[ 7.48940807 13.41208607  8.02900384]
-
-Compute the H-field of two sources at two sensors with one line of code.
-
->>> H = magpy.getH([src1, src2], [sens1, sens2])
->>> print(H)
-[[[ 5.95988158e+00  1.06729990e+01  6.38927824e+00]
-  [ 1.98854533e-14  1.98854533e-14 -6.10055863e+01]]
-
- [[ 2.68813151e-17  4.39005190e-01  8.11887842e-01]
-  [ 5.64983190e-01 -2.77555756e-16  2.81121230e+00]]]
-
-Position and orientation attributes can be paths.
-
->>> src1.move([(1,1,1), (2,2,2), (3,3,3)])
->>> print(src1.position)
-[[0. 0. 0.]
- [1. 1. 1.]
- [2. 2. 2.]
- [3. 3. 3.]]
-
-Field computation is automatically performed on the whole path in a vectorized form.
-
->>> B = src1.getB(sens1)
->>> print(B)
-[[  7.48940807  13.41208607   8.02900384]
- [  0.          99.07366165 109.1400359 ]
- [-80.14272938   0.         -71.27583002]
- [  0.           0.         -24.62209631]]
-
-Group sources and sensors for common manipulation using the `Collection` class.
-
->>> col = magpy.Collection(sens1, src2)
->>> print(sens1.position)
->>> print(src2.position)
->>> print(col.position)
-[1. 2. 3.]
-[1. 1. 1.]
-[0. 0. 0.]
-
->>> col.move((1,1,1))
->>> print(sens1.position)
->>> print(src2.position)
->>> print(col.position)
-[2. 3. 4.]
-[2. 2. 2.]
-[1. 1. 1.]
-
-When all source and sensor objects are created and all paths are defined the `show()` (top level function and method of all Magpylib objects) provides a convenient way to graphically display the geometric arrangement through the Matplotlib,
-
->>> magpy.show(col)
----> graphic output from matplotlib
-
-and and the Plotly graphic backend.
-
->>> magpy.show(col, backend='plotly')
----> graphic output from plotly
 """
 
 # module level dunders
diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py
index a9c0924bb..fcc58b8a0 100644
--- a/magpylib/_src/display/display.py
+++ b/magpylib/_src/display/display.py
@@ -10,7 +10,7 @@
     check_input_zoom,
     check_input_animation,
     check_format_input_vector,
-    )
+)
 
 def show(
     *objects,
@@ -114,9 +114,10 @@ def show(
         markers,
         dims=(2,),
         shape_m1=3,
-        sig_name='markers',
-        sig_type='array_like of shape (n,3)',
-        allow_None=True)
+        sig_name="markers",
+        sig_type="array_like of shape (n,3)",
+        allow_None=True,
+    )
 
     check_input_zoom(zoom)
     check_input_animation(animation)
@@ -124,14 +125,15 @@ def show(
         markers,
         dims=(2,),
         shape_m1=3,
-        sig_name='markers',
-        sig_type='array_like of shape (n,3)',
-        allow_None=True)
+        sig_name="markers",
+        sig_type="array_like of shape (n,3)",
+        allow_None=True,
+    )
 
     if backend == "matplotlib":
         if animation is not False:
             msg = "The matplotlib backend does not support animation at the moment.\n"
-            msg+= "Use `backend=plotly` instead."
+            msg += "Use `backend=plotly` instead."
             warnings.warn(msg)
             # animation = False
         display_matplotlib(
diff --git a/magpylib/_src/display/display_matplotlib.py b/magpylib/_src/display/display_matplotlib.py
index b8a8a6003..de83064a7 100644
--- a/magpylib/_src/display/display_matplotlib.py
+++ b/magpylib/_src/display/display_matplotlib.py
@@ -1,6 +1,5 @@
 """ matplotlib draw-functionalities"""
 
-from itertools import cycle
 import numpy as np
 import matplotlib.pyplot as plt
 from mpl_toolkits.mplot3d.art3d import Poly3DCollection
@@ -15,6 +14,7 @@
     system_size,
     faces_sphere,
     faces_cylinder_segment,
+    get_flatten_objects_properties,
 )
 from magpylib._src.input_checks import check_excitations
 from magpylib._src.style import get_style
@@ -333,8 +333,6 @@ def display_matplotlib(
     # pylint: disable=too-many-statements
 
     # apply config default values if None
-    if color_sequence is None:
-        color_sequence = Config.display.colorsequence
     # create or set plotting axis
     if axis is None:
         fig = plt.figure(dpi=80, figsize=(8, 8))
@@ -351,108 +349,94 @@ def display_matplotlib(
     points = []
     dipoles = []
     sensors = []
-    faced_objects_color = []
-
-    for semi_flat_obj, color in zip(obj_list_semi_flat, cycle(color_sequence)):
-        flat_objs = [semi_flat_obj]
-        if getattr(semi_flat_obj, "children", None) is not None:
-            flat_objs.extend(semi_flat_obj.children)
-            if getattr(semi_flat_obj, "position", None) is not None:
-                color = (
-                    color
-                    if semi_flat_obj.style.color is None
-                    else semi_flat_obj.style.color
+    flat_objs_props = get_flatten_objects_properties(
+        *obj_list_semi_flat, color_sequence=color_sequence
+    )
+    for obj, props in flat_objs_props.items():
+        color = props["color"]
+        style = get_style(obj, Config, **kwargs)
+        path_frames = style.path.frames
+        if path_frames is None:
+            path_frames = True
+        obj_color = style.color if style.color is not None else color
+        lw = 0.25
+        faces = None
+        if obj.style.model3d.data:
+            pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color)
+            points += pts
+        if obj.style.model3d.showdefault:
+            if obj._object_type == "Cuboid":
+                lw = 0.5
+                faces = faces_cuboid(obj, path_frames)
+            elif obj._object_type == "Cylinder":
+                faces = faces_cylinder(obj, path_frames)
+            elif obj._object_type == "CylinderSegment":
+                faces = faces_cylinder_segment(obj, path_frames)
+            elif obj._object_type == "Sphere":
+                faces = faces_sphere(obj, path_frames)
+            elif obj._object_type == "Line":
+                if style.arrow.show:
+                    check_excitations([obj])
+                arrow_size = style.arrow.size if style.arrow.show else 0
+                arrow_width = style.arrow.width
+                points += draw_line(
+                    [obj], path_frames, obj_color, arrow_size, arrow_width, ax
                 )
-
-        for obj in flat_objs:
-            style = get_style(obj, Config, **kwargs)
-            path_frames = style.path.frames
-            if path_frames is None:
-                path_frames = True
-            obj_color = style.color if style.color is not None else color
-            lw = 0.25
-            faces = None
-            if obj.style.model3d.data:
-                pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color)
-                points += pts
-            if obj.style.model3d.showdefault:
-                if obj._object_type == "Cuboid":
-                    lw = 0.5
-                    faces = faces_cuboid(obj, path_frames)
-                elif obj._object_type == "Cylinder":
-                    faces = faces_cylinder(obj, path_frames)
-                elif obj._object_type == "CylinderSegment":
-                    faces = faces_cylinder_segment(obj, path_frames)
-                elif obj._object_type == "Sphere":
-                    faces = faces_sphere(obj, path_frames)
-                elif obj._object_type == "Line":
-                    if style.arrow.show:
-                        check_excitations([obj])
-                    arrow_size = style.arrow.size if style.arrow.show else 0
-                    arrow_width = style.arrow.width
-                    points += draw_line(
-                        [obj], path_frames, obj_color, arrow_size, arrow_width, ax
-                    )
-                elif obj._object_type == "Loop":
-                    if style.arrow.show:
-                        check_excitations([obj])
-                    arrow_width = style.arrow.width
-                    arrow_size = style.arrow.size if style.arrow.show else 0
-                    points += draw_circular(
-                        [obj], path_frames, obj_color, arrow_size, arrow_width, ax
-                    )
-                elif obj._object_type == "Sensor":
-                    sensors.append((obj, obj_color))
-                    points += draw_pixel(
-                        [obj],
-                        ax,
-                        obj_color,
-                        style.pixel.color,
-                        style.pixel.size,
-                        style.pixel.symbol,
-                        path_frames,
-                    )
-                elif obj._object_type == "Dipole":
-                    dipoles.append((obj, obj_color))
-                    points += [obj.position]
-                elif obj._object_type == "CustomSource":
-                    draw_markers(
-                        np.array([obj.position]), ax, obj_color, symbol="*", size=10
-                    )
-                    label = (
-                        obj.style.label
-                        if obj.style.label is not None
-                        else str(type(obj).__name__)
-                    )
-                    ax.text(*obj.position, label, horizontalalignment="center")
-                    points += [obj.position]
-                if faces is not None:
-                    faced_objects_color += [obj_color]
-                    alpha = style.opacity
-                    pts = draw_faces(faces, obj_color, lw, alpha, ax)
-                    points += [np.vstack(pts).reshape(-1, 3)]
-                    if style.magnetization.show:
-                        check_excitations([obj])
-                        pts = draw_directs_faced(
-                            [obj],
-                            [obj_color],
-                            ax,
-                            path_frames,
-                            style.magnetization.size,
-                        )
-                        points += pts
-            if style.path.show:
-                marker, line = style.path.marker, style.path.line
-                points += draw_path(
-                    obj,
-                    obj_color,
-                    marker.symbol,
-                    marker.size,
-                    marker.color,
-                    line.style,
-                    line.width,
+            elif obj._object_type == "Loop":
+                if style.arrow.show:
+                    check_excitations([obj])
+                arrow_width = style.arrow.width
+                arrow_size = style.arrow.size if style.arrow.show else 0
+                points += draw_circular(
+                    [obj], path_frames, obj_color, arrow_size, arrow_width, ax
+                )
+            elif obj._object_type == "Sensor":
+                sensors.append((obj, obj_color))
+                points += draw_pixel(
+                    [obj],
                     ax,
+                    obj_color,
+                    style.pixel.color,
+                    style.pixel.size,
+                    style.pixel.symbol,
+                    path_frames,
                 )
+            elif obj._object_type == "Dipole":
+                dipoles.append((obj, obj_color))
+                points += [obj.position]
+            elif obj._object_type == "CustomSource":
+                draw_markers(
+                    np.array([obj.position]), ax, obj_color, symbol="*", size=10
+                )
+                label = (
+                    obj.style.label
+                    if obj.style.label is not None
+                    else str(type(obj).__name__)
+                )
+                ax.text(*obj.position, label, horizontalalignment="center")
+                points += [obj.position]
+            if faces is not None:
+                alpha = style.opacity
+                pts = draw_faces(faces, obj_color, lw, alpha, ax)
+                points += [np.vstack(pts).reshape(-1, 3)]
+                if style.magnetization.show:
+                    check_excitations([obj])
+                    pts = draw_directs_faced(
+                        [obj], [obj_color], ax, path_frames, style.magnetization.size,
+                    )
+                    points += pts
+        if style.path.show:
+            marker, line = style.path.marker, style.path.line
+            points += draw_path(
+                obj,
+                obj_color,
+                marker.symbol,
+                marker.size,
+                marker.color,
+                line.style,
+                line.width,
+                ax,
+            )
 
     # markers -------------------------------------------------------
     if markers is not None and markers:
diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py
index d47e60166..faa5704ec 100644
--- a/magpylib/_src/display/display_utility.py
+++ b/magpylib/_src/display/display_utility.py
@@ -1,9 +1,11 @@
 """ Display function codes"""
 
+from itertools import cycle
 from typing import Tuple
 import numpy as np
 from scipy.spatial.transform import Rotation as RotScipy
 from magpylib._src.style import Markers
+from magpylib._src.defaults.defaults_classes import default_settings as Config
 
 
 class MagpyMarkers:
@@ -456,3 +458,43 @@ def system_size(points):
     else:
         limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1
     return limx0, limx1, limy0, limy1, limz0, limz1
+
+
+def get_flatten_objects_properties(
+    *obj_list_semi_flat, color_sequence=None, color_cycle=None, **parent_props,
+):
+    """returns a flat dict -> (obj: display_props, ...) from nested collections"""
+    if color_sequence is None:
+        color_sequence = Config.display.colorsequence
+    if color_cycle is None:
+        color_cycle = cycle(color_sequence)
+    flat_objs = {}
+    for subobj in obj_list_semi_flat:
+        isCollection = getattr(subobj, "children", None) is not None
+        props = {**parent_props}
+        parent_color = parent_props.get("color", '!!!missing!!!')
+        if parent_color == "!!!missing!!!":
+            props["color"] = next(color_cycle)
+        if parent_props.get("legendgroup", None) is None:
+            props["legendgroup"] = f"{subobj}"
+        if parent_props.get("showlegend", None) is None:
+            props["showlegend"] = True
+        if parent_props.get("legendtext", None) is None:
+            legendtext = None
+            if isCollection:
+                legendtext = getattr(getattr(subobj, "style", None), "label", None)
+                legendtext = f"{subobj!r}" if legendtext is None else legendtext
+            props["legendtext"] = legendtext
+        flat_objs[subobj] = props
+        if isCollection:
+            if subobj.style.color is not None:
+                flat_objs[subobj]["color"] = subobj.style.color
+            flat_objs.update(
+                get_flatten_objects_properties(
+                    *subobj.children,
+                    color_sequence=color_sequence,
+                    color_cycle=color_cycle,
+                    **flat_objs[subobj],
+                )
+            )
+    return flat_objs
diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py
index 287e7f361..c94812791 100644
--- a/magpylib/_src/display/plotly/plotly_display.py
+++ b/magpylib/_src/display/plotly/plotly_display.py
@@ -3,7 +3,7 @@
 # pylint: disable=too-many-branches
 
 import numbers
-from itertools import cycle, combinations
+from itertools import combinations
 from typing import Tuple
 import warnings
 
@@ -31,6 +31,7 @@
     draw_arrow_from_vertices,
     draw_arrowed_circle,
     place_and_orient_model3d,
+    get_flatten_objects_properties,
 )
 from magpylib._src.defaults.defaults_utility import (
     SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY,
@@ -52,6 +53,7 @@
     merge_traces,
     getColorscale,
     getIntensity,
+    clean_legendgroups,
 )
 
 
@@ -334,7 +336,7 @@ def make_Sensor(
         pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum
         distance between any pixel of the same sensor, equal to `size_pixel`.
     """
-    pixel = np.array(pixel)
+    pixel = np.array(pixel).reshape((-1,3))
     default_suffix = (
         f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)"""
         if pixel.ndim != 1
@@ -352,7 +354,7 @@ def make_Sensor(
     )
     if autosize is not None:
         dim *= autosize
-    if np.squeeze(pixel).ndim == 1:
+    if pixel.shape[0] == 1:
         dim_ext = dim
     else:
         hull_dim = pixel.max(axis=0) - pixel.min(axis=0)
@@ -364,7 +366,7 @@ def make_Sensor(
     x, y, z = vertices.T
     sensor.update(x=x, y=y, z=z)
     meshes_to_merge = [sensor]
-    if np.squeeze(pixel).ndim != 1:
+    if pixel.shape[0] != 1:
         pixel_color = style.pixel.color
         pixel_size = style.pixel.size
         combs = np.array(list(combinations(pixel, 2)))
@@ -684,7 +686,7 @@ def make_path(input_obj, style, legendgroup, kwargs):
     return scatter_path
 
 
-def draw_frame(objs, color_sequence, zoom, autosize=None, **kwargs) -> Tuple:
+def draw_frame(obj_list_semi_flat, color_sequence, zoom, autosize=None, **kwargs) -> Tuple:
     """
     Creates traces from input `objs` and provided parameters, updates the size of objects like
     Sensors and Dipoles in `kwargs` depending on the canvas size.
@@ -694,6 +696,7 @@ def draw_frame(objs, color_sequence, zoom, autosize=None, **kwargs) -> Tuple:
     traces_dicts, kwargs: dict, dict
         returns the traces in a obj/traces_list dictionary and updated kwargs
     """
+    # pylint: disable=protected-access
     return_autosize = False
     Sensor = _src.obj_classes.Sensor
     Dipole = _src.obj_classes.Dipole
@@ -701,51 +704,18 @@ def draw_frame(objs, color_sequence, zoom, autosize=None, **kwargs) -> Tuple:
     # dipoles and sensors use autosize, the trace building has to be put at the back of the queue.
     # autosize is calculated from the other traces overall scene range
     traces_to_resize = {}
-    for obj, color in zip(objs, cycle(color_sequence)):
-        subobjs = [obj]
-        legendgroup = None
-        if getattr(obj, "children", None) is not None:
-            subobjs.extend(obj.children)
-            legendgroup = f"{obj}"
-            if getattr(obj, "position", None) is not None:
-                color = color if obj.style.color is None else obj.style.color
-        first_shown = False
-        for ind, subobj in enumerate(subobjs):
-            if legendgroup is not None:
-                if (
-                    subobj.style.model3d.showdefault or ind + 1 == len(subobjs)
-                ) and not first_shown:
-                    # take name of parent
-                    first_shown = True
-                    if getattr(subobj, "children", None) is not None:
-                        first_shown = any(m3.show for m3 in obj.style.model3d.data)
-                    showlegend = True
-                    legendtext = getattr(getattr(obj, "style", None), "label", None)
-                    legendtext = f"{obj!r}" if legendtext is None else legendtext
-                else:
-                    legendtext = None
-                    showlegend = False
-                # print(f"{ind+1:02d}/{len(subobjs):02d} {legendtext=}, {showlegend=}")
-            else:
-                showlegend = True
-                legendtext = None
-
-            params = {
-                **dict(
-                    color=color,
-                    legendgroup=legendgroup,
-                    showlegend=showlegend,
-                    legendtext=legendtext,
-                ),
-                **kwargs,
-            }
-            if isinstance(subobj, (Dipole, Sensor)):
-                traces_to_resize[subobj] = {**params}
-                # temporary coordinates to be able to calculate ranges
-                x, y, z = subobj.position.T
-                traces_dicts[subobj] = [dict(x=x, y=y, z=z)]
-            else:
-                traces_dicts[subobj] = get_plotly_traces(subobj, **params)
+    flat_objs_props = get_flatten_objects_properties(
+        *obj_list_semi_flat, color_sequence=color_sequence
+    )
+    for obj, params in flat_objs_props.items():
+        params.update(kwargs)
+        if isinstance(obj, (Dipole, Sensor)):
+            traces_to_resize[obj] = {**params}
+            # temporary coordinates to be able to calculate ranges
+            x, y, z = obj._position.T
+            traces_dicts[obj] = [dict(x=x, y=y, z=z)]
+        else:
+            traces_dicts[obj] = get_plotly_traces(obj, **params)
     traces = [t for tr in traces_dicts.values() for t in tr]
     ranges = get_scene_ranges(*traces, zoom=zoom)
     if autosize is None or autosize == "return":
@@ -1135,6 +1105,7 @@ def display_plotly(
             fig.add_traces(traces)
             fig.update_layout(title_text=title)
             apply_fig_ranges(fig, zoom=zoom)
+        clean_legendgroups(fig)
         fig.update_layout(legend_itemsizing="constant")
     if show_fig:
         fig.show(renderer=renderer)
diff --git a/magpylib/_src/display/plotly/plotly_utility.py b/magpylib/_src/display/plotly/plotly_utility.py
index 2f706afcb..60b174b49 100644
--- a/magpylib/_src/display/plotly/plotly_utility.py
+++ b/magpylib/_src/display/plotly/plotly_utility.py
@@ -126,3 +126,17 @@ def getColorscale(
             [1.0, color_north],
         ]
     return colorscale
+
+def clean_legendgroups(fig):
+    """removes legend duplicates"""
+    frames = [fig.data]
+    if fig.frames:
+        data_list = [f["data"] for f in fig.frames]
+        frames.extend(data_list)
+    for f in frames:
+        legendgroups = []
+        for t in f:
+            if t.legendgroup not in legendgroups and t.legendgroup is not None:
+                legendgroups.append(t.legendgroup)
+            elif t.legendgroup is not None and t.legendgrouptitle.text is None:
+                t.showlegend = False
diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py
index b535fd462..7ad13256f 100644
--- a/magpylib/_src/fields/field_wrap_BH_level2.py
+++ b/magpylib/_src/fields/field_wrap_BH_level2.py
@@ -2,6 +2,7 @@
 from scipy.spatial.transform import Rotation as R
 from magpylib._src.utility import (
     check_static_sensor_orient,
+    format_obj_input,
     format_src_inputs,
 )
 from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1
@@ -195,7 +196,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray:
         )
 
     # format sources input:
-    #   input: allow only bare src objects or 1D lists/tuple of src and col
+    #   input: allow only one bare src object or a 1D list/tuple of src and col
     #   out: sources = ordered list of sources
     #   out: src_list = ordered list of sources with flattened collections
     sources, src_list = format_src_inputs(sources)
@@ -293,7 +294,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray:
     if l > l0:
         for i,src in enumerate(sources):
             if src._object_type == 'Collection':
-                col_len = len(src.sources)
+                col_len = len(format_obj_input(src, allow="sources"))
                 B[i] = np.sum(B[i:i+col_len],axis=0)    # set B[i] to sum of slice
                 B = np.delete(B,np.s_[i+1:i+col_len],0) # delete remaining part of slice
 
diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py
index e074efdaf..57dae9623 100644
--- a/magpylib/_src/input_checks.py
+++ b/magpylib/_src/input_checks.py
@@ -9,7 +9,7 @@
 )
 from magpylib._src.defaults.defaults_classes import default_settings
 from magpylib import _src
-from magpylib._src.utility import wrong_obj_msg
+from magpylib._src.utility import format_obj_input, wrong_obj_msg, LIBRARY_SOURCES, LIBRARY_SENSORS
 
 
 #################################################################
@@ -411,9 +411,10 @@ def check_format_input_observers(inp):
             if getattr(obj, "_object_type", "") == "Sensor":
                 sensors.append(obj)
             elif getattr(obj, "_object_type", "") == "Collection":
-                if not obj.sensors:
+                child_sensors = format_obj_input(obj, allow='sensors')
+                if not child_sensors:
                     raise MagpylibBadUserInput(wrong_obj_msg(obj, allow="observers"))
-                sensors.extend(obj.sensors)
+                sensors.extend(child_sensors)
             else: # if its not a Sensor or a Collection it can only be a pos_vec
                 try:
                     obj = np.array(obj, dtype=float)
@@ -431,6 +432,67 @@ def check_format_input_observers(inp):
         return sensors
 
 
+def check_format_input_obj(
+    inp,
+    allow: str,
+    recursive = True,
+    typechecks = False,
+    ) -> list:
+    """
+    Returns a flat list of all wanted objects in input.
+
+    Parameters
+    ----------
+    input: can be
+        - objects
+
+    allow: str
+        Specify which object types are wanted, separate by +,
+        e.g. sensors+collections+sources
+
+    recursive: bool
+        Flatten Collection objects
+    """
+    # select wanted
+    wanted_types = []
+    if "sources" in allow.split("+"):
+        wanted_types += list(LIBRARY_SOURCES)
+    if "sensors" in allow.split("+"):
+        wanted_types += list(LIBRARY_SENSORS)
+    if "collections" in allow.split("+"):
+        wanted_types += ['Collection']
+
+    if typechecks:
+        all_types = list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS) + ["Collection"]
+
+    obj_list = []
+    for obj in inp:
+        obj_type = getattr(obj, "_object_type", None)
+
+        # add to list if wanted type
+        if obj_type in wanted_types:
+            obj_list.append(obj)
+
+        # recursion
+        if (obj_type == "Collection") and recursive:
+            obj_list += check_format_input_obj(
+                obj,
+                allow=allow,
+                recursive=recursive,
+                typechecks=typechecks,
+            )
+
+        # typechecks
+        if typechecks:
+            if not obj_type in all_types:
+                raise MagpylibBadUserInput(
+                    f"Input objects must be {allow} or a flat list thereof.\n"
+                    f"Instead received {type(obj)}."
+                )
+
+    return obj_list
+
+
 ############################################################################################
 ############################################################################################
 # SHOW AND GETB CHECKS
diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py
index 7c4262560..68ad2df96 100644
--- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py
+++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py
@@ -2,14 +2,101 @@
 READY FOR V4
 """
 
+import numpy as np
 from magpylib._src.display.display import show
 
+UNITS = {
+    "parent": None,
+    "position": "mm",
+    "orientation": "degrees",
+    "dimension": "mm",
+    "diameter": "mm",
+    "current": "A",
+    "magnetization": "mT",
+}
+
+
 class BaseDisplayRepr:
-    """Provides the display(self) and self.repr methods for all objects"""
+    """Provides the show and repr methods for all objects"""
 
     show = show
     _object_type = None
 
+    def _property_names_generator(self):
+        """returns a generator with class properties only"""
+        return (
+            attr
+            for attr in dir(self)
+            if isinstance(getattr(type(self), attr, None), property)
+        )
+
+    def _get_description(self, exclude=None):
+        """Returns list of string describing the object properties.
+
+        Parameters
+        ----------
+        exclude: bool, default=("style",)
+            properties to be excluded in the description view.
+        """
+        if exclude is None:
+            exclude = ()
+        params = list(self._property_names_generator())
+        lines = [f"{self!r}"]
+        for k in list(dict.fromkeys(list(UNITS) + list(params))):
+            if k in params and k not in exclude:
+                unit = UNITS.get(k, None)
+                unit_str = f"{unit}" if unit else ""
+                if k == "position":
+                    val = getattr(self, "_position")
+                    if val.shape[0] != 1:
+                        lines.append(f"  • path length: {val.shape[0]}")
+                        k = f"{k} (last)"
+                    val = f"{val[-1]}"
+                elif k == "orientation":
+                    val = getattr(self, "_orientation")
+                    val = val.as_rotvec(degrees=True)
+                    if len(val) != 1:
+                        k = f"{k} (last)"
+                    val = f"{val[-1]}"
+                elif k == 'pixel':
+                    val = getattr(self, "pixel")
+                    px_shape = val.shape[:-1]
+                    val_str = f"{int(np.product(px_shape))}"
+                    if val.ndim>2:
+                        val_str += f" ({'x'.join(str(p) for p in px_shape)})"
+                    val = val_str
+                else:
+                    val = getattr(self, k)
+                lines.append(f"  • {k}: {val} {unit_str}")
+        return lines
+
+
+    def describe(self, *, exclude=("style",), return_string=False):
+        """Returns a view of the object properties.
+
+        Parameters
+        ----------
+        exclude: bool, default=("style",)
+            Properties to be excluded in the description view.
+
+        return_string: bool, default=`False`
+            If `False` print description with stdout, if `True` return as string.
+        """
+        lines = self._get_description(exclude=exclude)
+        output = "\n".join(lines)
+
+        if return_string:
+            return output
+
+        print(output)
+        return None
+
+
+    def _repr_html_(self):
+        lines = self._get_description(exclude=("style",))
+        return f"""<pre>{'<br>'.join(lines)}</pre>"""
+
+
     def __repr__(self) -> str:
         name = getattr(self, "name", None)
         if name is None and hasattr(self, "style"):
diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py
index ec418f6f9..68d562372 100644
--- a/magpylib/_src/obj_classes/class_BaseGeo.py
+++ b/magpylib/_src/obj_classes/class_BaseGeo.py
@@ -12,7 +12,8 @@
 from magpylib._src.input_checks import (
     check_format_input_orientation,
     check_format_input_vector,
-    )
+)
+from magpylib._src.exceptions import MagpylibBadUserInput
 from magpylib._src.utility import add_iteration_suffix
 
 
@@ -23,12 +24,13 @@ def pad_slice_path(path1, path2):
     return: path2 with format (N,x)
     """
     delta_path = len(path1) - len(path2)
-    if delta_path>0:
-        return np.pad(path2, ((0,delta_path), (0,0)), 'edge')
-    if delta_path<0:
+    if delta_path > 0:
+        return np.pad(path2, ((0, delta_path), (0, 0)), "edge")
+    if delta_path < 0:
         return path2[-delta_path:]
     return path2
 
+
 class BaseGeo(BaseTransform):
     """Initializes position and orientation properties
     of an object in a global CS.
@@ -61,14 +63,17 @@ class BaseGeo(BaseTransform):
 
     """
 
-    def __init__(self, position=(0.,0.,0.,), orientation=None, style=None, **kwargs):
+    def __init__(
+        self, position=(0.0, 0.0, 0.0,), orientation=None, style=None, **kwargs
+    ):
 
+        self._parent = None
         # set _position and _orientation attributes
         self._init_position_orientation(position, orientation)
 
         # style
         self.style_class = self._get_style_class()
-        if style is not None or kwargs: #avoid style creation cost if not needed
+        if style is not None or kwargs:  # avoid style creation cost if not needed
             self.style = self._process_style_kwargs(style=style, **kwargs)
 
     @staticmethod
@@ -98,21 +103,22 @@ def _init_position_orientation(self, position, orientation):
         # format position and orientation inputs
         pos = check_format_input_vector(
             position,
-            dims=(1,2),
+            dims=(1, 2),
             shape_m1=3,
-            sig_name='position',
-            sig_type='array_like (list, tuple, ndarray) with shape (3,) or (n,3)',
-            reshape=True)
+            sig_name="position",
+            sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)",
+            reshape=True,
+        )
         oriQ = check_format_input_orientation(orientation, init_format=True)
 
         # padding logic: if one is longer than the other, edge-pad up the other
         len_pos = pos.shape[0]
         len_ori = oriQ.shape[0]
 
-        if len_pos>len_ori:
-            oriQ = np.pad(oriQ, ((0,len_pos-len_ori), (0,0)), 'edge')
-        elif len_pos<len_ori:
-            pos = np.pad(pos, ((0,len_ori-len_pos), (0,0)), 'edge')
+        if len_pos > len_ori:
+            oriQ = np.pad(oriQ, ((0, len_pos - len_ori), (0, 0)), "edge")
+        elif len_pos < len_ori:
+            pos = np.pad(pos, ((0, len_ori - len_pos), (0, 0)), "edge")
 
         # set attributes
         self._position = pos
@@ -128,9 +134,31 @@ def _get_style_class(self):
         return get_style_class(self)
 
     # properties ----------------------------------------------------
+    @property
+    def parent(self):
+        """The object is a child of it's parent collection."""
+        return self._parent
+
+    @parent.setter
+    def parent(self, inp):
+        if getattr(inp, "_object_type", "") == "Collection":
+            inp.add(self, override_parent=True)
+        elif inp is None:
+            if self._parent is not None:
+                self._parent.remove(self)
+            self._parent = None
+        else:
+            raise MagpylibBadUserInput(
+                "Input `parent` must be `None` or a `Collection` object."
+                f"Instead received {type(inp)}."
+            )
+
     @property
     def position(self):
-        """Object position attribute getter and setter."""
+        """
+        Object position(s) in the global coordinates in units of [mm]. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+        """
         return np.squeeze(self._position)
 
     @position.setter
@@ -151,11 +179,12 @@ def position(self, inp):
         # check and set new position
         self._position = check_format_input_vector(
             inp,
-            dims=(1,2),
+            dims=(1, 2),
             shape_m1=3,
-            sig_name='position',
-            sig_type='array_like (list, tuple, ndarray) with shape (3,) or (n,3)',
-            reshape=True)
+            sig_name="position",
+            sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)",
+            reshape=True,
+        )
 
         # pad/slice and set orientation path to same length
         oriQ = self._orientation.as_quat()
@@ -169,10 +198,13 @@ def position(self, inp):
             # set child position (pad/slice orientation)
             child.position = self._position + rel_child_pos
 
-
     @property
     def orientation(self):
-        """Object orientation attribute getter and setter."""
+        """
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+        """
         # cannot squeeze (its a Rotation object)
         if len(self._orientation) == 1:  # single path orientation - reduce dimension
             return self._orientation[0]
@@ -202,12 +234,16 @@ def orientation(self, inp):
             child.position = pad_slice_path(self._position, child._position)
             # compute rotation and apply
             old_ori_pad = R.from_quat(np.squeeze(pad_slice_path(oriQ, old_oriQ)))
-            child.rotate(self.orientation*old_ori_pad.inv(), anchor=self._position, start=0)
-
+            child.rotate(
+                self.orientation * old_ori_pad.inv(), anchor=self._position, start=0
+            )
 
     @property
     def style(self):
-        """instance of MagpyStyle for display styling options"""
+        """
+        Object style in the form of a BaseStyle object. Input must be
+        in the form of a style dictionary.
+        """
         if not hasattr(self, "_style") or self._style is None:
             self._style = self._validate_style(val=None)
         return self._style
@@ -225,31 +261,22 @@ def _validate_style(self, val=None):
             raise ValueError(
                 f"Input parameter `style` must be of type {self.style_class}.\n"
                 f"Instead received type {type(val)}"
-                )
+            )
         return val
 
+
     # dunders -------------------------------------------------------
     def __add__(self, obj):
-        """Add up sources to a Collection object.
+        """ Add up sources to a Collection object.
 
         Returns
         -------
         Collection: Collection
         """
         # pylint: disable=import-outside-toplevel
-        from magpylib._src.obj_classes.class_Collection import Collection
+        from magpylib import Collection
         return Collection(self, obj)
 
-    def __radd__(self, other):
-        """Add up sources to a Collection object. Allows to use `sum(objects)`.
-
-        Returns
-        -------
-        Collection: Collection
-        """
-        if other==0:
-            return self
-        return self.__add__(other)
 
     # methods -------------------------------------------------------
     def reset_path(self):
@@ -277,7 +304,7 @@ def reset_path(self):
         [0. 0. 0.]
         [0. 0. 0.]
         """
-        self.position = (0,0,0)
+        self.position = (0, 0, 0)
         self.orientation = None
         return self
 
@@ -304,8 +331,16 @@ def copy(self, **kwargs):
         """
         # pylint: disable=import-outside-toplevel
         from copy import deepcopy
-        obj_copy = deepcopy(self)
-        if getattr(self, '_style', None) is not None:
+
+        if not self.parent is None:
+            parent = self.parent
+            self.parent = None
+            obj_copy = deepcopy(self)
+            self.parent = parent
+        else:
+            obj_copy = deepcopy(self)
+
+        if getattr(self, "_style", None) is not None:
             label = self.style.label
             if label is None:
                 label = f"{type(self).__name__}_01"
@@ -313,11 +348,11 @@ def copy(self, **kwargs):
                 label = add_iteration_suffix(label)
             obj_copy.style.label = label
         style_kwargs = {}
-        for k,v in kwargs.items():
-            if k.startswith('style'):
+        for k, v in kwargs.items():
+            if k.startswith("style"):
                 style_kwargs[k] = v
             else:
-                setattr(obj_copy, k,v)
+                setattr(obj_copy, k, v)
         if style_kwargs:
             style_kwargs = self._process_style_kwargs(**style_kwargs)
             obj_copy.style.update(style_kwargs)
diff --git a/magpylib/_src/obj_classes/class_BaseTransform.py b/magpylib/_src/obj_classes/class_BaseTransform.py
index 21743c50e..b5b457066 100644
--- a/magpylib/_src/obj_classes/class_BaseTransform.py
+++ b/magpylib/_src/obj_classes/class_BaseTransform.py
@@ -66,7 +66,7 @@ def path_padding_param(scalar_input: bool, lenop: int, lenip: int, start: int):
     pad_behind = 0
 
     # start='auto': apply to all if scalar, append if vector
-    if start == 'auto':
+    if start == "auto":
         if scalar_input:
             start = 0
         else:
@@ -120,8 +120,8 @@ def path_padding(inpath, start, target_object):
     # pad old path depending on input
     padding, start = path_padding_param(scalar_input, len(ppath), lenip, start)
     if padding:
-        ppath = np.pad(ppath, (padding, (0, 0)), 'edge')
-        opath = np.pad(opath, (padding, (0, 0)), 'edge')
+        ppath = np.pad(ppath, (padding, (0, 0)), "edge")
+        opath = np.pad(opath, (padding, (0, 0)), "edge")
 
     # set end-index
     end = len(ppath) if scalar_input else start + lenip
@@ -129,7 +129,7 @@ def path_padding(inpath, start, target_object):
     return ppath, opath, start, end, bool(padding)
 
 
-def apply_move(target_object, displacement, start='auto'):
+def apply_move(target_object, displacement, start="auto"):
     """Implementation of the move() functionality.
 
     Parameters
@@ -154,11 +154,12 @@ def apply_move(target_object, displacement, start='auto'):
 
     # check and format inputs
     inpath = check_format_input_vector(
-            displacement,
-            dims=(1,2),
-            shape_m1=3,
-            sig_name='displacement',
-            sig_type='array_like (list, tuple, ndarray) with shape (3,) or (n,3)')
+        displacement,
+        dims=(1, 2),
+        shape_m1=3,
+        sig_name="displacement",
+        sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)",
+    )
     check_start_type(start)
 
     # pad target_object path and compute start and end-index for rotation application
@@ -173,7 +174,9 @@ def apply_move(target_object, displacement, start='auto'):
     return target_object
 
 
-def apply_rotation(target_object, rotation: R, anchor=None, start='auto', parent_path=None):
+def apply_rotation(
+    target_object, rotation: R, anchor=None, start="auto", parent_path=None
+):
     """Implementation of the rotate() functionality.
 
     Parameters
@@ -248,7 +251,7 @@ def apply_rotation(target_object, rotation: R, anchor=None, start='auto', parent
 class BaseTransform:
     """Inherit this class to provide rotation() and move() methods."""
 
-    def move(self, displacement, start='auto'):
+    def move(self, displacement, start="auto"):
         """Move object by the displacement input.
 
         Terminology for move/rotate methods:
@@ -315,15 +318,40 @@ def move(self, displacement, start='auto'):
         # Idea: An operation applied to a Collection is individually
         #    applied to its BaseGeo and to each child.
 
-        for child in getattr(self, 'children', []):
-            apply_move(child, displacement, start=start)
+        for child in getattr(self, "children", []):
+            child.move(displacement, start=start)
 
         apply_move(self, displacement, start=start)
 
         return self
 
+    def _rotate(self, rotation: R, anchor=None, start="auto", parent_path=None):
+        """Rotate object about a given anchor.
+
+        See `rotate` docstring for other parameters.
+
+        Parameters
+        ----------
+        parent_path: if there is no parent else parent._position
+            needs to be transmitted from the top level for nested collections, hence using a
+            private `_rotate` method to do so.
+
+        """
+        # Idea: An operation applied to a Collection is individually
+        #    applied to its BaseGeo and to each child.
+        #  -> this automatically generates the rotate-Compound behavior
+
+        # pylint: disable=no-member
+        for child in getattr(self, "children", []):
+            ppth = self._position if parent_path is None else parent_path
+            child._rotate(rotation, anchor=anchor, start=start, parent_path=ppth)
+
+        apply_rotation(
+            self, rotation, anchor=anchor, start=start, parent_path=parent_path
+        )
+        return self
 
-    def rotate(self, rotation: R, anchor=None, start='auto'):
+    def rotate(self, rotation: R, anchor=None, start="auto"):
         """Rotate object about a given anchor.
 
         Terminology for move/rotate methods:
@@ -399,23 +427,9 @@ def rotate(self, rotation: R, anchor=None, start='auto'):
          [  0.   0. 135.]]
         """
 
-        # pylint: disable=no-member
-
-        # Idea: An operation applied to a Collection is individually
-        #    applied to its BaseGeo and to each child.
-        #  -> this automatically generates the rotate-Compound behavior
-
-        for child in getattr(self, 'children', []):
-            apply_rotation(
-                child, rotation, anchor=anchor, start=start, parent_path=self._position
-            )
-
-        apply_rotation(self, rotation, anchor=anchor, start=start)
+        return self._rotate(rotation=rotation, anchor=anchor, start=start)
 
-        return self
-
-
-    def rotate_from_angax(self, angle, axis, anchor=None, start='auto', degrees=True):
+    def rotate_from_angax(self, angle, axis, anchor=None, start="auto", degrees=True):
         """Rotates object using angle-axis input.
 
         Terminology for move/rotate methods:
@@ -514,8 +528,7 @@ def rotate_from_angax(self, angle, axis, anchor=None, start='auto', degrees=True
         rot = R.from_rotvec(axis)
         return self.rotate(rot, anchor, start)
 
-
-    def rotate_from_rotvec(self, rotvec, anchor=None, start='auto', degrees=True):
+    def rotate_from_rotvec(self, rotvec, anchor=None, start="auto", degrees=True):
         """Rotates object using rotation vector input.
 
         Terminology for move/rotate methods:
@@ -593,8 +606,7 @@ def rotate_from_rotvec(self, rotvec, anchor=None, start='auto', degrees=True):
         rot = R.from_rotvec(rotvec, degrees=degrees)
         return self.rotate(rot, anchor=anchor, start=start)
 
-
-    def rotate_from_euler(self, angle, seq, anchor=None, start='auto', degrees=True):
+    def rotate_from_euler(self, angle, seq, anchor=None, start="auto", degrees=True):
         """Rotates object using Euler angle input.
 
         Terminology for move/rotate methods:
@@ -677,8 +689,7 @@ def rotate_from_euler(self, angle, seq, anchor=None, start='auto', degrees=True)
         rot = R.from_euler(seq, angle, degrees=degrees)
         return self.rotate(rot, anchor=anchor, start=start)
 
-
-    def rotate_from_matrix(self, matrix, anchor=None, start='auto'):
+    def rotate_from_matrix(self, matrix, anchor=None, start="auto"):
         """Rotates object using matrix input.
 
         Terminology for move/rotate methods:
@@ -739,7 +750,7 @@ def rotate_from_matrix(self, matrix, anchor=None, start='auto'):
         rot = R.from_matrix(matrix)
         return self.rotate(rot, anchor=anchor, start=start)
 
-    def rotate_from_mrp(self, mrp, anchor=None, start='auto'):
+    def rotate_from_mrp(self, mrp, anchor=None, start="auto"):
         """Rotates object using Modified Rodrigues Parameters (MRPs) input.
 
         Terminology for move/rotate methods:
@@ -800,8 +811,7 @@ def rotate_from_mrp(self, mrp, anchor=None, start='auto'):
         rot = R.from_mrp(mrp)
         return self.rotate(rot, anchor=anchor, start=start)
 
-
-    def rotate_from_quat(self, quat, anchor=None, start='auto'):
+    def rotate_from_quat(self, quat, anchor=None, start="auto"):
         """Rotates object using quaternion input.
 
         Terminology for move/rotate methods:
diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py
index 344ce24bc..03eb2b953 100644
--- a/magpylib/_src/obj_classes/class_Collection.py
+++ b/magpylib/_src/obj_classes/class_Collection.py
@@ -1,76 +1,207 @@
-"""Collection class code
-DOCSTRING v4 READY
-"""
+"""Collection class code"""
 
+# pylint: disable=redefined-builtin
+
+from collections import Counter
 from magpylib._src.utility import (
     format_obj_input,
-    check_duplicates,
     LIBRARY_SENSORS,
     LIBRARY_SOURCES,
+    rec_obj_remover,
 )
-
 from magpylib._src.obj_classes.class_BaseGeo import BaseGeo
 from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr
 from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2
 from magpylib._src.defaults.defaults_utility import validate_style_keys
 from magpylib._src.exceptions import MagpylibBadUserInput
+from magpylib._src.input_checks import check_format_input_obj
 
-class BaseCollection(BaseDisplayRepr):
-    """ Collection base class without BaseGeo properties
+
+def repr_obj(obj, format="type+id+label"):
     """
+    Returns a string that describes the object depending on the chosen tag format.
+    """
+    # pylint: disable=protected-access
+    show_type = "type" in format
+    show_label = "label" in format
+    show_id = "id" in format
+
+    tag = ""
+    if show_type:
+        tag += f"{obj._object_type}"
+
+    if show_label:
+        if show_type:
+            tag += " "
+        label = getattr(getattr(obj, "style", None), "label", None)
+        if label is None:
+            label = "nolabel" if show_type else f"{obj._object_type}"
+        tag += label
+
+    if show_id:
+        if show_type or show_label:
+            tag += " "
+        tag += f"(id={id(obj)})"
+    return tag
+
+
+def collection_tree_generator(
+    obj,
+    format="type+id+label",
+    max_elems=20,
+    prefix = "",
+    space = "    ",
+    branch = "│   ",
+    tee = "├── ",
+    last = "└── ",
+):
+    """
+    Recursively creates a generator that will yield a visual tree structure of
+    a collection object and all its children.
+    """
+    # pylint: disable=protected-access
+
+    # store children and properties of this branch
+    contents = []
+
+    children = getattr(obj, "children", [])
+    if len(children) > max_elems: # replace with counter if too many
+        counts = Counter([c._object_type for c in children])
+        children = [f"{v}x {k}s" for k, v in counts.items()]
+
+    props = []
+    view_props = "properties" in format
+    if view_props:
+        desc = getattr(obj, "_get_description", False)
+        if desc:
+            desc_out = desc(exclude=(
+                "children",
+                "parent",
+                "style",
+                "sources",
+                "sensors",
+                "collections")
+            )
+            props = [d.strip() for d in desc_out[1:]]
+
+    contents.extend(props)
+    contents.extend(children)
+
+    # generate and store "pointer" structure for this branch
+    pointers = [tee] * (len(contents) - 1) + [last]
+    pointers[: len(props)] = [branch if children else space] * len(props)
+
+    # create branch entries
+    for pointer, child in zip(pointers, contents):
+        child_repr = child if isinstance(child, str) else repr_obj(child, format)
+        yield prefix + pointer + child_repr
+
+        # recursion
+        has_child = getattr(child, "children", False)
+        if has_child or (view_props and desc):
+            # space because last, └── , above so no more |
+            extension = branch if pointer == tee else space
+
+            yield from collection_tree_generator(
+                child,
+                format=format,
+                max_elems=max_elems,
+                prefix=prefix + extension,
+                space=space,
+                branch=branch,
+                tee=tee,
+                last=last,
+            )
+
 
-    def __init__(self, *children):
+class BaseCollection(BaseDisplayRepr):
+    """Collection base class without BaseGeo properties"""
+
+    def __init__(self, *children, override_parent=False):
 
-        self._object_type = 'Collection'
+        self._object_type = "Collection"
 
         BaseDisplayRepr.__init__(self)
 
         self._children = []
         self._sources = []
         self._sensors = []
-        self.children = children
+        self._collections = []
+        self.add(*children, override_parent=override_parent)
 
     # property getters and setters
     @property
     def children(self):
-        """Collection children attribute getter and setter."""
+        """An ordered list of all children in the collection."""
         return self._children
 
     @children.setter
     def children(self, children):
         """Set Collection children."""
-        obj_list = format_obj_input(children, allow="sources+sensors")
+        # pylint: disable=protected-access
+        for child in self._children:
+            child._parent = None
         self._children = []
-        self.add(obj_list)
+        self.add(*children, override_parent=True)
 
     @property
     def sources(self):
-        """Collection sources attribute getter and setter."""
+        """An ordered list of all source objects in the collection."""
         return self._sources
 
     @sources.setter
     def sources(self, sources):
         """Set Collection sources."""
+        # pylint: disable=protected-access
+        new_children = []
+        for child in self._children:
+            if child in self._sources:
+                child._parent = None
+            else:
+                new_children.append(child)
+        self._children = new_children
         src_list = format_obj_input(sources, allow="sources")
-        self._children = [o for o in self._children if o not in self._sources]
-        self.add(src_list)
+        self.add(*src_list, override_parent=True)
 
     @property
     def sensors(self):
-        """Collection sensors attribute getter and setter."""
+        """An ordered list of all sensor objects in the collection."""
         return self._sensors
 
     @sensors.setter
     def sensors(self, sensors):
         """Set Collection sensors."""
+        # pylint: disable=protected-access
+        new_children = []
+        for child in self._children:
+            if child in self._sensors:
+                child._parent = None
+            else:
+                new_children.append(child)
+        self._children = new_children
         sens_list = format_obj_input(sensors, allow="sensors")
-        self._children = [o for o in self._children if o not in self._sensors]
-        self.add(sens_list)
+        self.add(*sens_list, override_parent=True)
 
-    # dunders
-    def __sub__(self, obj):
-        return self.remove(obj)
+    @property
+    def collections(self):
+        """An ordered list of all collection objects in the collection."""
+        return self._collections
+
+    @collections.setter
+    def collections(self, collections):
+        """Set Collection collections."""
+        # pylint: disable=protected-access
+        new_children = []
+        for child in self._children:
+            if child in self._collections:
+                child._parent = None
+            else:
+                new_children.append(child)
+        self._children = new_children
+        coll_list = format_obj_input(collections, allow="collections")
+        self.add(*coll_list, override_parent=True)
 
+    # dunders
     def __iter__(self):
         yield from self._children
 
@@ -80,28 +211,59 @@ def __getitem__(self, i):
     def __len__(self):
         return len(self._children)
 
-    def __repr__(self) -> str:
-        # pylint: disable=protected-access
-        s = super().__repr__()
-        if self._children:
-            if not self._sources:
-                pref = 'Sensor'
-            elif not self._sensors:
-                pref = 'Source'
-            else:
-                pref = 'Mixed'
-            return f"{pref}{s}"
-        return s
+    def _repr_html_(self):
+        lines = []
+        lines.append(repr_obj(self))
+        for line in collection_tree_generator(
+            self,
+            format="type+label+id",
+            max_elems=10,
+        ):
+            lines.append(line)
+        return f"""<pre>{'<br>'.join(lines)}</pre>"""
+
+    def describe(self, format='type+label+id', max_elems=10, return_string=False):
+        # pylint: disable=arguments-differ
+        """Returns or prints a tree view of the collection.
+
+        Parameters
+        ----------
+        format: bool, default='type+label+id'
+            Object description in tree view. Can be any combination of `'type'`, `'label'`
+            and `'id'` and `'properties'`.
+        max_elems: default=10
+            If number of children at any level is higher than `max_elems`, elements are
+            replaced by counters.
+        return_string: bool, default=`False`
+            If `False` print description with stdout, if `True` return as string.
+        """
+        tree = collection_tree_generator(
+            self,
+            format=format,
+            max_elems=max_elems,
+        )
+        output = [repr_obj(self, format)]
+        for t in tree:
+            output.append(t)
+        output = "\n".join(output)
+
+        if return_string:
+            return output
+        print(output)
+        return None
 
     # methods -------------------------------------------------------
-    def add(self, *children):
+    def add(self, *children, override_parent=False):
         """Add sources, sensors or collections.
 
         Parameters
         ----------
-        children: source, `Sensor` or `Collection` objects or arbitrary lists thereof
+        children: sources, sensors or collections
             Add arbitrary sources, sensors or other collections to this collection.
-            Duplicate children will automatically be eliminated.
+
+        override_parent: bool, default=`True`
+            Accept objects as children that already have parents. Automatically
+            removes such objects from previous parent collection.
 
         Returns
         -------
@@ -112,40 +274,79 @@ def add(self, *children):
         In this example we add a sensor object to a collection:
 
         >>> import magpylib as magpy
-        >>> col = magpy.Collection()
-        >>> sens = magpy.Sensor()
-        >>> col.add(sens)
-        >>> print(col.children)
-        [Sensor(id=2236606343584)]
+        >>> x1 = magpy.Sensor(style_label='x1')
+        >>> coll = magpy.Collection(x1, style_label='coll')
+        >>> coll.describe(labels=True)
+        coll
+        └── x1
+
+        >>> x2 = magpy.Sensor(style_label='x2')
+        >>> coll.add(x2)
+        >>> coll.describe(labels=True)
+        coll
+        ├── x1
+        └── x2
         """
-        # format input
-        obj_list = format_obj_input(children)
-        # combine with original obj_list
-        obj_list = self._children + obj_list
-        # check and eliminate duplicates
-        obj_list = check_duplicates(obj_list)
+        # pylint: disable=protected-access
+
+        # allow flat lists as input
+        if len(children)==1 and isinstance(children[0], (list, tuple)):
+            children = children[0]
+
+        # check and format input
+        obj_list = check_format_input_obj(
+            children,
+            allow="sensors+sources+collections",
+            recursive=False,
+            typechecks=True,
+        )
+
+        # assign parent
+        for obj in obj_list:
+            if obj._parent is None:
+                obj._parent = self
+            elif override_parent:
+                obj._parent.remove(obj)
+                obj._parent = self
+            else:
+                raise MagpylibBadUserInput(
+                    f"Cannot add {obj!r} to {self!r} because it already has a parent.\n"
+                    "Consider using `override_parent=True`."
+                )
+
         # set attributes
-        self._children = obj_list
+        self._children += obj_list
         self._update_src_and_sens()
+
         return self
 
     def _update_src_and_sens(self):
         # pylint: disable=protected-access
-        """updates source and sensor list when a child is added or removed"""
+        """updates sources, sensors and collections attributes from children"""
         self._sources = [
             obj for obj in self._children if obj._object_type in LIBRARY_SOURCES
         ]
         self._sensors = [
             obj for obj in self._children if obj._object_type in LIBRARY_SENSORS
         ]
+        self._collections = [
+            obj for obj in self._children if obj._object_type == "Collection"
+        ]
 
-    def remove(self, child):
-        """Remove a specific child from the collection.
+    def remove(self, *children, recursive=True, errors="raise"):
+        """Remove children from the collection tree.
 
         Parameters
         ----------
-        child: child object
-            Remove the given child from the collection.
+        children: child objects
+            Remove the given children from the collection.
+
+        recursive: bool, default=`True`
+            Remove children also when they are in child collections.
+
+        errors: str, default=`'raise'`
+            Can be `'raise'` or `'ignore'` to toggle error output when child is
+            not found for removal.
 
         Returns
         -------
@@ -156,23 +357,64 @@ def remove(self, child):
         In this example we remove a child from a Collection:
 
         >>> import magpylib as magpy
-        >>> sens = magpy.Sensor()
-        >>> col = magpy.Collection(sens)
-        >>> print(col.children)
-        [Sensor(id=2048351734560)]
-
-        >>> col.remove(sens)
-        >>> print(col.children)
-        []
+        >>> x1 = magpy.Sensor(style_label='x1')
+        >>> x2 = magpy.Sensor(style_label='x2')
+        >>> col = magpy.Collection(x1, x2, style_label='col')
+        >>> col.describe(labels=True)
+        col
+        ├── x1
+        └── x2
+
+        >>> col.remove(x1)
+        >>> col.describe(labels=True)
+        col
+        └── x2
         """
-        self._children.remove(child)
-        self._update_src_and_sens()
-        return self
+        # pylint: disable=protected-access
 
+        # allow flat lists as input
+        if len(children)==1 and isinstance(children[0], (list, tuple)):
+            children = children[0]
+
+        # check and format input
+        remove_objects = check_format_input_obj(
+            children,
+            allow="sensors+sources+collections",
+            recursive=False,
+            typechecks=True,
+        )
+        self_objects = check_format_input_obj(
+            self,
+            allow="sensors+sources+collections",
+            recursive=recursive,
+        )
+        for child in remove_objects:
+            if child in self_objects:
+                rec_obj_remover(self, child)
+                child._parent = None
+            else:
+                if errors == "raise":
+                    raise MagpylibBadUserInput(
+                        f"Cannot find and remove {child} from {self}."
+                    )
+                if errors != "ignore":
+                    raise MagpylibBadUserInput(
+                        "Input `errors` must be one of ('raise', 'ignore').\n"
+                        f"Instead received {errors}."
+                    )
+        return self
 
-    def set_children_styles(self, arg=None, **kwargs):
+    def set_children_styles(self, arg=None, recursive=True, _validate=True, **kwargs):
         """Set display style of all children in the collection. Only matching properties
-        will be applied. Input can be a style dict or style underscore magic.
+        will be applied.
+
+        Parameters
+        ----------
+        arg: style dictionary or style underscore magic input
+            Style arguments to be applied.
+
+        recursive: bool, default=`True`
+            Apply styles also to children of child collections.
 
         Returns
         -------
@@ -200,43 +442,49 @@ def set_children_styles(self, arg=None, **kwargs):
         >>> magpy.show(col, src)
         ---> graphic output
         """
+        # pylint: disable=protected-access
 
         if arg is None:
             arg = {}
         if kwargs:
             arg.update(kwargs)
-        style_kwargs = validate_style_keys(arg)
-        for src in self._children:
+        style_kwargs = arg
+        if _validate:
+            style_kwargs = validate_style_keys(arg)
+
+        for child in self._children:
             # match properties false will try to apply properties from kwargs only if it finds it
             # without throwing an error
+            if child._object_type == "Collection" and recursive:
+                self.__class__.set_children_styles(child, style_kwargs, _validate=False)
             style_kwargs_specific = {
                 k: v
                 for k, v in style_kwargs.items()
-                if k.split("_")[0] in src.style.as_dict()
+                if k.split("_")[0] in child.style.as_dict()
             }
-            src.style.update(**style_kwargs_specific, _match_properties=True)
+            child.style.update(**style_kwargs_specific, _match_properties=True)
         return self
 
-    def _validate_getBH_inputs(self, *children):
-        # pylint: disable=too-many-branches
+    def _validate_getBH_inputs(self, *inputs):
         """validate Collection.getBH inputs"""
         # pylint: disable=protected-access
-        sources, sensors = list(self._sources), list(self._sensors)
-        if self._sensors and self._sources:
+        # pylint: disable=too-many-branches
+        current_sources = format_obj_input(self, allow="sources")
+        current_sensors = format_obj_input(self, allow="sensors")
+        if current_sensors and current_sources:
             sources, sensors = self, self
-            if children:
+            if inputs:
                 raise MagpylibBadUserInput(
                     "Collections with sensors and sources do not allow `collection.getB()` inputs."
                     "Consider using `magpy.getB()` instead."
                 )
-        elif not sources:
-            sources, sensors = children, self
-        elif not sensors:
-            sources, sensors = self, children
+        elif not current_sources:
+            sources, sensors = inputs, self
+        elif not current_sensors:
+            sources, sensors = self, inputs
         return sources, sensors
 
-
-    def getB(self, *sources_observers, squeeze=True):
+    def getB(self, *inputs, squeeze=True):
         """Compute B-field in [mT] for given sources and observer inputs.
 
         Parameters
@@ -282,12 +530,10 @@ def getB(self, *sources_observers, squeeze=True):
          [  0.           0.         166.66666667]]
         """
 
-        sources, sensors = self._validate_getBH_inputs(*sources_observers)
-
-        return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field='B')
+        sources, sensors = self._validate_getBH_inputs(*inputs)
+        return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field="B")
 
-
-    def getH(self, *children, squeeze=True):
+    def getH(self, *inputs, squeeze=True):
         """Compute H-field in [kA/m] for given sources and observer inputs.
 
         Parameters
@@ -333,30 +579,37 @@ def getH(self, *children, squeeze=True):
          [  0.           0.         66.31455962]]
         """
 
-        sources, sensors = self._validate_getBH_inputs(*children)
+        sources, sensors = self._validate_getBH_inputs(*inputs)
 
-        return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field='H')
+        return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field="H")
 
 
 class Collection(BaseGeo, BaseCollection):
-    """ Group multiple children (sources and sensors) in one Collection for
+    """Group multiple children (sources, sensors and collections) in a collection for
     common manipulation.
 
+    Collections span a local reference frame. All objects in a collection are held to
+    that reference frame when an operation (e.g. move, rotate, setter, ...) is applied
+    to the collection.
+
     Collections can be used as `sources` and `observers` input for magnetic field
     computation. For magnetic field computation a collection that contains sources
-    functions like a single (compound) source. When the collection contains sensors
+    functions like a single source. When the collection contains sensors
     it functions like a list of all its sensors.
 
-    Collections function like compound-objects. They have their own `position` and
-    `orientation` attributes. Move, rotate and setter operations acting on a
-    `Collection` object are individually applied to all child objects so that the
-    geometric compound structure is maintained. For example, `rotate()` with
-    `anchor=None` rotates all children about `collection.position`.
-
     Parameters
     ----------
-    children: sources, sensors, collections or arbitrary lists thereof
-        Ordered list of all children.
+    children: sources, `Sensor` or `Collection objects
+        An ordered list of all children in the collection.
+
+    sensors: `Sensor` objects
+        An ordered list of all sensor objects in the collection.
+
+    sources: source objects
+        An ordered list of all source objects`(magnets, currents, misc) in the collection.
+
+    collections: `Collection` objects
+        An ordered list of all collection objects in the collection.
 
     position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
         Object position(s) in the global coordinates in units of [mm]. For m>1, the
@@ -367,6 +620,9 @@ class Collection(BaseGeo, BaseCollection):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -424,6 +680,20 @@ class Collection(BaseGeo, BaseCollection):
     [ 0.00126232 -0.00093169 -0.00034448]
     """
 
-    def __init__(self, *args, position=(0,0,0), orientation=None, style=None, **kwargs):
-        BaseGeo.__init__(self, position=position, orientation=orientation, style=style, **kwargs)
-        BaseCollection.__init__(self, *args)
+    def __init__(
+        self,
+        *args,
+        position=(0, 0, 0),
+        orientation=None,
+        style=None,
+        override_parent=False,
+        **kwargs,
+    ):
+        BaseGeo.__init__(
+            self,
+            position=position,
+            orientation=orientation,
+            style=style,
+            **kwargs,
+        )
+        BaseCollection.__init__(self, *args, override_parent=override_parent)
diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py
index 27d3e3c5f..f32e24b67 100644
--- a/magpylib/_src/obj_classes/class_Sensor.py
+++ b/magpylib/_src/obj_classes/class_Sensor.py
@@ -34,6 +34,9 @@ class Sensor(BaseGeo, BaseDisplayRepr):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -91,7 +94,9 @@ def __init__(
     # property getters and setters
     @property
     def pixel(self):
-        """ Sensor pixel attribute getter and setter."""
+        """ Sensor pixel (=sensing elements) positions in the local object coordinates
+        (rotate with object), in units of [mm].
+        """
         return self._pixel
 
     @pixel.setter
diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py
index 97b3010ba..2a898d509 100644
--- a/magpylib/_src/obj_classes/class_current_Line.py
+++ b/magpylib/_src/obj_classes/class_current_Line.py
@@ -35,6 +35,9 @@ class Line(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -102,7 +105,11 @@ def __init__(
     # property getters and setters
     @property
     def vertices(self):
-        """Object vertices attribute getter and setter."""
+        """
+        The current flows along the vertices which are given in units of [mm] in the
+        local object coordinates (move/rotate with object). At least two vertices
+        must be given.
+        """
         return self._vertices
 
     @vertices.setter
diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py
index 15344be0d..a1c8416a0 100644
--- a/magpylib/_src/obj_classes/class_current_Loop.py
+++ b/magpylib/_src/obj_classes/class_current_Loop.py
@@ -34,6 +34,9 @@ class Loop(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -97,7 +100,7 @@ def __init__(
     # property getters and setters
     @property
     def diameter(self):
-        """Object diameter attribute getter and setter."""
+        """Diameter of the loop in units of [mm]."""
         return self._diameter
 
     @diameter.setter
diff --git a/magpylib/_src/obj_classes/class_mag_Cuboid.py b/magpylib/_src/obj_classes/class_mag_Cuboid.py
index bf1a877cf..4d9c238a4 100644
--- a/magpylib/_src/obj_classes/class_mag_Cuboid.py
+++ b/magpylib/_src/obj_classes/class_mag_Cuboid.py
@@ -35,6 +35,9 @@ class Cuboid(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -98,7 +101,7 @@ def __init__(
     # property getters and setters
     @property
     def dimension(self):
-        """Object dimension attribute getter and setter."""
+        """Length of the cuboid sides [a,b,c] in units of [mm]."""
         return self._dimension
 
     @dimension.setter
diff --git a/magpylib/_src/obj_classes/class_mag_Cylinder.py b/magpylib/_src/obj_classes/class_mag_Cylinder.py
index 6e41b4bf7..894dd9c31 100644
--- a/magpylib/_src/obj_classes/class_mag_Cylinder.py
+++ b/magpylib/_src/obj_classes/class_mag_Cylinder.py
@@ -35,6 +35,9 @@ class Cylinder(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -98,7 +101,7 @@ def __init__(
     # property getters and setters
     @property
     def dimension(self):
-        """Object dimension attribute getter and setter."""
+        """Dimension (d,h) denote diameter and height of the cylinder in units of [mm]."""
         return self._dimension
 
     @dimension.setter
diff --git a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py
index 63380b845..da0be6e4f 100644
--- a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py
+++ b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py
@@ -39,6 +39,9 @@ class CylinderSegment(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -103,7 +106,12 @@ def __init__(
     # property getters and setters
     @property
     def dimension(self):
-        """Object dimension attribute getter and setter."""
+        """
+        Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2)
+        where r1<r2 denote inner and outer radii in units of [mm], phi1<phi2 denote
+        the cylinder section angles in units of [deg] and h is the cylinder height
+        in units of [mm].
+        """
         return self._dimension
 
     @dimension.setter
@@ -113,12 +121,12 @@ def dimension(self, dim):
 
     @property
     def _barycenter(self):
-        """Object barycenter. Always has dim (n,3) depending on object path."""
+        """Object barycenter."""
         return self._get_barycenter(self._position, self._orientation, self.dimension)
 
     @property
     def barycenter(self):
-        """Object barycenter. Output shape is matches position and orientation."""
+        """Object barycenter."""
         return np.squeeze(self._barycenter)
 
     @staticmethod
diff --git a/magpylib/_src/obj_classes/class_mag_Sphere.py b/magpylib/_src/obj_classes/class_mag_Sphere.py
index 8d39168c4..ca9e2050d 100644
--- a/magpylib/_src/obj_classes/class_mag_Sphere.py
+++ b/magpylib/_src/obj_classes/class_mag_Sphere.py
@@ -34,6 +34,9 @@ class Sphere(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -98,7 +101,7 @@ def __init__(
     # property getters and setters
     @property
     def diameter(self):
-        """Object diameter attribute getter and setter."""
+        """Diameter of the sphere in units of [mm]."""
         return self._diameter
 
     @diameter.setter
diff --git a/magpylib/_src/obj_classes/class_misc_Custom.py b/magpylib/_src/obj_classes/class_misc_Custom.py
index 65b228519..e723b4742 100644
--- a/magpylib/_src/obj_classes/class_misc_Custom.py
+++ b/magpylib/_src/obj_classes/class_misc_Custom.py
@@ -20,12 +20,12 @@ class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH):
     Parameters
     ----------
     field_B_lambda: callable, default=`None`
-        Field function for the B-field. must accept position input with format (n,3) and
-        return the B-field with similar shape.
+        Field function for the B-field. Must accept position input with format (n,3) and
+        return the B-field with similar shape in units of [mT].
 
     field_H_lambda: callable, default=`None`
-        Field function for the H-field. must accept position input with format (n,3) and
-        return the H-field with similar shape.
+        Field function for the H-field. Must accept position input with format (n,3) and
+        return the H-field with similar shape in units of [kA/m].
 
     position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
         Object position(s) in the global coordinates in units of [mm]. For m>1, the
@@ -36,6 +36,9 @@ class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -102,9 +105,9 @@ def __init__(
 
     @property
     def field_B_lambda(self):
-        """Field function for B-field, should accept array_like positions of shape (n,3)
-        in units of [mm] and return a B-field array of same shape in the global
-        coordinate system in units of [mT].
+        """
+        Field function for the B-field. Must accept position input with format (n,3) and
+        return the B-field with similar shape in units of [mT].
         """
         return self._field_B_lambda
 
@@ -114,9 +117,9 @@ def field_B_lambda(self, val):
 
     @property
     def field_H_lambda(self):
-        """Field function for H-field, should accept array_like positions of shape (n,3)
-        in units of [mm] and return a H-field array of same shape in the global
-        coordinate system in units of [kA/m].
+        """
+        Field function for the H-field. Must accept position input with format (n,3) and
+        return the H-field with similar shape in units of [kA/m].
         """
         return self._field_H_lambda
 
diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py
index a7926469a..a1e4cd5e4 100644
--- a/magpylib/_src/obj_classes/class_misc_Dipole.py
+++ b/magpylib/_src/obj_classes/class_misc_Dipole.py
@@ -18,9 +18,8 @@ class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH):
     Parameters
     ----------
     moment: array_like, shape (3,), unit [mT*mm^3], default=`None`
-        Magnetic dipole moment in units of [mT*mm^3] given in the local object coordinates
-        (rotates with object). For homogeneous magnets the relation moment=magnetization*volume
-        holds.
+        Magnetic dipole moment in units of [mT*mm^3] given in the local object coordinates.
+        For homogeneous magnets the relation moment=magnetization*volume holds.
 
     position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
         Object position(s) in the global coordinates in units of [mm]. For m>1, the
@@ -31,6 +30,9 @@ class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH):
         a unit-rotation. For m>1, the `position` and `orientation` attributes
         together represent an object path.
 
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
     style: dict
         Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
         using style underscore magic, e.g. `style_color='red'`.
@@ -91,7 +93,7 @@ def __init__(
     # property getters and setters
     @property
     def moment(self):
-        """Object moment attributes getter and setter."""
+        """Magnetic dipole moment in units of [mT*mm^3] given in the local object coordinates."""
         return self._moment
 
     @moment.setter
diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py
index f166ac2a1..55b2c2675 100644
--- a/magpylib/_src/utility.py
+++ b/magpylib/_src/utility.py
@@ -1,5 +1,5 @@
 """ some utility functions"""
-#import numbers
+# import numbers
 from math import log10
 from typing import Sequence
 import numpy as np
@@ -81,7 +81,7 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) ->
     - sources (sequence): input sources
 
     ### Returns:
-    - list: flattened, ordered list f sources
+    - list: flattened, ordered list of sources
 
     ### Info:
     - exits if invalid sources are given
@@ -91,21 +91,20 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) ->
     obj_list = []
     flatten_collection = not "collections" in allow.split("+")
     for obj in objects:
-        if isinstance(obj, (tuple, list)):
-            obj_list += format_obj_input(
-                *obj, allow=allow, warn=warn
-            )  # recursive flattening
-        else:
-            try:
-                if obj._object_type == "Collection":
-                    if flatten_collection:
-                        obj_list += obj.children
-                    else:
-                        obj_list += [obj]
-                elif obj._object_type in list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS):
+        try:
+            if getattr(obj, "_object_type", None) in list(LIBRARY_SOURCES) + list(
+                LIBRARY_SENSORS
+            ):
+                obj_list += [obj]
+            else:
+                if flatten_collection or isinstance(obj, (list, tuple)):
+                    obj_list += format_obj_input(
+                        *obj, allow=allow, warn=warn,
+                    )  # recursive flattening
+                else:
                     obj_list += [obj]
-            except Exception as error:
-                raise MagpylibBadUserInput(wrong_obj_msg(obj, allow=allow)) from error
+        except Exception as error:
+            raise MagpylibBadUserInput(wrong_obj_msg(obj, allow=allow)) from error
     obj_list = filter_objects(obj_list, allow=allow, warn=False)
     return obj_list
 
@@ -127,19 +126,23 @@ def format_src_inputs(sources) -> list:
     """
     # pylint: disable=protected-access
 
+    # store all sources here
+    src_list = []
+
     # if bare source make into list
     if not isinstance(sources, (list, tuple)):
         sources = [sources]
-    # flatten collections
-    src_list = []
+
     if not sources:
         raise MagpylibBadUserInput(wrong_obj_msg(allow="sources"))
+
     for src in sources:
-        obj_type = getattr(src, "_object_type", None)
+        obj_type = getattr(src, "_object_type", "")
         if obj_type == "Collection":
-            if not src.sources:
+            child_sources = format_obj_input(src, allow="sources")
+            if not child_sources:
                 raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources"))
-            src_list += src.sources
+            src_list += child_sources
         elif obj_type in LIBRARY_SOURCES:
             src_list += [src]
         else:
@@ -295,7 +298,7 @@ def add_iteration_suffix(name):
     m = re.search(r"\d+$", name)
     n = "00"
     endstr = None
-    midchar = "_" if name[-1]!= '_' else ""
+    midchar = "_" if name[-1] != "_" else ""
     if m is not None:
         midchar = ""
         n = m.group()
@@ -326,3 +329,17 @@ def cyl_field_to_cart(phi, Br, Bphi=None):
         By = Br * np.sin(phi)
 
     return Bx, By
+
+
+def rec_obj_remover(parent, child):
+    """ remove known child from parent collection"""
+    # pylint: disable=protected-access
+    for obj in parent:
+        if obj == child:
+            parent._children.remove(child)
+            parent._update_src_and_sens()
+            return True
+        if getattr(obj, "_object_type", "") == "Collection":
+            if rec_obj_remover(obj, child):
+                break
+    return None


Test patch:
diff --git a/tests/test_Coumpound_setters.py b/tests/test_Coumpound_setters.py
index 5594bf68e..2f44216b9 100644
--- a/tests/test_Coumpound_setters.py
+++ b/tests/test_Coumpound_setters.py
@@ -20,13 +20,12 @@ def make_wheel(Ncubes=6, height=10, diameter=36, path_len=5, label=None):
     s0 = cs_lambda().rotate_from_angax(
         np.linspace(0.0, 360.0, Ncubes, endpoint=False), "z", anchor=(0, 0, 0), start=0
     )
-    cs_list = []
+    c = magpy.Collection()
     for ind in range(Ncubes):
         s = cs_lambda()
         s.position = s0.position[ind]
         s.orientation = s0.orientation[ind]
-        cs_list.append(s)
-    c = magpy.Collection(cs_list)
+        c.add(s)
     c.rotate_from_angax(90, "x")
     c.rotate_from_angax(
         np.linspace(90, 360, path_len), axis="z", start=0, anchor=(80, 0, 0)
diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py
index 20edacefc..c1d2b931c 100644
--- a/tests/test_display_matplotlib.py
+++ b/tests/test_display_matplotlib.py
@@ -105,9 +105,10 @@ def test_col_display():
     # pylint: disable=assignment-from-no-return
     ax = plt.subplot(projection="3d")
     pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3))
-    pm2 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3))
-    col = magpy.Collection(pm1, pm2)
-    x = col.show(canvas=ax)
+    pm2 = pm1.copy(position=(2,0,0))
+    pm3 = pm1.copy(position=(4,0,0))
+    nested_col = (pm1 + pm2 + pm3).set_children_styles(color='magenta')
+    x = nested_col.show(canvas=ax)
     assert x is None, "colletion display test fail"
 
 
diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py
index 29afaeb02..7320a506d 100644
--- a/tests/test_display_plotly.py
+++ b/tests/test_display_plotly.py
@@ -107,9 +107,10 @@ def test_col_display():
     magpy.defaults.display.backend = "plotly"
     fig = go.Figure()
     pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3))
-    pm2 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3))
-    col = magpy.Collection(pm1, pm2)
-    x = col.show(canvas=fig)
+    pm2 = pm1.copy(position=(2,0,0))
+    pm3 = pm1.copy(position=(4,0,0))
+    nested_col = (pm1 + pm2 + pm3).set_children_styles(color='magenta')
+    x = nested_col.show(canvas=fig)
     assert x is None, "collection display test fail"
 
 
@@ -282,7 +283,7 @@ def test_display_warnings():
     with pytest.warns(UserWarning):  # max frames surpassed
         src.show(canvas=fig, animation=True, animation_time=2, animation_fps=1)
     src = Cuboid((1, 2, 3), (1, 2, 3))
-    with pytest.warns(UserWarning):  # no objet path detected
+    with pytest.warns(UserWarning):  # no object path detected
         src.show(canvas=fig, style_path_frames=[], animation=True)
 
 
diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py
index 841a3e61e..649af1f7c 100644
--- a/tests/test_getBH_level2.py
+++ b/tests/test_getBH_level2.py
@@ -13,10 +13,10 @@ def test_getB_level2_input_simple():
     pm2 = magpy.magnet.Cuboid(mag,dim_cuboid)
     pm3 = magpy.magnet.Cylinder(mag,dim_cyl)
     pm4 = magpy.magnet.Cylinder(mag,dim_cyl)
-    col1 = magpy.Collection([pm1])
-    col2 = magpy.Collection([pm1,pm2])
-    col3 = magpy.Collection([pm1,pm2,pm3])
-    col4 = sum([pm1,pm2,pm3,pm4])
+    col1 = magpy.Collection(pm1.copy())
+    col2 = magpy.Collection(pm1.copy(),pm2.copy())
+    col3 = magpy.Collection(pm1.copy(),pm2.copy(),pm3.copy())
+    col4 = magpy.Collection(pm1.copy(),pm2.copy(),pm3.copy(),pm4.copy())
     pos_obs = (1,2,3)
     sens1 = magpy.Sensor(position=pos_obs)
     sens2 = magpy.Sensor(pixel=pos_obs)
@@ -61,20 +61,19 @@ def test_getB_level2_input_shape22():
     mag = (1,2,3)
     dim_cuboid = (1,2,3)
     dim_cyl = (1,2)
-    pm1 = magpy.magnet.Cuboid(mag,dim_cuboid)
-    pm2 = magpy.magnet.Cuboid(mag,dim_cuboid)
-    pm3 = magpy.magnet.Cylinder(mag,dim_cyl)
-    pm4 = magpy.magnet.Cylinder(mag,dim_cyl)
-    col1 = magpy.Collection([pm1])
-    col2 = magpy.Collection([pm1,pm2])
-    col3 = magpy.Collection([pm1,pm2,pm3])
-    col4 = magpy.Collection([pm1,pm2,pm3,pm4])
-    pos_obs = [[(1,2,3),(1,2,3)],[(1,2,3),(1,2,3)]]
+    pm1 = lambda: magpy.magnet.Cuboid(mag, dim_cuboid)
+    pm2 = lambda: magpy.magnet.Cuboid(mag, dim_cuboid)
+    pm3 = lambda: magpy.magnet.Cylinder(mag, dim_cyl)
+    pm4 = lambda: magpy.magnet.Cylinder(mag, dim_cyl)
+    col1 = magpy.Collection(pm1())
+    col2 = magpy.Collection(pm1(), pm2())
+    col3 = magpy.Collection(pm1(), pm2(), pm3())
+    col4 = magpy.Collection(pm1(), pm2(), pm3(), pm4())
+    pos_obs = [[(1,2,3), (1,2,3)], [(1,2,3), (1,2,3)]]
     sens1 = magpy.Sensor(pixel=pos_obs)
 
-    fb22 = magpy.getB(pm1,pos_obs)
-    fc22 = magpy.getB(pm3,pos_obs)
-
+    fb22 = magpy.getB(pm1(), pos_obs)
+    fc22 = magpy.getB(pm3(), pos_obs)
 
     for poso,fb,fc in zip([pos_obs,sens1,[sens1,sens1,sens1]],
         [fb22,fb22,[fb22,fb22,fb22]],
@@ -83,16 +82,16 @@ def test_getB_level2_input_shape22():
         fb = np.array(fb)
         fc = np.array(fc)
         src_obs_res = [
-            [pm1, poso, fb],
-            [pm3, poso, fc],
-            [[pm1,pm2], poso, [fb,fb]],
-            [[pm1,pm2,pm3], poso, [fb,fb,fc]],
+            [pm1(), poso, fb],
+            [pm3(), poso, fc],
+            [[pm1(),pm2()], poso, [fb,fb]],
+            [[pm1(),pm2(),pm3()], poso, [fb,fb,fc]],
             [col1, poso, fb],
             [col2, poso, 2*fb],
             [col3, poso, 2*fb+fc],
             [col4, poso, 2*fb+2*fc],
-            [[pm1,col1], poso, [fb, fb]],
-            [[pm1,col1,col2,pm2,col4], poso , [fb,fb,2*fb,fb,2*fb+2*fc]],
+            [[pm1(),col1], poso, [fb, fb]],
+            [[pm1(),col1,col2,pm2(),col4], poso , [fb,fb,2*fb,fb,2*fb+2*fc]],
             ]
 
         for sor in src_obs_res:
diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py
index f3f356c13..edab887dd 100644
--- a/tests/test_input_checks.py
+++ b/tests/test_input_checks.py
@@ -9,7 +9,7 @@
 
 
 def test_input_objects_position_good():
-    """good positions"""
+    """good input: magpy.Sensor(position=inp)"""
     goods = [
         (1,2,3),
         (0,0,0),
@@ -27,7 +27,7 @@ def test_input_objects_position_good():
 
 
 def test_input_objects_position_bad():
-    """bad positions"""
+    """bad input: magpy.Sensor(position=inp)"""
     bads = [
         (1,2),
         (1,2,3,4),
@@ -43,7 +43,7 @@ def test_input_objects_position_bad():
 
 
 def test_input_objects_pixel_good():
-    """good pixel"""
+    """good input: magpy.Sensor(pixel=inp)"""
     goods = [
         (1,-2,3),
         (0,0,0),
@@ -63,7 +63,7 @@ def test_input_objects_pixel_good():
 
 
 def test_input_objects_pixel_bad():
-    """bad pixel"""
+    """bad input: magpy.Sensor(pixel=inp)"""
     bads = [
         (1,2),
         (1,2,3,4),
@@ -78,7 +78,7 @@ def test_input_objects_pixel_bad():
 
 
 def test_input_objects_orientation_good():
-    """good orientations (from rotvec)"""
+    """good input: magpy.Sensor(orientation=inp)"""
     goods = [
         None,
         (.1,.2,.3),
@@ -96,7 +96,7 @@ def test_input_objects_orientation_good():
 
 
 def test_input_objects_orientation_bad():
-    """bad orienations"""
+    """bad input: magpy.Sensor(orientation=inp)"""
     bads = [
         (1,2),
         (1,2,3,4),
@@ -111,7 +111,7 @@ def test_input_objects_orientation_bad():
 
 
 def test_input_objects_current_good():
-    """good currents"""
+    """good input: magpy.current.Loop(inp)"""
     goods = [
         None,
         0,
@@ -131,7 +131,7 @@ def test_input_objects_current_good():
 
 
 def test_input_objects_current_bad():
-    """bad current"""
+    """bad input: magpy.current.Loop(inp)"""
     bads = [
         (1,2),
         [(1,2,3,4)]*2,
@@ -144,7 +144,7 @@ def test_input_objects_current_bad():
 
 
 def test_input_objects_diameter_good():
-    """good diameter"""
+    """good input: magpy.current.Loop(diameter=inp)"""
     goods = [
         None,
         0,
@@ -154,7 +154,7 @@ def test_input_objects_diameter_good():
         True,
         ]
     for good in goods:
-        src = magpy.current.Loop(1, good)
+        src = magpy.current.Loop(diameter=good)
         if good is None:
             assert src.diameter is None
         else:
@@ -162,7 +162,7 @@ def test_input_objects_diameter_good():
 
 
 def test_input_objects_diameter_bad():
-    """bad diameter"""
+    """bad input: magpy.current.Loop(diameter=inp)"""
     bads = [
         (1,2),
         [(1,2,3,4)]*2,
@@ -173,11 +173,12 @@ def test_input_objects_diameter_bad():
         -1.123,
         ]
     for bad in bads:
-        np.testing.assert_raises(MagpylibBadUserInput, magpy.current.Loop, 1, bad)
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            magpy.current.Loop(diameter=bad)
 
 
 def test_input_objects_vertices_good():
-    """good vertices"""
+    """good input: magpy.current.Line(vertices=inp)"""
     goods = [
         None,
         ((0,0,0),(0,0,0)),
@@ -187,7 +188,7 @@ def test_input_objects_vertices_good():
         np.array(((1,2,3),(2,3,4))),
         ]
     for good in goods:
-        src = magpy.current.Line(1, good)
+        src = magpy.current.Line(vertices=good)
         if good is None:
             assert src.vertices is None
         else:
@@ -195,7 +196,7 @@ def test_input_objects_vertices_good():
 
 
 def test_input_objects_vertices_bad():
-    """bad vertices"""
+    """bad input: magpy.current.Line(vertices=inp)"""
     bads = [
         (1,2),
         [(1,2,3,4)]*2,
@@ -208,11 +209,16 @@ def test_input_objects_vertices_bad():
         True,
         ]
     for bad in bads:
-        np.testing.assert_raises(MagpylibBadUserInput, magpy.current.Line, 1, bad)
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            magpy.current.Line(vertices=bad)
 
 
 def test_input_objects_magnetization_moment_good():
-    """good magnetization and moment"""
+    """
+    good input:
+        magpy.magnet.Cuboid(magnetization=inp),
+        magpy.misc.Dipole(moment=inp)
+    """
     goods = [
         None,
         (1,2,3),
@@ -232,7 +238,11 @@ def test_input_objects_magnetization_moment_good():
 
 
 def test_input_objects_magnetization_moment_bad():
-    """bad magnetization and moment"""
+    """
+    bad input:
+        magpy.magnet.Cuboid(magnetization=inp),
+        magpy.misc.Dipole(moment=inp)
+    """
     bads = [
         (1,2),
         [1,2,3,4],
@@ -246,12 +256,14 @@ def test_input_objects_magnetization_moment_bad():
         True,
         ]
     for bad in bads:
-        np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.Cuboid, bad)
-        np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.Dipole, bad)
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            magpy.magnet.Cuboid(magnetization=bad)
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            magpy.misc.Dipole(moment=bad)
 
 
 def test_input_objects_dimension_cuboid_good():
-    """good cuboid dimension"""
+    """good input: magpy.magnet.Cuboid(dimension=inp)"""
     goods = [
         None,
         (1,2,3),
@@ -259,7 +271,7 @@ def test_input_objects_dimension_cuboid_good():
         np.array((1,2,3)),
         ]
     for good in goods:
-        src = magpy.magnet.Cuboid((1,1,1), good)
+        src = magpy.magnet.Cuboid(dimension=good)
         if good is None:
             assert src.dimension is None
         else:
@@ -267,7 +279,7 @@ def test_input_objects_dimension_cuboid_good():
 
 
 def test_input_objects_dimension_cuboid_bad():
-    """bad cuboid dimension"""
+    """bad input: magpy.magnet.Cuboid(dimension=inp)"""
     bads = [
         [-1,2,3],
         (0,1,2),
@@ -282,11 +294,12 @@ def test_input_objects_dimension_cuboid_bad():
         True,
         ]
     for bad in bads:
-        np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.Cuboid, (1,1,1), bad)
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            magpy.magnet.Cuboid(dimension=bad)
 
 
 def test_input_objects_dimension_cylinder_good():
-    """good cylinder dimension"""
+    """good input: magpy.magnet.Cylinder(dimension=inp)"""
     goods = [
         None,
         (1,2),
@@ -294,7 +307,7 @@ def test_input_objects_dimension_cylinder_good():
         np.array((1,2)),
         ]
     for good in goods:
-        src = magpy.magnet.Cylinder((1,1,1), good)
+        src = magpy.magnet.Cylinder(dimension=good)
         if good is None:
             assert src.dimension is None
         else:
@@ -302,7 +315,7 @@ def test_input_objects_dimension_cylinder_good():
 
 
 def test_input_objects_dimension_cylinder_bad():
-    """bad cylinder dimension"""
+    """bad input: magpy.magnet.Cylinder(dimension=inp)"""
     bads = [
         [-1,2],
         (0,1),
@@ -317,11 +330,12 @@ def test_input_objects_dimension_cylinder_bad():
         True,
         ]
     for bad in bads:
-        np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.Cylinder, (1,1,1), bad)
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            magpy.magnet.Cylinder(dimension=bad)
 
 
 def test_input_objects_dimension_cylinderSegment_good():
-    """good cylinder segment dimension"""
+    """good input: magpy.magnet.CylinderSegment(dimension=inp)"""
     goods = [
         None,
         (0,2,3,0,50),
@@ -333,7 +347,7 @@ def test_input_objects_dimension_cylinderSegment_good():
         (0,2,3,-10,0),
         ]
     for good in goods:
-        src = magpy.magnet.CylinderSegment((1,1,1), good)
+        src = magpy.magnet.CylinderSegment(dimension=good)
         if good is None:
             assert src.dimension is None
         else:
@@ -341,7 +355,7 @@ def test_input_objects_dimension_cylinderSegment_good():
 
 
 def test_input_objects_dimension_cylinderSegment_bad():
-    """bad cylinder segment dimension"""
+    """good input: magpy.magnet.CylinderSegment(dimension=inp)"""
     bads = [
         (1,2,3,4),
         (1,2,3,4,5,6),
@@ -360,11 +374,12 @@ def test_input_objects_dimension_cylinderSegment_bad():
         True
         ]
     for bad in bads:
-        np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.CylinderSegment, (1,1,1), bad)
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            magpy.magnet.CylinderSegment(dimension=bad)
 
 
 def test_input_objects_fiedBHlambda_good():
-    """good custom fiedBHlambda"""
+    """good input: magpy.misc.CustomSource(field_B_lambda=f, field_H_lambda=f)"""
     def f(x):
         """3 in 3 out"""
         return x
@@ -375,7 +390,7 @@ def f(x):
 
 
 def test_input_objects_fiedBHlambda_bad():
-    """bad custom fiedBlambda"""
+    """bad input: magpy.misc.CustomSource(field_B_lambda=f, field_H_lambda=f)"""
     def f(x):
         """bad fieldBH lambda"""
         return 1
@@ -585,15 +600,16 @@ def test_input_observer_good():
     pos_vec3 = [[(1,2,3)]*2]*3
     sens1 = magpy.Sensor()
     sens2 = magpy.Sensor()
-    sens3 = magpy.Sensor(pixel=pos_vec3)
+    sens3 = magpy.Sensor()
+    sens4 = magpy.Sensor(pixel=pos_vec3)
     coll1 = magpy.Collection(sens1)
-    coll2 = magpy.Collection(sens1, sens2)
+    coll2 = magpy.Collection(sens2, sens3)
 
     goods = [
         sens1,
         coll1, coll2,
         pos_vec1, pos_vec2, pos_vec3,
-        [sens1, coll1], [sens1, coll2], [sens1, pos_vec1], [sens3, pos_vec3],
+        [sens1, coll1], [sens1, coll2], [sens1, pos_vec1], [sens4, pos_vec3],
         [pos_vec1, coll1], [pos_vec1, coll2],
         [sens1, coll1, pos_vec1],
         [sens1, coll1, sens2, pos_vec1]
@@ -628,6 +644,162 @@ def test_input_observer_bad():
     for bad in bads:
         np.testing.assert_raises(MagpylibBadUserInput, src.getB, bad)
 
+
+def test_input_collection_good():
+    """good inputs: collection(inp)"""
+    # pylint: disable=unnecessary-lambda
+    x = lambda : magpy.Sensor()
+    s = lambda : magpy.magnet.Cuboid()
+    c = lambda : magpy.Collection()
+
+    goods = [ #unpacked
+        [x()], [s()], [c()],
+        [x(), s(), c()],
+        [x(), x(), s(), s(), c(), c()],
+        [[x(), s(), c()]],
+        [(x(), s(), c())],
+    ]
+
+    for good in goods:
+        col = magpy.Collection(*good)
+        assert getattr(col, '_object_type', '') == 'Collection'
+
+
+def test_input_collection_bad():
+    """bad inputs: collection(inp)"""
+    # pylint: disable=unnecessary-lambda
+    x = lambda : magpy.Sensor()
+    s = lambda : magpy.magnet.Cuboid()
+    c = lambda : magpy.Collection()
+
+    bads = [
+        'some_string', None, True, 1, np.array((1,2,3)),
+        [x(), [s(), c()]],
+    ]
+    for bad in bads:
+        np.testing.assert_raises(MagpylibBadUserInput, magpy.Collection, bad)
+
+
+def test_input_collection_add_good():
+    """good inputs: collection.add(inp)"""
+    # pylint: disable=unnecessary-lambda
+    x = lambda : magpy.Sensor()
+    s = lambda : magpy.magnet.Cuboid()
+    c = lambda : magpy.Collection()
+
+    goods = [ #unpacked
+        [x()], [s()], [c()],
+        [x(), s(), c()],
+        [x(), x(), s(), s(), c(), c()],
+        [[x(), s(), c()]],
+        [(x(), s(), c())],
+    ]
+
+    for good in goods:
+        col = magpy.Collection()
+        col.add(*good)
+        assert getattr(col, '_object_type', '') == 'Collection'
+
+
+def test_input_collection_add_bad():
+    """bad inputs: collection.add(inp)"""
+    # pylint: disable=unnecessary-lambda
+    x = lambda : magpy.Sensor()
+    s = lambda : magpy.magnet.Cuboid()
+    c = lambda : magpy.Collection()
+
+    bads = [
+        'some_string', None, True, 1, np.array((1,2,3)),
+        [x(), [s(), c()]],
+    ]
+    for bad in bads:
+        col = magpy.Collection()
+        np.testing.assert_raises(MagpylibBadUserInput, col.add, bad)
+
+
+def test_input_collection_remove_good():
+    """good inputs: collection.remove(inp)"""
+    x = magpy.Sensor()
+    s = magpy.magnet.Cuboid()
+    c = magpy.Collection()
+
+    goods = [ #unpacked
+        [x], [s], [c],
+        [x, s, c],
+        [[x,s]],
+        [(x,s)],
+    ]
+
+    for good in goods:
+        col = magpy.Collection(*good)
+        assert col.children == (list(good[0]) if isinstance(good[0], (tuple, list)) else good)
+        col.remove(*good)
+        assert not col.children
+
+
+def test_input_collection_remove_bad():
+    """bad inputs: collection.remove(inp)"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    s1 = magpy.magnet.Cuboid()
+    s2 = magpy.magnet.Cuboid()
+    c1 = magpy.Collection()
+    c2 = magpy.Collection()
+    col = magpy.Collection(x1, x2, s1, s2, c1)
+
+    bads = [
+        'some_string', None, True, 1, np.array((1,2,3)),
+        [x1, [x2]]
+    ]
+    for bad in bads:
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            col.remove(bad)
+
+    # bad errors input
+    with np.testing.assert_raises(MagpylibBadUserInput):
+        col.remove(c2, errors='w00t')
+
+
+def test_input_basegeo_parent_setter_good():
+    """good inputs: obj.parent=inp"""
+    x = magpy.Sensor()
+    c = magpy.Collection()
+
+    goods = [
+        c,
+        None,
+    ]
+
+    for good in goods:
+        x.parent = good
+        assert x.parent == good
+
+
+def test_input_basegeo_parent_setter_bad():
+    """bad inputs: obj.parent=inp"""
+    x = magpy.Sensor()
+    c = magpy.Collection()
+
+    bads = [
+        'some_string', [], True, 1, np.array((1,2,3)),
+        [c],
+        magpy.Sensor(),
+        magpy.magnet.Cuboid(),
+    ]
+
+    for bad in bads:
+        with np.testing.assert_raises(MagpylibBadUserInput):
+            x.parent=bad
+
+    # when obj is good but has already a parent
+    x = magpy.Sensor()
+    magpy.Collection(x)
+    with np.testing.assert_raises(MagpylibBadUserInput):
+        magpy.Collection(x)
+
+
+
+
 ###########################################################
 ###########################################################
 # GET BH
diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py
index 701e2b90f..97f8f7bfd 100644
--- a/tests/test_obj_BaseGeo.py
+++ b/tests/test_obj_BaseGeo.py
@@ -1,8 +1,8 @@
+import re
 import numpy as np
 import pytest
 from scipy.spatial.transform import Rotation as R
 from magpylib._src.obj_classes.class_BaseGeo import BaseGeo
-from magpylib._src.exceptions import MagpylibBadUserInput
 import magpylib as magpy
 
 
@@ -346,13 +346,6 @@ def test_kwargs():
         bg = BaseGeo((0, 0, 0), None, styl_label="label_02")
 
 
-def test_bad_sum():
-    """test when adding bad objects"""
-    cuboid = magpy.magnet.Cuboid((1, 1, 1), (1, 1, 1))
-    with pytest.raises(MagpylibBadUserInput):
-        1 + cuboid
-
-
 def test_copy():
     """test copying object"""
     bg1 = BaseGeo((0, 0, 0), None, style_label='label1') #has style
@@ -373,3 +366,127 @@ def test_copy():
 
     # check if style is passed correctly
     assert bg2c.style.color == "orange"
+
+
+def test_copy_parents():
+    """ make sure that parents are not copied"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    x3 = magpy.Sensor()
+
+    c = x1 + x2 + x3
+
+    y = x1.copy()
+
+    assert x1.parent.parent == c
+    assert y.parent is None
+
+
+def test_describe():
+    """testing descibe method"""
+    # pylint: disable=protected-access
+    x1 = magpy.magnet.Cuboid(style_label='x1')
+    x2 = magpy.magnet.Cylinder(style_label='x2', dimension=(1,3), magnetization=(2,3,4))
+    s1 = magpy.Sensor(position=[(1,2,3)]*3, pixel=[(1,2,3)]*15)
+
+    desc = x1.describe()
+    assert desc is None
+
+    test = (
+        "<pre>Cuboid(id=REGEX, label='x1')<br>  • parent: None <br>  • " +
+        "position: [0. 0. 0.] mm<br>  • orientation: [0. 0. 0.] degrees<br>  • " +
+        "dimension: None mm<br>  • magnetization: None mT</pre>"
+    )
+    rep = x1._repr_html_()
+    rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep)
+    assert test == rep
+
+    magpy.Collection(x1, x2)
+    test = [
+        "Cuboid(id=REGEX, label='x1')",
+        "  • parent: Collection(id=REGEX) ", # INVISIBLE SPACE
+        "  • position: [0. 0. 0.] mm",
+        "  • orientation: [0. 0. 0.] degrees",
+        "  • dimension: None mm",
+        "  • magnetization: None mT",
+    ]
+    desc = x1.describe(return_string=True)
+    desc = re.sub('id=*[0-9]*[0-9]', 'id=REGEX', desc)
+    assert test == desc.split("\n")
+
+    test = [
+        "Cylinder(id=REGEX, label='x2')",
+        "  • parent: Collection(id=REGEX) ", # INVISIBLE SPACE
+        "  • position: [0. 0. 0.] mm",
+        "  • orientation: [0. 0. 0.] degrees",
+        "  • dimension: [1. 3.] mm",
+        "  • magnetization: [2. 3. 4.] mT",
+    ]
+    desc = x2.describe(return_string=True)
+    desc = re.sub('id=*[0-9]*[0-9]', 'id=REGEX', desc)
+    assert test == desc.split("\n")
+
+    test = [
+        "Sensor(id=REGEX)",
+        "  • parent: None ", # INVISIBLE SPACE
+        "  • path length: 3",
+        "  • position (last): [1. 2. 3.] mm",
+        "  • orientation (last): [0. 0. 0.] degrees",
+        "  • pixel: 15 ", # INVISIBLE SPACE
+    ]
+    desc = s1.describe(return_string=True)
+    desc = re.sub('id=*[0-9]*[0-9]', 'id=REGEX', desc)
+    assert test == desc.split("\n")
+
+    # exclude=None test
+    s = magpy.Sensor()
+    desc = s.describe(exclude=None, return_string=True)
+    test = (
+        "Sensor(id=REGEX)\n" +
+        "  • parent: None \n" +
+        "  • position: [0. 0. 0.] mm\n" +
+        "  • orientation: [0. 0. 0.] degrees\n" +
+        "  • pixel: 1 \n" +
+        "  • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " +
+        "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," +
+        " color=None, description=Description(show=None, text=None), label=None, " +
+        "model3d=Model3d(data=[], showdefault=True), opacity=None, path=Path(frames=None," +
+        " line=Line(color=None, style=None, width=None), marker=Marker(color=None," +
+        " size=None, symbol=None), numbering=None, show=None), pixel=Pixel(color=None," +
+        " size=1, symbol=None), size=None) "
+    )
+    desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc)
+    assert desc == test
+    
+    # exclude=None test
+    s = magpy.Sensor()
+    desc = s.describe(exclude=None, return_string=True)
+    test = (
+        "Sensor(id=REGEX)\n" +
+        "  • parent: None \n" +
+        "  • position: [0. 0. 0.] mm\n" +
+        "  • orientation: [0. 0. 0.] degrees\n" +
+        "  • pixel: 1 \n" +
+        "  • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " +
+        "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," +
+        " color=None, description=Description(show=None, text=None), label=None, " +
+        "model3d=Model3d(data=[], showdefault=True), opacity=None, path=Path(frames=None," +
+        " line=Line(color=None, style=None, width=None), marker=Marker(color=None," +
+        " size=None, symbol=None), numbering=None, show=None), pixel=Pixel(color=None," +
+        " size=1, symbol=None), size=None) "
+    )
+    desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc)
+    assert desc == test
+
+    # lots of sensor pixel
+    s = magpy.Sensor(pixel=[[[(1,2,3)]*5]*5]*3)
+    desc = s.describe(return_string=True)
+    test = (
+        "Sensor(id=REGEX)\n" +
+        "  • parent: None \n" +
+        "  • position: [0. 0. 0.] mm\n" +
+        "  • orientation: [0. 0. 0.] degrees\n" +
+        "  • pixel: 75 (3x5x5) "
+    )
+    desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc)
+    assert desc == test
diff --git a/tests/test_obj_Collection.py b/tests/test_obj_Collection.py
index 8c2935774..fa6bcecd4 100644
--- a/tests/test_obj_Collection.py
+++ b/tests/test_obj_Collection.py
@@ -1,5 +1,6 @@
 import pickle
 import os
+import re
 import numpy as np
 from scipy.spatial.transform import Rotation as R
 import pytest
@@ -49,7 +50,7 @@ def test_Collection_basics():
     )
     mags, dims2, dims3, posos, angs, axs, anchs, movs, rvs, _ = data
 
-    B1, B2, B3 = [], [], []
+    B1, B2 = [], []
     for mag, dim2, dim3, ang, ax, anch, mov, poso, rv in zip(
         mags, dims2, dims3, angs, axs, anchs, movs, posos, rvs
     ):
@@ -69,14 +70,9 @@ def test_Collection_basics():
         pm5 = magpy.magnet.Cylinder(mag[4], dim2[1])
         pm6 = magpy.magnet.Cylinder(mag[5], dim2[2])
 
-        col1 = magpy.Collection(pm1, [pm2, pm3])
-        col1 += pm4
-        col2 = magpy.Collection(pm5, pm6)
-        col1 += col2
-        col1 - pm5 - pm4
-        col1.remove(pm1)
-        col3 = col1 + pm5 + pm4 + pm1
-        col1.add(pm5, pm4, pm1)
+        col1 = magpy.Collection(pm1, pm2, pm3)
+        col1.add(pm4, pm5, pm6)
+
 
         # 18 subsequent operations
         for a, aa, aaa, mv in zip(ang, ax, anch, mov):
@@ -87,14 +83,11 @@ def test_Collection_basics():
 
         B1 += [magpy.getB([pm1b, pm2b, pm3b, pm4b, pm5b, pm6b], poso, sumup=True)]
         B2 += [col1.getB(poso)]
-        B3 += [col3.getB(poso)]
 
     B1 = np.array(B1)
     B2 = np.array(B2)
-    B3 = np.array(B3)
 
-    assert np.allclose(B1, B2), "Collection testfail1"
-    assert np.allclose(B1, B3), "Collection testfail2"
+    np.testing.assert_allclose(B1, B2)
 
 
 @pytest.mark.parametrize(
@@ -264,35 +257,27 @@ def test_Collection_with_Dipole():
     assert np.allclose(B, Btest)
 
 
-def test_repr_collection():
-    """test __repr__"""
-    pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3))
-    pm2 = magpy.magnet.Cylinder((1, 2, 3), (2, 3))
-    sens = magpy.Sensor()
-    col = magpy.Collection()
-    col.sources = pm1, pm2
-    assert "Source" in col.__repr__(), "Collection repr failed"
-    col.sensors = [sens]
-    assert "Mixed" in col.__repr__(), "Collection repr failed"
-    col.sources = []
-    assert "Sensor" in col.__repr__(), "Collection repr failed"
-
-
 def test_adding_sources():
     """test if all sources can be added"""
-    src1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3))
-    src2 = magpy.magnet.Cylinder((1, 2, 3), (1, 2))
-    src3 = magpy.magnet.Sphere((1, 2, 3), 1)
-    src4 = magpy.current.Loop(1, 1)
-    src5 = magpy.current.Line(1, [(1, 2, 3), (2, 3, 4)])
-    src6 = magpy.misc.Dipole((1, 2, 3))
-    col = src1 + src2 + src3 + src4 + src5 + src6
+    s1 = magpy.magnet.Cuboid()
+    s2 = magpy.magnet.Cylinder()
+    s3 = magpy.magnet.CylinderSegment()
+    s4 = magpy.magnet.Sphere()
+    s5 = magpy.current.Loop()
+    s6 = magpy.current.Line()
+    s7 = magpy.misc.Dipole()
+    x1 = magpy.Sensor()
+    c1 = magpy.Collection()
+    c2 = magpy.Collection()
+
+    for obj in [s1, s2, s3, s4, s5, s6, s7, x1, c1]:
+        c2.add(obj)
 
     strs = ""
-    for src in col:
+    for src in c2:
         strs += str(src)[:3]
 
-    assert strs == "CubCylSphLooLinDip"
+    assert strs == "CubCylCylSphLooLinDipSenCol"
 
 
 def test_set_children_styles():
@@ -311,13 +296,130 @@ def test_set_children_styles():
 
 def test_reprs():
     """test repr strings"""
-    s1 = magpy.magnet.Sphere((1,2,3), 5)
-    x1 = magpy.Sensor()
+    # pylint: disable=protected-access
+
     c = magpy.Collection()
     assert repr(c)[:10]=='Collection'
+
+    s1 = magpy.magnet.Sphere((1,2,3), 5)
     c = magpy.Collection(s1)
-    assert repr(c)[:10]=='SourceColl'
+    assert repr(c)[:10]=='Collection'
+
+    x1 = magpy.Sensor()
     c = magpy.Collection(x1)
-    assert repr(c)[:10]=='SensorColl'
+    assert repr(c)[:10]=='Collection'
+
+    x1 = magpy.Sensor()
+    s1 = magpy.magnet.Sphere((1,2,3), 5)
     c = magpy.Collection(s1,x1)
-    assert repr(c)[:10]=='MixedColle'
+    assert repr(c)[:10]=='Collection'
+
+    x1 = magpy.magnet.Cuboid(style_label='x1')
+    x2 = magpy.magnet.Cuboid(style_label='x2')
+    cc = x1 + x2
+    rep = cc._repr_html_()
+    rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep)
+    test = "<pre>Collection nolabel (id=REGEX)<br>├── Cuboid x1"
+    test += " (id=REGEX)<br>└── Cuboid x2 (id=REGEX)</pre>"
+    assert rep == test
+
+
+def test_collection_describe():
+    """ test describe method"""
+
+    x = magpy.magnet.Cuboid(style_label='x')
+    y = magpy.magnet.Cuboid(style_label='y')
+    z = magpy.magnet.Cuboid(style_label='z')
+    u = magpy.magnet.Cuboid(style_label='u')
+    c = x + y + z + u
+
+    desc = c.describe(format="label, type", return_string=True).split("\n")
+    test = [
+        "Collection nolabel",
+        "├── Collection nolabel",
+        "│   ├── Collection nolabel",
+        "│   │   ├── Cuboid x",
+        "│   │   └── Cuboid y",
+        "│   └── Cuboid z",
+        "└── Cuboid u",
+    ]
+    assert test == desc
+
+    desc = c.describe(format="label", return_string=True).split("\n")
+    test = [
+        "Collection",
+        "├── Collection",
+        "│   ├── Collection",
+        "│   │   ├── x",
+        "│   │   └── y",
+        "│   └── z",
+        "└── u",
+    ]
+    assert test == desc
+
+    desc = c.describe(format="type", return_string=True).split("\n")
+    test = [
+        "Collection",
+        "├── Collection",
+        "│   ├── Collection",
+        "│   │   ├── Cuboid",
+        "│   │   └── Cuboid",
+        "│   └── Cuboid",
+        "└── Cuboid",
+    ]
+    assert test == desc
+
+    desc = c.describe(format="label,type,id", return_string=True).split("\n")
+    test = [
+        "Collection nolabel (id=REGEX)",
+        "├── Collection nolabel (id=REGEX)",
+        "│   ├── Collection nolabel (id=REGEX)",
+        "│   │   ├── Cuboid x (id=REGEX)",
+        "│   │   └── Cuboid y (id=REGEX)",
+        "│   └── Cuboid z (id=REGEX)",
+        "└── Cuboid u (id=REGEX)",
+    ]
+    assert "".join(test)==re.sub('id=*[0-9]*[0-9]', 'id=REGEX', "".join(desc))
+
+
+    #pylint: disable=unnecessary-lambda
+    x = lambda : magpy.magnet.Cuboid()
+    y = lambda : magpy.current.Loop()
+    z = lambda : magpy.misc.CustomSource()
+
+    c = magpy.Collection(*[x() for _ in range(100)])
+    c.add(*[y() for _ in range(50)])
+    c.add(*[z() for _ in range(25)])
+
+    desc = c.describe(format="type+label", return_string=True).split('\n')
+    test = [
+        "Collection nolabel",
+        "├── 100x Cuboids",
+        "├── 50x Loops",
+        "└── 25x CustomSources",
+    ]
+    assert test == desc
+
+    x = magpy.magnet.Cuboid(style_label='x')
+    y = magpy.magnet.Cuboid(style_label='y')
+    cc = x + y
+    desc = cc.describe(format="label, properties", return_string=True).split("\n")
+    test = [
+        "Collection",
+        "│   • position: [0. 0. 0.] mm",
+        "│   • orientation: [0. 0. 0.] degrees",
+        "├── x",
+        "│       • position: [0. 0. 0.] mm",
+        "│       • orientation: [0. 0. 0.] degrees",
+        "│       • dimension: None mm",
+        "│       • magnetization: None mT",
+        "└── y",
+        "        • position: [0. 0. 0.] mm",
+        "        • orientation: [0. 0. 0.] degrees",
+        "        • dimension: None mm",
+        "        • magnetization: None mT",
+    ]
+    assert desc == test
+
+    desc = cc.describe()
+    assert desc is None
diff --git a/tests/test_obj_Collection_child_parent.py b/tests/test_obj_Collection_child_parent.py
new file mode 100644
index 000000000..8b6f001ba
--- /dev/null
+++ b/tests/test_obj_Collection_child_parent.py
@@ -0,0 +1,315 @@
+import numpy as np
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+
+def test_parent_setter():
+    """ setting and removing a parent"""
+    child_labels = lambda x: [c.style.label for c in x]
+
+    # default parent is None
+    x1 = magpy.Sensor(style_label='x1')
+    assert x1.parent is None
+
+    # init collection gives parent
+    c1 = magpy.Collection(x1, style_label='c1')
+    assert x1.parent.style.label == 'c1'
+    assert child_labels(c1) == ['x1']
+
+    # remove parent with setter
+    x1.parent=None
+    assert x1.parent is None
+    assert child_labels(c1) == []
+
+    # set parent
+    x1.parent = c1
+    assert x1.parent.style.label == 'c1'
+    assert child_labels(c1) == ['x1']
+
+    # set another parent
+    c2 = magpy.Collection(style_label='c2')
+    x1.parent = c2
+    assert x1.parent.style.label == 'c2'
+    assert child_labels(c1) == []
+    assert child_labels(c2) == ['x1']
+
+
+def test_children_setter():
+    """ setting new children and removing old parents"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    x3 = magpy.Sensor()
+    x4 = magpy.Sensor()
+
+    c = magpy.Collection(x1, x2)
+    c.children = [x3, x4]
+
+    # remove old parents
+    assert x1.parent is None
+    assert x2.parent is None
+    # new children
+    assert c[0] == x3
+    assert c[1] == x4
+
+
+def test_setter_parent_override():
+    """ all setter should override parents"""
+    x1 = magpy.Sensor()
+    s1 = magpy.magnet.Cuboid()
+    c1 = magpy.Collection()
+    coll = magpy.Collection(x1, s1, c1)
+
+    coll2 = magpy.Collection()
+    coll2.children = coll.children
+    assert coll2.children == [x1, s1, c1]
+
+    coll3 = magpy.Collection()
+    coll3.sensors = [x1]
+    coll3.sources = [s1]
+    coll3.collections = [c1]
+    assert coll3.children == [x1, s1, c1]
+
+
+def test_sensors_setter():
+    """ setting new sensors and removing old parents"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    x3 = magpy.Sensor()
+    x4 = magpy.Sensor()
+    s1 = magpy.magnet.CylinderSegment()
+
+    c = magpy.Collection(x1, x2, s1)
+    c.sensors = [x3, x4]
+
+    # remove old parents
+    assert x1.parent is None
+    assert x2.parent is None
+    # keep non-sensors
+    assert s1.parent == c
+    # new sensors
+    assert c[0] == s1
+    assert c.sensors[0] == x3
+    assert c.sensors[1] == x4
+
+
+def test_sources_setter():
+    """ setting new sources and removing old parents"""
+    s1 = magpy.magnet.Cylinder()
+    s2 = magpy.magnet.Cylinder()
+    s3 = magpy.magnet.Cylinder()
+    s4 = magpy.magnet.Cylinder()
+    x1 = magpy.Sensor()
+
+    c = magpy.Collection(x1, s1, s2)
+    c.sources = [s3, s4]
+
+    # old parents
+    assert s1.parent is None
+    assert s2.parent is None
+    # keep non-sources
+    assert x1.parent == c
+    # new children
+    assert c[0] == x1
+    assert c.sources[0] == s3
+    assert c[2] == s4
+
+
+def test_collections_setter():
+    """ setting new sources and removing old parents"""
+    c1 = magpy.Collection()
+    c2 = magpy.Collection()
+    c3 = magpy.Collection()
+    c4 = magpy.Collection()
+    x1 = magpy.Sensor()
+
+    c = magpy.Collection(c1, x1, c2)
+    c.collections = [c3, c4]
+
+    # old parents
+    assert c1.parent is None
+    assert c2.parent is None
+    # keep non-collections
+    assert x1.parent == c
+    # new children
+    assert c[0] == x1
+    assert c.collections[0] == c3
+    assert c[2] == c4
+
+
+def test_collection_inputs():
+    """ test basic collection inputs"""
+
+    s1 = magpy.magnet.Cuboid(style_label='s1')
+    s2 = magpy.magnet.Cuboid(style_label='s2')
+    s3 = magpy.magnet.Cuboid(style_label='s3')
+    x1 = magpy.Sensor(style_label='x1')
+    x2 = magpy.Sensor(style_label='x2')
+    c1 = magpy.Collection(x2, style_label='c1')
+
+    c2 = magpy.Collection(c1, x1, s1, s2, s3)
+    assert [c.style.label for c in c2.children] == ['c1', 'x1', 's1', 's2', 's3']
+    assert [c.style.label for c in c2.sensors] == ['x1']
+    assert [c.style.label for c in c2.sources] == ['s1', 's2', 's3']
+    assert [c.style.label for c in c2.collections] == ['c1']
+
+
+def test_collection_parent_child_relation():
+    """ test if parent-child relations are properly set with collections"""
+
+    s1 = magpy.magnet.Cuboid()
+    s2 = magpy.magnet.Cuboid()
+    s3 = magpy.magnet.Cuboid()
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    c1 = magpy.Collection(x2)
+    c2 = magpy.Collection(c1, x1, s1, s2, s3)
+
+    assert x1.parent == c2
+    assert s3.parent == c2
+    assert x2.parent == c1
+    assert c1.parent == c2
+    assert c2.parent is None
+
+
+def test_collections_add():
+    """ test collection construction"""
+    child_labels = lambda x: [c.style.label for c in x]
+
+    x1 = magpy.Sensor(style_label='x1')
+    x2 = magpy.Sensor(style_label='x2')
+    x3 = magpy.Sensor(style_label='x3')
+    x6 = magpy.Sensor(style_label='x6')
+    x7 = magpy.Sensor(style_label='x7')
+
+    # simple add
+    c2 = magpy.Collection(x1, style_label='c2')
+    c2.add(x2, x3)
+    assert child_labels(c2) == ['x1', 'x2', 'x3']
+
+    # adding another collection
+    c3 = magpy.Collection(x6, style_label='c3')
+    c2.add(c3)
+    assert child_labels(c2) == ['x1', 'x2', 'x3', 'c3']
+    assert child_labels(c3) == ['x6']
+
+    # adding to child collection should not change its parent collection
+    c3.add(x7)
+    assert child_labels(c2) == ['x1', 'x2', 'x3', 'c3']
+    assert child_labels(c3) == ['x6', 'x7']
+
+    # add with parent override
+    assert x7.parent == c3
+
+    c4 = magpy.Collection(style_label='c4')
+    c4.add(x7, override_parent=True)
+
+    assert child_labels(c3) == ['x6']
+    assert child_labels(c4) == ['x7']
+    assert x7.parent == c4
+
+
+def test_collection_plus():
+    """
+    testing collection adding and the += functionality
+    """
+    child_labels = lambda x: [c.style.label for c in x]
+
+    s1 = magpy.magnet.Cuboid(style_label='s1')
+    s2 = magpy.magnet.Cuboid(style_label='s2')
+    x1 = magpy.Sensor(style_label='x1')
+    x2 = magpy.Sensor(style_label='x2')
+    x3 = magpy.Sensor(style_label='x3')
+    c1 = magpy.Collection(s1, style_label='c1')
+
+    # practical simple +
+    c2 = c1 + s2
+    assert child_labels(c2) == ['c1', 's2']
+
+    # useless triple addition consistency
+    c3 = x1 + x2 + x3
+    assert c3[0][0].style.label == 'x1'
+    assert c3[0][1].style.label == 'x2'
+    assert c3[1].style.label == 'x3'
+
+    # useless += consistency
+    s3 = magpy.magnet.Cuboid(style_label='s3')
+    c2 += s3
+    assert [c.style.label for c in c2[0]] == ['c1', 's2']
+    assert c2[1] == s3
+
+
+def test_collection_remove():
+    """ removing from collections"""
+    child_labels = lambda x: [c.style.label for c in x]
+    source_labels = lambda x: [c.style.label for c in x.sources]
+    sensor_labels = lambda x: [c.style.label for c in x.sensors]
+
+    x1 = magpy.Sensor(style_label='x1')
+    x2 = magpy.Sensor(style_label='x2')
+    x3 = magpy.Sensor(style_label='x3')
+    x4 = magpy.Sensor(style_label='x4')
+    x5 = magpy.Sensor(style_label='x5')
+    s1 = magpy.misc.Dipole(style_label='s1')
+    s2 = magpy.misc.Dipole(style_label='s2')
+    s3 = magpy.misc.Dipole(style_label='s3')
+    q1 = magpy.misc.CustomSource(style_label='q1')
+    c1 = magpy.Collection(x1, x2, x3, x4, x5, style_label='c1')
+    c2 = magpy.Collection(s1, s2, s3, style_label='c2')
+    c3 = magpy.Collection(q1, c1, c2, style_label='c3')
+
+    assert child_labels(c1) == ['x1', 'x2', 'x3', 'x4', 'x5']
+    assert child_labels(c2) == ['s1', 's2', 's3']
+    assert child_labels(c3) == ['q1', 'c1', 'c2']
+
+    # remove item from collection
+    c1.remove(x5)
+    assert child_labels(c1) == ['x1', 'x2', 'x3', 'x4']
+    assert [c.style.label for c in c1.sensors] == ['x1', 'x2', 'x3', 'x4']
+
+    # remove 2 items from collection
+    c1.remove(x3, x4)
+    assert child_labels(c1) == ['x1', 'x2']
+    assert sensor_labels(c1) == ['x1', 'x2']
+
+    # remove item from child collection
+    c3.remove(s3)
+    assert child_labels(c3) == ['q1', 'c1', 'c2']
+    assert child_labels(c2) == ['s1', 's2']
+    assert source_labels(c2) == ['s1', 's2']
+
+    # remove child collection
+    c3.remove(c2)
+    assert child_labels(c3) == ['q1', 'c1']
+    assert child_labels(c2) == ['s1', 's2']
+
+    # attempt remove non-existant child
+    c3.remove(s1, errors='ignore')
+    assert child_labels(c3) == ['q1', 'c1']
+    assert child_labels(c1) == ['x1', 'x2']
+
+    # attempt remove child in lower level with recursion=False
+    c3.remove(x1, errors='ignore', recursive=False)
+    assert child_labels(c3) == ['q1', 'c1']
+    assert child_labels(c1) == ['x1', 'x2']
+
+    # attempt remove of non-existing child
+    with np.testing.assert_raises(MagpylibBadUserInput):
+        c3.remove(x1, errors='raise', recursive=False)
+
+
+def test_collection_nested_getBH():
+    """ test if getBH functionality is self-consistent with nesting"""
+    s1 = magpy.current.Loop(1, 1)
+    s2 = magpy.current.Loop(1, 1)
+    s3 = magpy.current.Loop(1, 1)
+    s4 = magpy.current.Loop(1, 1)
+
+    obs = [(1,2,3), (-2,-3,1), (2,2,-4), (4,2,-4)]
+    coll = s1 + s2 + s3 + s4 # nasty nesting
+
+    B1 = s1.getB(obs)
+    B4 = coll.getB(obs)
+    np.testing.assert_allclose(4*B1, B4)
+
+    H1 = s1.getH(obs)
+    H4 = coll.getH(obs)
+    np.testing.assert_allclose(4*H1, H4)
\ No newline at end of file
diff --git a/tests/test_obj_Collection_v4motion.py b/tests/test_obj_Collection_v4motion.py
index a670b3e16..31f78aef1 100644
--- a/tests/test_obj_Collection_v4motion.py
+++ b/tests/test_obj_Collection_v4motion.py
@@ -250,7 +250,6 @@ def test_Collection_setter():
     # magpy.show(*col0)
     POS = []
     ORI = []
-    col0 = magpy.Collection()
     for poz, roz in zip(
         [(0, 0, 0), (0, 0, 5), (5, 0, 0), (5, 0, 5), (10, 0, 0), (10, 0, 5)],
         [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 2, 3), (-2, -1, 3)],
@@ -259,12 +258,10 @@ def test_Collection_setter():
         for i in range(5):
             src = magpy.magnet.Cuboid((1, 0, 0), (0.5, 0.5, 0.5), (1, 0, 0))
             src.rotate_from_angax(72 * i, "z", (0, 0, 0))
-            col = col + src
+            col.add(src)
         col.position = poz
         col.orientation = R.from_rotvec(roz)
 
-        col0 = col0 + col
-
         POS += [[src.position for src in col]]
         ORI += [[src.orientation.as_rotvec() for src in col]]