Repository: napari/napari

Issue:
Feature: `add_<shape_type>` methods on Shapes layer
## 🚀 Feature
was just using the shapes layer programmatically (for maybe the first time?? 😂)  ... I had two points and wanted to draw a line.  I found myself looking for an `add_line` method, but found slightly more confusing `add()` method.

I wonder how folks would feel about some of convenience methods?  it might allow us to do a little more data validation and provide guidance for the corresponding shape type?  (i.e. `line` expects a different number of vertices than `rectangle` right?)

I kinda wanted to do `layer.add_line((x1, y1),  (x2, y2))`.  but I'd understand if we don't like supporting non-container arguments like that.  but I next tried a list: 
```python
layer.add([(x1, y1),  (x2, y2)], shape_type='line')
``` 
... looks like that failed because it wasn't a numpy array... however, the error message there was rather confusing:

```python
ValueError: <enum 'ShapeType'> may only be called with a `str` or an instance of <enum 'ShapeType'>. Got <class 'int'>
```

<details>

```pytb
In [94]: shp.add([coord1, coord2], shape_type='line')
---------------------------------------------------------------------------
~/Dropbox (HMS)/Python/forks/napari/napari/layers/shapes/shapes.py in add(self, data, shape_type, edge_width, edge_color, face_color, z_index)
   1677                 self.text.remove(indices_to_remove)
   1678
-> 1679             self._add_shapes(
   1680                 data,
   1681                 shape_type=shape_type,

~/Dropbox (HMS)/Python/forks/napari/napari/layers/shapes/shapes.py in _add_shapes(self, data, shape_type, edge_width, edge_color, face_color, z_index, z_refresh)
   1883             )
   1884
-> 1885             self._add_shapes_to_view(shape_inputs, self._data_view)
   1886
   1887         self._display_order_stored = copy(self._dims_order)

~/Dropbox (HMS)/Python/forks/napari/napari/layers/shapes/shapes.py in _add_shapes_to_view(self, shape_inputs, data_view)
   1893         for d, st, ew, ec, fc, z in shape_inputs:
   1894
-> 1895             shape_cls = shape_classes[ShapeType(st)]
   1896             shape = shape_cls(
   1897                 d,

~/Dropbox (HMS)/Python/forks/napari/napari/utils/misc.py in __call__(cls, value, names, module, qualname, type, start)
    234                 return value
    235             else:
--> 236                 raise ValueError(
    237                     trans._(
    238                         '{class_name} may only be called with a `str` or an instance of {class_name}. Got {dtype}',

```

</details>


anyway, just some thoughts from a new shapes user :)


Discussion:
I'm 👍 on this proposal. Different shapes have very different natural parameterizations that is hard to capture with a single `.add` call.
I think this is a good idea and also agree it's probably expected behaviour, especially since each shape has its own button in the layer controls. It'd be nice to have a corresponding `add_` method for each new shape button in the controls.

Resolving patch:
diff --git a/examples/add_shapes.py b/examples/add_shapes.py
--- a/examples/add_shapes.py
+++ b/examples/add_shapes.py
@@ -65,6 +65,18 @@
     name='shapes',
 )
 
+# shapes of each type can also be added via their respective add_<shape_type> method 
+# e.g. for the polygons above:
+
+# layer = viewer.add_shapes(name='shapes') # create empty layer
+# layer.add_polygons(
+#     polygons,
+#     edge_width=1,
+#     edge_color='coral',
+#     face_color='royalblue',    
+# )
+
+
 # change some properties of the layer
 layer.selected_data = set(range(layer.nshapes))
 layer.current_edge_width = 5
diff --git a/napari/layers/shapes/_shapes_utils.py b/napari/layers/shapes/_shapes_utils.py
--- a/napari/layers/shapes/_shapes_utils.py
+++ b/napari/layers/shapes/_shapes_utils.py
@@ -1089,3 +1089,53 @@ def number_of_shapes(data):
         n_shapes = len(data)
 
     return n_shapes
+
+
+def validate_num_vertices(
+    data, shape_type, min_vertices=None, valid_vertices=None
+):
+    """Raises error if a shape in data has invalid number of vertices.
+
+    Checks whether all shapes in data have a valid number of vertices
+    for the given shape type and vertex information. Rectangles and
+    ellipses can have either 2 or 4 vertices per shape,
+    lines can have only 2, while paths and polygons have a minimum
+    number of vertices, but no maximum.
+
+    One of valid_vertices or min_vertices must be passed to the
+    function.
+
+    Parameters
+    ----------
+    data : Array | Tuple(Array,str) | List[Array | Tuple(Array, str)] | Tuple(List[Array], str)
+        List of shape data, where each element is either an (N, D) array of the
+        N vertices of a shape in D dimensions or a tuple containing an array of
+        the N vertices and the shape_type string. Can be an 3-dimensional array
+        if each shape has the same number of vertices.
+    shape_type : str
+        Type of shape being validated (for detailed error message)
+    min_vertices : int or None
+        Minimum number of vertices for the shape type, by default None
+    valid_vertices : Tuple(int) or None
+        Valid number of vertices for the shape type in data, by default None
+
+    Raises
+    ------
+    ValueError
+        Raised if a shape is found with invalid number of vertices
+    """
+    n_shapes = number_of_shapes(data)
+    # single array of vertices
+    if n_shapes == 1 and len(np.array(data).shape) == 2:
+        # wrap in extra dimension so we can iterate through shape not vertices
+        data = [data]
+    for shape in data:
+        if (valid_vertices and len(shape) not in valid_vertices) or (
+            min_vertices and len(shape) < min_vertices
+        ):
+            raise ValueError(
+                trans._(
+                    f"{shape_type} {shape} has invalid number of vertices: {len(shape)}.",
+                    deferred=True,
+                )
+            )
diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py
--- a/napari/layers/shapes/shapes.py
+++ b/napari/layers/shapes/shapes.py
@@ -53,6 +53,7 @@
     get_default_shape_type,
     get_shape_ndim,
     number_of_shapes,
+    validate_num_vertices,
 )
 
 DEFAULT_COLOR_CYCLE = np.array([[1, 0, 1, 1], [0, 1, 0, 1]])
@@ -1574,6 +1575,288 @@ def _set_editable(self, editable=None):
         if not self.editable:
             self.mode = Mode.PAN_ZOOM
 
+    def add_rectangles(
+        self,
+        data,
+        *,
+        edge_width=None,
+        edge_color=None,
+        face_color=None,
+        z_index=None,
+    ):
+        """Add rectangles to the current layer.
+
+        Parameters
+        ----------
+        data : Array | List[Array]
+            List of rectangle data where each element is a (4, D) array of 4 vertices
+            in D dimensions, or a (2, D) array of 2 vertices in D dimensions, where
+            the vertices are top-left and bottom-right corners.
+            Can be a 3-dimensional array for multiple shapes, or list of 2 or 4
+            vertices for a single shape.
+        edge_width : float | list
+            thickness of lines and edges. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        edge_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        face_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        z_index : int | list
+            Specifier of z order priority. Shapes with higher z order are
+            displayed ontop of others. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        """
+        # rectangles can have either 4 vertices or (top left, bottom right)
+        valid_vertices_per_shape = (2, 4)
+        validate_num_vertices(
+            data, 'rectangle', valid_vertices=valid_vertices_per_shape
+        )
+
+        self.add(
+            data,
+            shape_type='rectangle',
+            edge_width=edge_width,
+            edge_color=edge_color,
+            face_color=face_color,
+            z_index=z_index,
+        )
+
+    def add_ellipses(
+        self,
+        data,
+        *,
+        edge_width=None,
+        edge_color=None,
+        face_color=None,
+        z_index=None,
+    ):
+        """Add ellipses to the current layer.
+
+        Parameters
+        ----------
+        data : Array | List[Array]
+            List of ellipse data where each element is a (4, D) array of 4 vertices
+            in D dimensions representing a bounding box, or a (2, D) array of
+            center position and radii magnitudes in D dimensions.
+            Can be a 3-dimensional array for multiple shapes, or list of 2 or 4
+            vertices for a single shape.
+        edge_width : float | list
+            thickness of lines and edges. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        edge_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        face_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        z_index : int | list
+            Specifier of z order priority. Shapes with higher z order are
+            displayed ontop of others. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        """
+
+        valid_elem_per_shape = (2, 4)
+        validate_num_vertices(
+            data, 'ellipse', valid_vertices=valid_elem_per_shape
+        )
+
+        self.add(
+            data,
+            shape_type='ellipse',
+            edge_width=edge_width,
+            edge_color=edge_color,
+            face_color=face_color,
+            z_index=z_index,
+        )
+
+    def add_polygons(
+        self,
+        data,
+        *,
+        edge_width=None,
+        edge_color=None,
+        face_color=None,
+        z_index=None,
+    ):
+        """Add polygons to the current layer.
+
+        Parameters
+        ----------
+        data : Array | List[Array]
+            List of polygon data where each element is a (V, D) array of V vertices
+            in D dimensions representing a polygon. Can be a 3-dimensional array if
+            polygons have same number of vertices, or a list of V vertices for a
+            single polygon.
+        edge_width : float | list
+            thickness of lines and edges. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        edge_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        face_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        z_index : int | list
+            Specifier of z order priority. Shapes with higher z order are
+            displayed ontop of others. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        """
+
+        min_vertices = 3
+        validate_num_vertices(data, 'polygon', min_vertices=min_vertices)
+
+        self.add(
+            data,
+            shape_type='polygon',
+            edge_width=edge_width,
+            edge_color=edge_color,
+            face_color=face_color,
+            z_index=z_index,
+        )
+
+    def add_lines(
+        self,
+        data,
+        *,
+        edge_width=None,
+        edge_color=None,
+        face_color=None,
+        z_index=None,
+    ):
+        """Add lines to the current layer.
+
+        Parameters
+        ----------
+        data : Array | List[Array]
+            List of line data where each element is a (2, D) array of 2 vertices
+            in D dimensions representing a line. Can be a 3-dimensional array for
+            multiple shapes, or list of 2 vertices for a single shape.
+        edge_width : float | list
+            thickness of lines and edges. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        edge_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        face_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        z_index : int | list
+            Specifier of z order priority. Shapes with higher z order are
+            displayed ontop of others. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        """
+
+        valid_vertices_per_line = (2,)
+        validate_num_vertices(
+            data, 'line', valid_vertices=valid_vertices_per_line
+        )
+
+        self.add(
+            data,
+            shape_type='line',
+            edge_width=edge_width,
+            edge_color=edge_color,
+            face_color=face_color,
+            z_index=z_index,
+        )
+
+    def add_paths(
+        self,
+        data,
+        *,
+        edge_width=None,
+        edge_color=None,
+        face_color=None,
+        z_index=None,
+    ):
+        """Add paths to the current layer.
+
+        Parameters
+        ----------
+        data : Array | List[Array]
+            List of path data where each element is a (V, D) array of V vertices
+            in D dimensions representing a path. Can be a 3-dimensional array
+            if all paths have same number of vertices, or a list of V vertices
+            for a single path.
+        edge_width : float | list
+            thickness of lines and edges. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        edge_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        face_color : str | tuple | list
+            If string can be any color name recognized by vispy or hex value if
+            starting with `#`. If array-like must be 1-dimensional array with 3
+            or 4 elements. If a list is supplied it must be the same length as
+            the length of `data` and each element will be applied to each shape
+            otherwise the same value will be used for all shapes.
+        z_index : int | list
+            Specifier of z order priority. Shapes with higher z order are
+            displayed ontop of others. If a list is supplied it must be the
+            same length as the length of `data` and each element will be
+            applied to each shape otherwise the same value will be used for all
+            shapes.
+        """
+
+        min_vertices_per_path = 2
+        validate_num_vertices(data, 'path', min_vertices=min_vertices_per_path)
+
+        self.add(
+            data,
+            shape_type='path',
+            edge_width=edge_width,
+            edge_color=edge_color,
+            face_color=face_color,
+            z_index=z_index,
+        )
+
     def add(
         self,
         data,


Test patch:
diff --git a/napari/layers/shapes/_tests/test_shapes.py b/napari/layers/shapes/_tests/test_shapes.py
--- a/napari/layers/shapes/_tests/test_shapes.py
+++ b/napari/layers/shapes/_tests/test_shapes.py
@@ -343,45 +343,54 @@ def test_data_setter_with_text(properties):
     assert len(layer.text.values) == n_new_shapes_2
 
 
-def test_rectangles():
+@pytest.mark.parametrize(
+    "shape",
+    [
+        # single & multiple four corner rectangles
+        (1, 4, 2),
+        (10, 4, 2),
+        # single & multiple two corner rectangles
+        (1, 2, 2),
+        (10, 2, 2),
+    ],
+)
+def test_rectangles(shape):
     """Test instantiating Shapes layer with a random 2D rectangles."""
-    # Test a single four corner rectangle
-    shape = (1, 4, 2)
+    # Test instantiating with data
     np.random.seed(0)
     data = 20 * np.random.random(shape)
     layer = Shapes(data)
     assert layer.nshapes == shape[0]
-    assert np.all(layer.data[0] == data[0])
+    # 4 corner rectangle(s) passed, assert vertices the same
+    if shape[1] == 4:
+        assert np.all([layer.data[i] == data[i] for i in range(layer.nshapes)])
+    # 2 corner rectangle(s) passed, assert 4 vertices in layer
+    else:
+        assert [len(rect) == 4 for rect in layer.data]
     assert layer.ndim == shape[2]
     assert np.all([s == 'rectangle' for s in layer.shape_type])
 
-    # Test multiple four corner rectangles
-    shape = (10, 4, 2)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data)
-    assert layer.nshapes == shape[0]
-    assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)])
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'rectangle' for s in layer.shape_type])
+    # Test adding via add_rectangles method
+    layer2 = Shapes()
+    layer2.add_rectangles(data)
+    assert layer.nshapes == layer2.nshapes
+    assert np.allclose(layer2.data, layer.data)
+    assert np.all([s == 'rectangle' for s in layer2.shape_type])
 
-    # Test a single two corner rectangle, which gets converted into four
-    # corner rectangle
-    shape = (1, 2, 2)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data)
-    assert layer.nshapes == 1
-    assert len(layer.data[0]) == 4
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'rectangle' for s in layer.shape_type])
 
-    # Test multiple two corner rectangles
-    shape = (10, 2, 2)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data)
-    assert layer.nshapes == shape[0]
-    assert np.all([len(ld) == 4 for ld in layer.data])
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'rectangle' for s in layer.shape_type])
+def test_add_rectangles_raises_errors():
+    """Test input validation for add_rectangles method"""
+    layer = Shapes()
+
+    np.random.seed(0)
+    # single rectangle, 3 vertices
+    data = 20 * np.random.random((1, 3, 2))
+    with pytest.raises(ValueError):
+        layer.add_rectangles(data)
+    # multiple rectangles, 5 vertices
+    data = 20 * np.random.random((5, 5, 2))
+    with pytest.raises(ValueError):
+        layer.add_rectangles(data)
 
 
 def test_rectangles_with_shape_type():
@@ -475,49 +484,65 @@ def test_3D_rectangles():
     assert layer.ndim == 3
     assert np.all([s == 'rectangle' for s in layer.shape_type])
 
+    # test adding with add_rectangles
+    layer2 = Shapes()
+    layer2.add_rectangles(data)
+    assert layer2.nshapes == layer.nshapes
+    assert np.all(
+        [np.all(ld == ld2) for ld, ld2 in zip(layer.data, layer2.data)]
+    )
+    assert np.all([s == 'rectangle' for s in layer2.shape_type])
 
-def test_ellipses():
-    """Test instantiating Shapes layer with a random 2D ellipses."""
-    # Test a single four corner ellipses
-    shape = (1, 4, 2)
-    np.random.seed(0)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data, shape_type='ellipse')
-    assert layer.nshapes == shape[0]
-    assert np.all(layer.data[0] == data[0])
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'ellipse' for s in layer.shape_type])
 
-    # Test multiple four corner ellipses
-    shape = (10, 4, 2)
+@pytest.mark.parametrize(
+    "shape",
+    [
+        # single & multiple four corner ellipses
+        (1, 4, 2),
+        (10, 4, 2),
+        # single & multiple center, radii ellipses
+        (1, 2, 2),
+        (10, 2, 2),
+    ],
+)
+def test_ellipses(shape):
+    """Test instantiating Shapes layer with random 2D ellipses."""
+
+    # Test instantiating with data
     np.random.seed(0)
     data = 20 * np.random.random(shape)
     layer = Shapes(data, shape_type='ellipse')
     assert layer.nshapes == shape[0]
-    assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)])
+    # 4 corner bounding box passed, assert vertices the same
+    if shape[1] == 4:
+        assert np.all([layer.data[i] == data[i] for i in range(layer.nshapes)])
+    # (center, radii) passed, assert 4 vertices in layer
+    else:
+        assert [len(rect) == 4 for rect in layer.data]
     assert layer.ndim == shape[2]
     assert np.all([s == 'ellipse' for s in layer.shape_type])
 
-    # Test a single ellipse center radii, which gets converted into four
-    # corner ellipse
-    shape = (1, 2, 2)
-    np.random.seed(0)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data, shape_type='ellipse')
-    assert layer.nshapes == 1
-    assert len(layer.data[0]) == 4
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'ellipse' for s in layer.shape_type])
+    # Test adding via add_ellipses method
+    layer2 = Shapes()
+    layer2.add_ellipses(data)
+    assert layer.nshapes == layer2.nshapes
+    assert np.allclose(layer2.data, layer.data)
+    assert np.all([s == 'ellipse' for s in layer2.shape_type])
+
+
+def test_add_ellipses_raises_error():
+    """Test input validation for add_ellipses method"""
+    layer = Shapes()
 
-    # Test multiple center radii ellipses
-    shape = (10, 2, 2)
     np.random.seed(0)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data, shape_type='ellipse')
-    assert layer.nshapes == shape[0]
-    assert np.all([len(ld) == 4 for ld in layer.data])
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'ellipse' for s in layer.shape_type])
+    # single ellipse, 3 vertices
+    data = 20 * np.random.random((1, 3, 2))
+    with pytest.raises(ValueError):
+        layer.add_ellipses(data)
+    # multiple ellipses, 5 vertices
+    data = 20 * np.random.random((5, 5, 2))
+    with pytest.raises(ValueError):
+        layer.add_ellipses(data)
 
 
 def test_ellipses_with_shape_type():
@@ -606,6 +631,16 @@ def test_4D_ellispse():
     assert layer.ndim == 4
     assert np.all([s == 'ellipse' for s in layer.shape_type])
 
+    # test adding via add_ellipses
+    layer2 = Shapes(ndim=4)
+    layer2.add_ellipses(data)
+    assert layer.nshapes == layer2.nshapes
+    assert np.all(
+        [np.all(ld == ld2) for ld, ld2 in zip(layer.data, layer2.data)]
+    )
+    assert layer.ndim == 4
+    assert np.all([s == 'ellipse' for s in layer2.shape_type])
+
 
 def test_ellipses_roundtrip():
     """Test a full roundtrip with ellipss data."""
@@ -617,20 +652,11 @@ def test_ellipses_roundtrip():
     assert np.all([nd == d for nd, d in zip(new_layer.data, layer.data)])
 
 
-def test_lines():
+@pytest.mark.parametrize('shape', [(1, 2, 2), (10, 2, 2)])
+def test_lines(shape):
     """Test instantiating Shapes layer with a random 2D lines."""
-    # Test a single two end point line
-    shape = (1, 2, 2)
-    np.random.seed(0)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data, shape_type='line')
-    assert layer.nshapes == shape[0]
-    assert np.all(layer.data[0] == data[0])
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'line' for s in layer.shape_type])
 
-    # Test multiple lines
-    shape = (10, 2, 2)
+    # Test instantiating with data
     np.random.seed(0)
     data = 20 * np.random.random(shape)
     layer = Shapes(data, shape_type='line')
@@ -639,6 +665,31 @@ def test_lines():
     assert layer.ndim == shape[2]
     assert np.all([s == 'line' for s in layer.shape_type])
 
+    # Test adding using add_lines
+    layer2 = Shapes()
+    layer2.add_lines(data)
+    assert layer.nshapes == layer2.nshapes
+    assert np.allclose(layer2.data, layer.data)
+    assert np.all([s == 'line' for s in layer2.shape_type])
+
+
+def test_add_lines_raises_error():
+    """Test adding lines with incorrect vertices raise error"""
+
+    # single line
+    shape = (1, 3, 2)
+    data = 20 * np.random.random(shape)
+    layer = Shapes()
+    with pytest.raises(ValueError):
+        layer.add_lines(data)
+
+    # multiple lines
+    data = [
+        20 * np.random.random((np.random.randint(3, 10), 2)) for _ in range(10)
+    ]
+    with pytest.raises(ValueError):
+        layer.add_lines(data)
+
 
 def test_lines_with_shape_type():
     """Test instantiating lines with shape_type"""
@@ -686,28 +737,52 @@ def test_lines_roundtrip():
     assert np.all([nd == d for nd, d in zip(new_layer.data, layer.data)])
 
 
-def test_paths():
-    """Test instantiating Shapes layer with a random 2D paths."""
-    # Test a single path with 6 points
-    shape = (1, 6, 2)
-    np.random.seed(0)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data, shape_type='path')
-    assert layer.nshapes == shape[0]
-    assert np.all(layer.data[0] == data[0])
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'path' for s in layer.shape_type])
-
-    # Test multiple paths with different numbers of points
-    data = [
-        20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(10)
+@pytest.mark.parametrize(
+    "shape",
+    [
+        # single path, six points
+        (6, 2),
     ]
+    + [
+        # multiple 2D paths with different numbers of points
+        (np.random.randint(2, 12), 2)
+        for _ in range(10)
+    ],
+)
+def test_paths(shape):
+    """Test instantiating Shapes layer with random 2D paths."""
+
+    # Test instantiating with data
+    data = [20 * np.random.random(shape)]
     layer = Shapes(data, shape_type='path')
     assert layer.nshapes == len(data)
     assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)])
     assert layer.ndim == 2
     assert np.all([s == 'path' for s in layer.shape_type])
 
+    # Test adding to layer via add_paths
+    layer2 = Shapes()
+    layer2.add_paths(data)
+    assert layer.nshapes == layer2.nshapes
+    assert np.allclose(layer2.data, layer.data)
+    assert np.all([s == 'path' for s in layer2.shape_type])
+
+
+def test_add_paths_raises_error():
+    """Test adding paths with incorrect vertices raise error"""
+
+    # single path
+    shape = (1, 1, 2)
+    data = 20 * np.random.random(shape)
+    layer = Shapes()
+    with pytest.raises(ValueError):
+        layer.add_paths(data)
+
+    # multiple paths
+    data = 20 * np.random.random((10, 1, 2))
+    with pytest.raises(ValueError):
+        layer.add_paths(data)
+
 
 def test_paths_with_shape_type():
     """Test instantiating paths with shape_type in data"""
@@ -755,28 +830,53 @@ def test_paths_roundtrip():
     )
 
 
-def test_polygons():
+@pytest.mark.parametrize(
+    "shape",
+    [
+        # single 2D polygon, six points
+        (6, 2),
+    ]
+    + [
+        # multiple 2D polygons with different numbers of points
+        (np.random.randint(3, 12), 2)
+        for _ in range(10)
+    ],
+)
+def test_polygons(shape):
     """Test instantiating Shapes layer with a random 2D polygons."""
-    # Test a single polygon with 6 points
-    shape = (1, 6, 2)
-    np.random.seed(0)
-    data = 20 * np.random.random(shape)
-    layer = Shapes(data, shape_type='polygon')
-    assert layer.nshapes == shape[0]
-    assert np.all(layer.data[0] == data[0])
-    assert layer.ndim == shape[2]
-    assert np.all([s == 'polygon' for s in layer.shape_type])
 
-    # Test multiple polygons with different numbers of points
-    data = [
-        20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(10)
-    ]
+    # Test instantiating with data
+    data = [20 * np.random.random(shape)]
     layer = Shapes(data, shape_type='polygon')
     assert layer.nshapes == len(data)
     assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)])
     assert layer.ndim == 2
     assert np.all([s == 'polygon' for s in layer.shape_type])
 
+    # Test adding via add_polygons
+    layer2 = Shapes()
+    layer2.add_polygons(data)
+    assert layer.nshapes == layer2.nshapes
+    assert np.allclose(layer2.data, layer.data)
+    assert np.all([s == 'polygon' for s in layer2.shape_type])
+
+
+def test_add_polygons_raises_error():
+    """Test input validation for add_polygons method"""
+    layer = Shapes()
+
+    np.random.seed(0)
+    # single polygon, 2 vertices
+    data = 20 * np.random.random((1, 2, 2))
+    with pytest.raises(ValueError):
+        layer.add_polygons(data)
+    # multiple polygons, only some with 2 vertices
+    data = [20 * np.random.random((5, 2)) for _ in range(5)] + [
+        20 * np.random.random((2, 2)) for _ in range(2)
+    ]
+    with pytest.raises(ValueError):
+        layer.add_polygons(data)
+
 
 def test_polygons_with_shape_type():
     """Test 2D polygons with shape_type in data"""