Repository: napari-threedee/napari-threedee (language: python)

Issue:
goals
The goals of this repo are to
- provide a suite of interactive workflows which enable specific visualisations/analyses in 3D
- be a lightweight dependency that others could easily provide with their application
- be modular, each workflow should be independent of the others
- be serialisable, for scripting/making animations
- each workflow should have a dock widget




Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index fd2845d..bc5d226 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,6 +62,7 @@ docs/_build/
 
 # MkDocs documentation
 /site/
+/docs/generated/
 
 # PyBuilder
 target/
diff --git a/README.md b/README.md
index 251ca02..a51da74 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,9 @@ To install latest development version :
     pip install git+https://github.com/alisterburt/napari-threedee.git
 
 ## Example applications
+
+See the full list of [example gallery scripts here on our website](https://napari-threedee.github.io/generated/gallery/).
+
 <table border="0">
 <tr><td>
 
diff --git a/docs/examples/README.md b/docs/examples/README.md
new file mode 100644
index 0000000..a8a4afd
--- /dev/null
+++ b/docs/examples/README.md
@@ -0,0 +1,3 @@
+# Gallery for napari-threedee
+
+Gallery of examples built with napari-threedee.
diff --git a/docs/examples/library/README.md b/docs/examples/library/README.md
new file mode 100644
index 0000000..dbb3b7f
--- /dev/null
+++ b/docs/examples/library/README.md
@@ -0,0 +1,3 @@
+# Library examples
+
+Examples where napari-threedee is used as a library.
diff --git a/examples/library/camera_spline.py b/docs/examples/library/camera_spline_library.py
similarity index 81%
rename from examples/library/camera_spline.py
rename to docs/examples/library/camera_spline_library.py
index 425b691..5c54a32 100644
--- a/examples/library/camera_spline.py
+++ b/docs/examples/library/camera_spline_library.py
@@ -1,3 +1,10 @@
+"""
+Camera spline (library)
+===============================
+
+An example controlling the camera spline,
+using napari-threedee as a library.
+"""
 import napari
 import skimage
 
diff --git a/examples/library/layer_manipulator.py b/docs/examples/library/layer_manipulator_library.py
similarity index 68%
rename from examples/library/layer_manipulator.py
rename to docs/examples/library/layer_manipulator_library.py
index aeffcf0..15dddcd 100644
--- a/examples/library/layer_manipulator.py
+++ b/docs/examples/library/layer_manipulator_library.py
@@ -1,3 +1,10 @@
+"""
+Layer manipulator (library)
+===================================
+
+An example controlling the layer manipulator,
+using napari-threedee as a library.
+"""
 import napari
 import numpy as np
 
diff --git a/examples/library/mesh_headlight.py b/docs/examples/library/mesh_headlight_library.py
similarity index 84%
rename from examples/library/mesh_headlight.py
rename to docs/examples/library/mesh_headlight_library.py
index d4dcd05..810ae4e 100644
--- a/examples/library/mesh_headlight.py
+++ b/docs/examples/library/mesh_headlight_library.py
@@ -1,3 +1,10 @@
+"""
+Mesh headlight (library)
+================================
+
+An example controlling the mesh headlight,
+using napari-threedee as a library.
+"""
 import napari
 import numpy as np
 from vispy.io import load_data_file, read_mesh
diff --git a/docs/examples/library/path_annotator_library.py b/docs/examples/library/path_annotator_library.py
new file mode 100644
index 0000000..2b0465e
--- /dev/null
+++ b/docs/examples/library/path_annotator_library.py
@@ -0,0 +1,60 @@
+"""
+Path point annotator (library)
+==============================
+
+An example controlling the path annotator,
+using napari-threedee as a library.
+"""
+
+import napari
+import numpy as np
+from skimage import data
+
+from napari_threedee.annotators import PathAnnotator
+from napari_threedee.data_models import N3dPaths, N3dPath
+
+CREATE_LAYER_FROM_EXISTING_DATA = True
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=4).astype(float)
+
+# add image layer to viewer (rendering as a plane)
+image_layer = viewer.add_image(
+    blobs,
+    name='orange plane',
+    rendering='average',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+        'position': (32, 32, 32),
+        'normal': (1, 0, 0),
+        'thickness': 10,
+    }
+)
+
+# optionally create an n3d compatible points layer from existing data
+if CREATE_LAYER_FROM_EXISTING_DATA is True:
+    path = N3dPath(data=np.random.uniform(low=8, high=56, size=(10, 3)))
+    points_layer = N3dPaths(data=[path]).as_layer()  # list of N3dPath
+else:
+    points_layer = None
+
+# create the path annotator
+annotator = PathAnnotator(
+    viewer=viewer,
+    image_layer=image_layer,
+    points_layer=points_layer,
+    enabled=True,
+)
+
+# run napari
+viewer.axes.visible = True
+viewer.axes.labels = False
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
diff --git a/docs/examples/library/point_annotator_library.py b/docs/examples/library/point_annotator_library.py
new file mode 100644
index 0000000..be7963b
--- /dev/null
+++ b/docs/examples/library/point_annotator_library.py
@@ -0,0 +1,57 @@
+"""
+Point annotator (library)
+=================================
+
+An example controlling the point annotator,
+using napari-threedee as a library.
+"""
+import napari
+from skimage import data
+
+from napari_threedee.annotators import PointAnnotator
+
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate 3d image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
+
+# add image layer to viewer (rendering as a plane)
+image_layer = viewer.add_image(
+    blobs,
+    rendering='average',
+    name='plane',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+        'position': (32, 32, 32),
+        'normal': (1, 0, 0),
+        'thickness': 10,
+    },
+)
+
+# add points layer to viewer
+points_layer = viewer.add_points(
+    data=[],
+    size=5,
+    face_color='cornflowerblue',
+    ndim=3
+)
+
+# create the point annotator
+annotator = PointAnnotator(
+    viewer=viewer,
+    image_layer=image_layer,
+    points_layer=points_layer,
+    enabled=True,
+)
+
+# run napari
+viewer.layers.selection = [image_layer]
+viewer.axes.visible = True
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
diff --git a/examples/library/render_plane_manipulator.py b/docs/examples/library/render_plane_manipulator_library.py
similarity index 81%
rename from examples/library/render_plane_manipulator.py
rename to docs/examples/library/render_plane_manipulator_library.py
index 7ff0563..b67a23e 100644
--- a/examples/library/render_plane_manipulator.py
+++ b/docs/examples/library/render_plane_manipulator_library.py
@@ -1,3 +1,10 @@
+"""
+Render plane manipulator (library)
+==========================================
+
+An example controlling the render plane manipulator,
+using napari-threedee as a library.
+"""
 from napari_threedee.manipulators._qt import QtRenderPlaneManipulatorWidget
 
 import napari
diff --git a/docs/examples/library/sphere_annotator_library.py b/docs/examples/library/sphere_annotator_library.py
new file mode 100644
index 0000000..9e0d2e2
--- /dev/null
+++ b/docs/examples/library/sphere_annotator_library.py
@@ -0,0 +1,60 @@
+"""
+Sphere annotator (library)
+==================================
+
+An example controlling the sphere annotator,
+using napari-threedee as a library.
+"""
+import napari
+import numpy as np
+from skimage import data
+
+from napari_threedee.data_models import N3dSpheres
+from napari_threedee.annotators.spheres import SphereAnnotator
+
+CREATE_LAYER_FROM_EXISTING_DATA = True
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
+
+# add image layer to viewer (rendering as a plane)
+image_layer = viewer.add_image(
+    blobs,
+    rendering='average',
+    name='plane',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+        'position': (32, 32, 32),
+        'normal': (1, 0, 0),
+        'thickness': 10,
+    },
+)
+
+# optionally create an n3d compatible points layer from existing data
+if CREATE_LAYER_FROM_EXISTING_DATA is True:
+    centers = np.random.uniform(0, 64, size=(10, 3))
+    radii = np.random.uniform(1, 10, size=(10,))
+    points_layer = N3dSpheres(centers=centers, radii=radii).as_layer()
+else:
+    points_layer = None
+
+# create the annotator
+annotator = SphereAnnotator(
+    viewer=viewer,
+    image_layer=image_layer,
+    points_layer=points_layer,
+    enabled=True
+)
+
+# run napari
+viewer.axes.visible = True
+viewer.axes.labels = False
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
diff --git a/docs/examples/library/surface_annotator_library.py b/docs/examples/library/surface_annotator_library.py
new file mode 100644
index 0000000..6d38dfb
--- /dev/null
+++ b/docs/examples/library/surface_annotator_library.py
@@ -0,0 +1,45 @@
+"""
+Surface annotator (library)
+===================================
+
+An example controlling the surface annotator,
+using napari-threedee as a library.
+"""
+import napari
+from skimage import data
+
+import napari_threedee as n3d
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate 3d image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
+
+# add image layer to viewer (rendering as plane)
+image_layer = viewer.add_image(
+    blobs,
+    name='orange plane',
+    rendering='average',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+        'position': (32, 32, 32),
+        'normal': (1, 0, 0),
+        'thickness': 10,
+    })
+
+# create annotator
+annotator = n3d.annotators.SurfaceAnnotator(
+    viewer=viewer, image_layer=image_layer, enabled=True
+)
+
+# run napari
+viewer.layers.selection = [image_layer]
+viewer.axes.visible = True
+viewer.axes.labels = False
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
diff --git a/docs/examples/plugin/README.md b/docs/examples/plugin/README.md
new file mode 100644
index 0000000..5565f7d
--- /dev/null
+++ b/docs/examples/plugin/README.md
@@ -0,0 +1,3 @@
+# Plugin examples
+
+Examples where napari-threedee is used as a napari plugin.
diff --git a/examples/plugin/camera_spline.py b/docs/examples/plugin/camera_spline_plugin.py
similarity index 86%
rename from examples/plugin/camera_spline.py
rename to docs/examples/plugin/camera_spline_plugin.py
index b9053b3..925cfc2 100644
--- a/examples/plugin/camera_spline.py
+++ b/docs/examples/plugin/camera_spline_plugin.py
@@ -1,3 +1,10 @@
+"""
+Camera spline (plugin)
+==============================
+
+An example controlling the camera spline,
+using napari-threedee as a napari plugin.
+"""
 import napari
 import skimage
 
diff --git a/examples/plugin/layer_manipulator.py b/docs/examples/plugin/layer_manipulator_plugin.py
similarity index 84%
rename from examples/plugin/layer_manipulator.py
rename to docs/examples/plugin/layer_manipulator_plugin.py
index cdfe391..4733e5d 100644
--- a/examples/plugin/layer_manipulator.py
+++ b/docs/examples/plugin/layer_manipulator_plugin.py
@@ -1,3 +1,10 @@
+"""
+Layer manipulator (plugin)
+==================================
+
+An example controlling the layer manipulator,
+using napari-threedee as a napari plugin.
+"""
 import napari
 
 try:
diff --git a/examples/plugin/mesh_headlight.py b/docs/examples/plugin/mesh_headlight_plugin.py
similarity index 82%
rename from examples/plugin/mesh_headlight.py
rename to docs/examples/plugin/mesh_headlight_plugin.py
index c909eb3..ad73013 100644
--- a/examples/plugin/mesh_headlight.py
+++ b/docs/examples/plugin/mesh_headlight_plugin.py
@@ -1,3 +1,10 @@
+"""
+Mesh headlight (plugin)
+===============================
+
+An example controlling the mesh headlight,
+using napari-threedee as a napari plugin.
+"""
 import napari
 import numpy as np
 from vispy.io import load_data_file, read_mesh
diff --git a/docs/examples/plugin/path_annotator_plugin.py b/docs/examples/plugin/path_annotator_plugin.py
new file mode 100644
index 0000000..aeb0814
--- /dev/null
+++ b/docs/examples/plugin/path_annotator_plugin.py
@@ -0,0 +1,58 @@
+"""
+Path point annotator (plugin)
+==============================
+
+An example controlling the path annotator,
+using napari-threedee as a napari plugin.
+"""
+import napari
+from skimage import data
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=4).astype(float)
+
+# add two image layers to viewer
+viewer.add_image(
+    blobs,
+    name='orange plane',
+    rendering='average',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+        'position': (32, 32, 32),
+        'normal': (1, 0, 0),
+        'thickness': 10,
+    }
+)
+
+viewer.add_image(
+    blobs,
+    name='blue plane',
+    rendering='average',
+    colormap='bop blue',
+    blending='additive',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+        'position': (32, 32, 32),
+        'normal': (0, 1, 0),
+        'thickness': 10,
+    }
+)
+
+# add plugin dock widget to viewer
+viewer.window.add_plugin_dock_widget(
+    plugin_name="napari-threedee", widget_name="path annotator"
+)
+
+# run napari
+viewer.layers.selection = [viewer.layers[0]]
+viewer.axes.visible = True
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
\ No newline at end of file
diff --git a/examples/plugin/plane_point_annotator.py b/docs/examples/plugin/point_annotator_plugin.py
similarity index 53%
rename from examples/plugin/plane_point_annotator.py
rename to docs/examples/plugin/point_annotator_plugin.py
index 744b2bf..7f325ba 100644
--- a/examples/plugin/plane_point_annotator.py
+++ b/docs/examples/plugin/point_annotator_plugin.py
@@ -1,26 +1,20 @@
-import napari
-import skimage
+"""
+Plane point annotator (plugin)
+==============================
 
+An example controlling the point annotator,
+using napari-threedee as a napari plugin.
+"""
+import napari
+from skimage import data
 
+# create napari viewer
 viewer = napari.Viewer(ndisplay=3)
-blobs = skimage.data.binary_blobs(
-    length=64,
-    volume_fraction=0.1,
-    n_dim=4
-).astype(float)
-
-plane_parameters_z = {
-    'position': (32, 32, 32),
-    'normal': (1, 0, 0),
-    'thickness': 10,
-}
 
-plane_parameters_y = {
-    'position': (32, 32, 32),
-    'normal': (0, 1, 0),
-    'thickness': 10,
-}
+# generate image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=4).astype(float)
 
+# add two image layers to viewer
 viewer.add_image(
     blobs,
     name='orange plane',
@@ -29,7 +23,11 @@
     blending='translucent',
     opacity=0.5,
     depiction='plane',
-    plane=plane_parameters_z)
+    plane={
+    'position': (32, 32, 32),
+    'normal': (1, 0, 0),
+    'thickness': 10,
+})
 
 viewer.add_image(
     blobs,
@@ -39,13 +37,20 @@
     blending='additive',
     opacity=0.5,
     depiction='plane',
-    plane=plane_parameters_y)
-
-viewer.add_points([], ndim=4, face_color='cornflowerblue',  size=2)
+    plane={
+    'position': (32, 32, 32),
+    'normal': (0, 1, 0),
+    'thickness': 10,
+})
 
+# add plugin dock widget to viewer
 viewer.window.add_plugin_dock_widget(
     plugin_name="napari-threedee", widget_name="point annotator"
 )
 
-viewer.camera.angles = (60, 60, 60)
-napari.run()
+# run napari
+viewer.layers.selection = [viewer.layers[0]]
+viewer.axes.visible = True
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
\ No newline at end of file
diff --git a/examples/plugin/points_manipulator.py b/docs/examples/plugin/points_manipulator_plugin.py
similarity index 72%
rename from examples/plugin/points_manipulator.py
rename to docs/examples/plugin/points_manipulator_plugin.py
index 5ddaecd..cce1584 100644
--- a/examples/plugin/points_manipulator.py
+++ b/docs/examples/plugin/points_manipulator_plugin.py
@@ -1,8 +1,14 @@
-"""Example of using the points manipulator to re-position points.
+"""
+Points manipulator (plugin)
+===================================
+
+Example of using the points manipulator to re-position points,
+using napari-threedee as a napari plugin.
 
 Enter the point selection mode and click on a point to activate the manipulator.
 Click and drag the arms of the manipulator to move the point.
 When a point is selected, hold space to rotate the view without losing the selection.
+
 """
 
 import numpy as np
diff --git a/examples/plugin/render_plane_manipulator.py b/docs/examples/plugin/render_plane_manipulator_plugin.py
similarity index 82%
rename from examples/plugin/render_plane_manipulator.py
rename to docs/examples/plugin/render_plane_manipulator_plugin.py
index 437d4a3..bd6966e 100644
--- a/examples/plugin/render_plane_manipulator.py
+++ b/docs/examples/plugin/render_plane_manipulator_plugin.py
@@ -1,3 +1,10 @@
+"""
+Render plane manipulator (plugin)
+=========================================
+
+An example controlling the render plane manipulator,
+using napari-threedee as a napari plugin.
+"""
 import napari
 from skimage import data
 
diff --git a/docs/examples/plugin/sphere_annotator_plugin.py b/docs/examples/plugin/sphere_annotator_plugin.py
new file mode 100644
index 0000000..e9b0da1
--- /dev/null
+++ b/docs/examples/plugin/sphere_annotator_plugin.py
@@ -0,0 +1,56 @@
+"""
+Sphere annotator (plugin)
+=================================
+
+An example controlling the sphere annotator,
+using napari-threedee as a napari plugin.
+"""
+import napari
+from skimage import data
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
+
+# add two image layers to viewer
+viewer.add_image(
+    blobs,
+    name='orange plane',
+    rendering='average',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+    'position': (32, 32, 32),
+    'normal': (1, 0, 0),
+    'thickness': 10,
+})
+
+viewer.add_image(
+    blobs,
+    name='blue plane',
+    rendering='average',
+    colormap='bop blue',
+    blending='additive',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+    'position': (32, 32, 32),
+    'normal': (0, 1, 0),
+    'thickness': 10,
+})
+
+# add plugin dock widget to viewer
+viewer.window.add_plugin_dock_widget(
+    plugin_name="napari-threedee", widget_name="sphere annotator"
+)
+
+# run napari
+viewer.layers.selection = [viewer.layers[0]]
+viewer.axes.visible = True
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
\ No newline at end of file
diff --git a/docs/examples/plugin/spline_annotator_plugin.py b/docs/examples/plugin/spline_annotator_plugin.py
new file mode 100644
index 0000000..ed8658f
--- /dev/null
+++ b/docs/examples/plugin/spline_annotator_plugin.py
@@ -0,0 +1,56 @@
+"""
+Spline annotator (plugin)
+=================================
+
+An example controlling the spline annotator,
+using napari-threedee as a napari plugin.
+"""
+import napari
+from skimage import data
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
+
+# add two image layers to viewer
+viewer.add_image(
+    blobs,
+    name='orange plane',
+    rendering='average',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+    'position': (32, 32, 32),
+    'normal': (1, 0, 0),
+    'thickness': 10,
+})
+
+viewer.add_image(
+    blobs,
+    name='blue plane',
+    rendering='average',
+    colormap='bop blue',
+    blending='additive',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+    'position': (32, 32, 32),
+    'normal': (0, 1, 0),
+    'thickness': 10,
+})
+
+# add plugin dock widget to viewer
+viewer.window.add_plugin_dock_widget(
+    plugin_name="napari-threedee", widget_name="sphere annotator"
+)
+
+# run napari
+viewer.layers.selection = [viewer.layers[0]]
+viewer.axes.visible = True
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
\ No newline at end of file
diff --git a/docs/examples/plugin/surface_annotator_plugin.py b/docs/examples/plugin/surface_annotator_plugin.py
new file mode 100644
index 0000000..2245dad
--- /dev/null
+++ b/docs/examples/plugin/surface_annotator_plugin.py
@@ -0,0 +1,43 @@
+"""
+Surface annotator example (plugin)
+=================================
+
+An example controlling the surface annotator,
+using napari-threedee as a napari plugin.
+"""
+
+import napari
+from skimage import data
+
+# create napari viewer
+viewer = napari.Viewer(ndisplay=3)
+
+# generate image data
+blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
+
+# add an image layers to the viewer
+viewer.add_image(
+    blobs,
+    name='orange plane',
+    rendering='average',
+    colormap='bop orange',
+    blending='translucent',
+    opacity=0.5,
+    depiction='plane',
+    plane={
+    'position': (32, 32, 32),
+    'normal': (1, 0, 0),
+    'thickness': 10,
+})
+
+# add plugin dock widget to viewer
+viewer.window.add_plugin_dock_widget(
+    plugin_name="napari-threedee", widget_name="surface annotator"
+)
+
+# run napari
+viewer.layers.selection = [viewer.layers[0]]
+viewer.axes.visible = True
+viewer.camera.angles = (-15, 25, -30)
+viewer.camera.zoom *= 0.5
+napari.run()
\ No newline at end of file
diff --git a/docs/gallery_conf.py b/docs/gallery_conf.py
new file mode 100644
index 0000000..23bd160
--- /dev/null
+++ b/docs/gallery_conf.py
@@ -0,0 +1,53 @@
+from pathlib import Path
+import warnings
+
+from mkdocs_gallery.scrapers import figure_md_or_html, matplotlib_scraper
+from mkdocs_gallery.gen_data_model import GalleryScript
+import napari
+import qtgallery
+
+warnings.filterwarnings("ignore", category=DeprecationWarning)
+
+
+def napari_image_scraper(block, script: GalleryScript):
+    """Scrape screenshots from napari windows.
+
+    Parameters
+    ----------
+    block : tuple
+        A tuple containing the (label, content, line_number) of the block.
+
+    script : GalleryScript
+        Script being run
+
+    Returns
+    -------
+    md : str
+        The ReSTructuredText that will be rendered to HTML containing
+        the images. This is often produced by :func:`figure_md_or_html`.
+    """
+    viewer = napari.current_viewer()
+    if viewer is not None:
+        image_path = next(script.run_vars.image_path_iterator)
+        screenshot = viewer.screenshot(canvas_only=False, flash=False, path=image_path)
+        viewer.close()
+        return figure_md_or_html([image_path], script)
+    else:
+        return ""
+
+
+def _reset_napari(gallery_conf, file: Path):
+    # Close all open napari windows and reset theme
+    while napari.current_viewer() is not None:
+        napari.current_viewer().close()
+    settings = napari.settings.get_settings()
+    settings.appearance.theme = 'dark'
+    # qtgallery manages the event loop so it
+    # is not completely blocked by napari.run()
+    qtgallery.reset_qapp(gallery_conf, file)
+
+
+conf = {
+    "image_scrapers": [napari_image_scraper, matplotlib_scraper],
+    "reset_modules": [_reset_napari],
+}
diff --git a/examples/empty_surface.py b/examples/empty_surface.py
deleted file mode 100644
index 1edc18e..0000000
--- a/examples/empty_surface.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import napari
-from vispy.geometry import create_sphere
-
-sphere_mesh_data = create_sphere(radius=10)
-
-viewer = napari.Viewer(ndisplay=3)
-napari_mesh_data = (sphere_mesh_data.get_vertices(), sphere_mesh_data.get_faces())
-print(sphere_mesh_data.get_vertices().mean(axis=0))
-viewer.add_surface(napari_mesh_data)
-napari.run()
diff --git a/examples/library/point_annotator.py b/examples/library/point_annotator.py
deleted file mode 100644
index b92a4ab..0000000
--- a/examples/library/point_annotator.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from napari_threedee.annotators import PointAnnotator
-
-import napari
-from skimage import data
-
-viewer = napari.Viewer(ndisplay=3)
-blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
-
-# add a volume and render as plane
-plane_parameters = {
-    'position': (32, 32, 32),
-    'normal': (1, 0, 0),
-    'thickness': 10,
-}
-
-plane_layer = viewer.add_image(
-    blobs,
-    rendering='average',
-    name='plane',
-    colormap='bop orange',
-    blending='translucent',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters,
-)
-
-points_layer = viewer.add_points(
-    data=[],
-    size=5,
-    face_color='cornflowerblue',
-    ndim=3
-)
-
-annotator = PointAnnotator(
-    viewer=viewer,
-    image_layer=plane_layer,
-    points_layer=points_layer,
-    enabled=True,
-)
-viewer.layers.selection = [plane_layer]
-viewer.camera.set_view_direction([-1, -1, 1])
-napari.run()
diff --git a/examples/library/sphere_annotator.py b/examples/library/sphere_annotator.py
deleted file mode 100644
index 2e93785..0000000
--- a/examples/library/sphere_annotator.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from napari_threedee.annotators._qt.qt_sphere_annotator import \
-    QtSphereAnnotatorWidget
-
-import napari
-from skimage import data
-
-viewer = napari.Viewer(ndisplay=3)
-blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float)
-
-# add a volume and render as plane
-plane_parameters = {
-    'position': (32, 32, 32),
-    'normal': (1, 0, 0),
-    'thickness': 10,
-}
-
-plane_layer = viewer.add_image(
-    blobs,
-    rendering='average',
-    name='plane',
-    colormap='bop orange',
-    blending='translucent',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters,
-)
-
-annotator = QtSphereAnnotatorWidget(
-    viewer=viewer
-)
-viewer.window.add_dock_widget(annotator)
-napari.run()
diff --git a/examples/library/spline_annotator.py b/examples/library/spline_annotator.py
deleted file mode 100644
index 825505e..0000000
--- a/examples/library/spline_annotator.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import napari
-import skimage
-
-from napari_threedee.annotators._qt import QtSplineAnnotatorWidget
-
-
-viewer = napari.Viewer(ndisplay=3)
-blobs = skimage.data.binary_blobs(
-    length=64,
-    volume_fraction=0.1,
-    n_dim=4
-).astype(float)
-
-plane_parameters_z = {
-    'position': (32, 32, 32),
-    'normal': (1, 0, 0),
-    'thickness': 10,
-}
-
-plane_parameters_y = {
-    'position': (32, 32, 32),
-    'normal': (0, 1, 0),
-    'thickness': 10,
-}
-
-viewer.add_image(
-    blobs,
-    name='orange plane',
-    rendering='average',
-    colormap='bop orange',
-    blending='translucent',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters_z)
-
-viewer.add_image(
-    blobs,
-    name='blue plane',
-    rendering='average',
-    colormap='bop blue',
-    blending='additive',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters_y)
-
-spline_annotator = QtSplineAnnotatorWidget(viewer=viewer)
-viewer.window.add_dock_widget(spline_annotator)
-
-
-viewer.camera.angles = (60, 60, 60)
-napari.run()
diff --git a/examples/library/surface_annotator.py b/examples/library/surface_annotator.py
deleted file mode 100644
index 3977334..0000000
--- a/examples/library/surface_annotator.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import napari
-import skimage
-
-from napari_threedee.annotators._qt import QtSurfaceAnnotatorWidget
-
-
-viewer = napari.Viewer(ndisplay=3)
-blobs = skimage.data.binary_blobs(
-    length=64,
-    volume_fraction=0.1,
-    n_dim=3
-).astype(float)
-
-plane_parameters_z = {
-    'position': (32, 32, 32),
-    'normal': (1, 0, 0),
-    'thickness': 10,
-}
-
-plane_parameters_y = {
-    'position': (32, 32, 32),
-    'normal': (0, 1, 0),
-    'thickness': 10,
-}
-
-viewer.add_image(
-    blobs,
-    name='orange plane',
-    rendering='average',
-    colormap='bop orange',
-    blending='translucent',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters_z)
-
-viewer.add_image(
-    blobs,
-    name='blue plane',
-    rendering='average',
-    colormap='bop blue',
-    blending='additive',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters_y)
-
-spline_annotator = QtSurfaceAnnotatorWidget(viewer=viewer)
-viewer.window.add_dock_widget(spline_annotator)
-
-
-viewer.camera.angles = (60, 60, 60)
-napari.run()
diff --git a/examples/plugin/spline_annotator.py b/examples/plugin/spline_annotator.py
deleted file mode 100644
index 05facf7..0000000
--- a/examples/plugin/spline_annotator.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import napari
-import skimage
-
-
-viewer = napari.Viewer(ndisplay=3)
-blobs = skimage.data.binary_blobs(
-    length=64,
-    volume_fraction=0.1,
-    n_dim=4
-).astype(float)
-
-plane_parameters_z = {
-    'position': (32, 32, 32),
-    'normal': (1, 0, 0),
-    'thickness': 10,
-}
-
-plane_parameters_y = {
-    'position': (32, 32, 32),
-    'normal': (0, 1, 0),
-    'thickness': 10,
-}
-
-viewer.add_image(
-    blobs,
-    name='orange plane',
-    rendering='average',
-    colormap='bop orange',
-    blending='additive',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters_z)
-
-viewer.add_image(
-    blobs,
-    name='blue plane',
-    rendering='average',
-    colormap='bop blue',
-    blending='additive',
-    opacity=0.5,
-    depiction='plane',
-    plane=plane_parameters_y)
-
-
-viewer.window.add_plugin_dock_widget(
-    plugin_name="napari-threedee", widget_name="spline annotator"
-)
-
-viewer.camera.angles = (60, 60, 60)
-napari.run()
diff --git a/mkdocs.yml b/mkdocs.yml
index cc94bfb..7ad3c67 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -98,6 +98,7 @@ nav:
       - annotations/point_spec.md
       - annotations/sphere_spec.md
       - annotations/spline_spec.md
+  - 'Examples gallery': generated/gallery  # This node will automatically be named and have sub-nodes.
   - 'API':
       - API/napari_utilities.md
       - API/selection_utilities.md
@@ -137,6 +138,13 @@ plugins:
       is_video: True
       video_autoplay: True
       video_loop: True
+  - gallery:
+      conf_script: docs/gallery_conf.py
+      examples_dirs: [docs/examples]
+      gallery_dirs: [docs/generated/gallery]
+      filename_pattern: /*.py  # which scripts will be executed for the docs
+      ignore_pattern: /__init__.py  # ignore these examle files completely
+      run_stale_examples: True
 
 extra_css:
   - stylesheets/extra.css
diff --git a/setup.cfg b/setup.cfg
index 49e5dd5..3518907 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -51,12 +51,15 @@ napari.manifest =
 
 [options.extras_require]
 dev = 
-	mkdocs
+	mkdocs==1.2.4 # Pinned due to issue https://github.com/smarie/mkdocs-gallery/issues/57
+	mkdocs-gallery
 	mkdocs-material
 	mkdocstrings[python]
 	mkdocs-video
+	napari[all]
 	pytest
 	pytest-qt
+	qtgallery
 
 [options.package_data]
 napari_threedee = napari.yaml
diff --git a/src/napari_threedee/__init__.py b/src/napari_threedee/__init__.py
index 96d0391..d8f965b 100644
--- a/src/napari_threedee/__init__.py
+++ b/src/napari_threedee/__init__.py
@@ -1,3 +1,7 @@
+from . import data_models
+from . import annotators
+from . import manipulators
+
 try:
     from ._version import version as __version__
 except ImportError:
diff --git a/src/napari_threedee/_backend/__init__.py b/src/napari_threedee/_backend/__init__.py
index f0b5ff3..ce2d279 100644
--- a/src/napari_threedee/_backend/__init__.py
+++ b/src/napari_threedee/_backend/__init__.py
@@ -1,2 +1,2 @@
-from .threedee_model import ThreeDeeModel
+from .threedee_model import N3dComponent
 from .threedee_widget_base import QtThreeDeeWidgetBase
diff --git a/src/napari_threedee/_backend/threedee_model.py b/src/napari_threedee/_backend/threedee_model.py
index af84759..c9419db 100644
--- a/src/napari_threedee/_backend/threedee_model.py
+++ b/src/napari_threedee/_backend/threedee_model.py
@@ -1,7 +1,7 @@
 from abc import ABC, abstractmethod
 
 
-class ThreeDeeModel(ABC):
+class N3dComponent(ABC):
     """Base class for manipulators and annotators.
 
     By adhering to the interface defined by this class, widgets can be automatically generated for
diff --git a/src/napari_threedee/_backend/threedee_widget_base.py b/src/napari_threedee/_backend/threedee_widget_base.py
index 0dacc7a..ec2a28b 100644
--- a/src/napari_threedee/_backend/threedee_widget_base.py
+++ b/src/napari_threedee/_backend/threedee_widget_base.py
@@ -4,17 +4,17 @@
 from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout
 from napari.utils.events import Event
 
-from napari_threedee._backend.threedee_model import ThreeDeeModel
+from napari_threedee._backend.threedee_model import N3dComponent
 from ..utils.napari_utils import generate_populated_layer_selection_widget
 
 
 class QtThreeDeeWidgetBase(QWidget):
     """Base class for GUI elements in napari-threedee."""
 
-    def __init__(self, model_class: Type[ThreeDeeModel], viewer: napari.Viewer, flags=None, *args,
+    def __init__(self, model_class: Type[N3dComponent], viewer: napari.Viewer, flags=None, *args,
                  **kwargs):
         super().__init__(flags, *args, **kwargs)
-        self.model: ThreeDeeModel = model_class(viewer)
+        self.model: N3dComponent = model_class(viewer)
         self.viewer = viewer
 
         self._layer_selection_widget = generate_populated_layer_selection_widget(
diff --git a/src/napari_threedee/annotators/__init__.py b/src/napari_threedee/annotators/__init__.py
index 17122b3..b45a55a 100644
--- a/src/napari_threedee/annotators/__init__.py
+++ b/src/napari_threedee/annotators/__init__.py
@@ -1,3 +1,4 @@
-from .point_annotator import PointAnnotator
-from .spline_annotator import SplineAnnotator
-from .sphere_annotator import SphereAnnotator
+from .points import PointAnnotator
+from .paths import PathAnnotator
+from .spheres import SphereAnnotator
+from .surfaces import SurfaceAnnotator
diff --git a/src/napari_threedee/annotators/_qt/__init__.py b/src/napari_threedee/annotators/_qt/__init__.py
index 520bf2a..809b9de 100644
--- a/src/napari_threedee/annotators/_qt/__init__.py
+++ b/src/napari_threedee/annotators/_qt/__init__.py
@@ -1,3 +1,4 @@
 from .qt_point_annotator import QtPointAnnotatorWidget
-from .qt_spline_annotator import QtSplineAnnotatorWidget
-from .qt_surface_annotator import QtSurfaceAnnotatorWidget
\ No newline at end of file
+from .qt_path_annotator import QtPathAnnotatorWidget
+from .qt_sphere_annotator import QtSphereAnnotatorWidget
+from .qt_surface_annotator import QtSurfaceAnnotatorWidget
diff --git a/src/napari_threedee/annotators/_qt/qt_spline_annotator.py b/src/napari_threedee/annotators/_qt/qt_path_annotator.py
similarity index 91%
rename from src/napari_threedee/annotators/_qt/qt_spline_annotator.py
rename to src/napari_threedee/annotators/_qt/qt_path_annotator.py
index 4dcf8a7..df36191 100644
--- a/src/napari_threedee/annotators/_qt/qt_spline_annotator.py
+++ b/src/napari_threedee/annotators/_qt/qt_path_annotator.py
@@ -4,12 +4,12 @@
 
 from napari_threedee._backend.threedee_widget_base import QtThreeDeeWidgetBase
 
-from napari_threedee.annotators.spline_annotator import SplineAnnotator
+from napari_threedee.annotators.paths import PathAnnotator
 
 
-class QtSplineAnnotatorWidget(QtThreeDeeWidgetBase):
+class QtPathAnnotatorWidget(QtThreeDeeWidgetBase):
     def __init__(self, viewer: napari.Viewer):
-        super().__init__(model_class=SplineAnnotator, viewer=viewer)
+        super().__init__(model_class=PathAnnotator, viewer=viewer)
 
         self.auto_fit_checkbox = QCheckBox("automatically fit spline")
         self.auto_fit_checkbox.clicked.connect(self._on_auto_fit)
@@ -44,10 +44,10 @@ def __init__(self, viewer: napari.Viewer):
         self.model.events.active_spline_id.connect(self._update_active_spline_id)
 
     def _on_spline_fit(self):
-        # update splines from points
+        # update data from points
         self.model._update_spheres()
 
-        # draw splines
+        # draw data
         self.model._draw_splines()
 
     def _on_auto_fit(self):
diff --git a/src/napari_threedee/annotators/_qt/qt_point_annotator.py b/src/napari_threedee/annotators/_qt/qt_point_annotator.py
index 7036625..74fa517 100644
--- a/src/napari_threedee/annotators/_qt/qt_point_annotator.py
+++ b/src/napari_threedee/annotators/_qt/qt_point_annotator.py
@@ -2,7 +2,7 @@
 
 from napari_threedee._backend.threedee_widget_base import QtThreeDeeWidgetBase
 
-from napari_threedee.annotators.point_annotator import PointAnnotator
+from napari_threedee.annotators.points import PointAnnotator
 
 
 class QtPointAnnotatorWidget(QtThreeDeeWidgetBase):
diff --git a/src/napari_threedee/annotators/_qt/qt_sphere_annotator.py b/src/napari_threedee/annotators/_qt/qt_sphere_annotator.py
index 2ef7354..c6b8583 100644
--- a/src/napari_threedee/annotators/_qt/qt_sphere_annotator.py
+++ b/src/napari_threedee/annotators/_qt/qt_sphere_annotator.py
@@ -2,7 +2,7 @@
 
 from ..._backend import QtThreeDeeWidgetBase
 
-from napari_threedee.annotators.sphere_annotator import SphereAnnotator
+from napari_threedee.annotators.spheres import SphereAnnotator
 
 
 class QtSphereAnnotatorWidget(QtThreeDeeWidgetBase):
diff --git a/src/napari_threedee/annotators/_qt/qt_surface_annotator.py b/src/napari_threedee/annotators/_qt/qt_surface_annotator.py
index 0a8f57c..4c2c889 100644
--- a/src/napari_threedee/annotators/_qt/qt_surface_annotator.py
+++ b/src/napari_threedee/annotators/_qt/qt_surface_annotator.py
@@ -4,7 +4,7 @@
 
 from napari_threedee._backend.threedee_widget_base import QtThreeDeeWidgetBase
 
-from napari_threedee.annotators.surface_annotator import SurfaceAnnotator
+from napari_threedee.annotators.surfaces.annotator import SurfaceAnnotator
 
 
 
@@ -12,22 +12,36 @@ class QtSurfaceAnnotatorWidget(QtThreeDeeWidgetBase):
     def __init__(self, viewer: napari.Viewer):
         super().__init__(model_class=SurfaceAnnotator, viewer=viewer)
 
-        self.update_surface_button = QPushButton("fit surface")
-        self.update_surface_button.pressed.connect(self._draw_surface)
+        surface_layout = QHBoxLayout()
+        self.active_surface_spinbox = QSpinBox()
+        self.active_surface_spinbox.setMinimum(0)
+        self.active_surface_spinbox.setValue(self.model.active_surface_id)
+        self.active_surface_spinbox.valueChanged.connect(
+            self._on_current_surface_changed
+        )
 
-        spinbox_layout = QHBoxLayout()
-        self.active_level_index_spinbox = QSpinBox()
-        self.active_level_index_spinbox.setMinimum(0)
-        self.active_level_index_spinbox.setValue(self.model.active_spline_id)
-        self.active_level_index_spinbox.valueChanged.connect(self._on_current_filament_id_changed)
-        spinbox_layout.addWidget(QLabel("active surface level:"))
-        spinbox_layout.addWidget(self.active_level_index_spinbox)
+        surface_layout.addWidget(QLabel("surface:"))
+        surface_layout.addWidget(self.active_surface_spinbox)
 
+        level_layout = QHBoxLayout()
+        self.active_level_spinbox = QSpinBox()
+        self.active_level_spinbox.setMinimum(0)
+        self.active_level_spinbox.setValue(self.model.active_level_id)
+        self.active_level_spinbox.valueChanged.connect(
+            self._on_current_level_changed
+        )
+
+        level_layout.addWidget(QLabel("level:"))
+        level_layout.addWidget(self.active_level_spinbox)
 
         # add the fitting UI to the group_box
+        self.update_surface_button = QPushButton("fit surface")
+        self.update_surface_button.pressed.connect(self._draw_surface)
+
         self.fitting_group_box = QGroupBox("fit surface")
         group_box_layout = QVBoxLayout()
-        group_box_layout.addLayout(spinbox_layout)
+        group_box_layout.addLayout(surface_layout)
+        group_box_layout.addLayout(level_layout)
         group_box_layout.addWidget(self.update_surface_button)
         self.fitting_group_box.setLayout(group_box_layout)
         self.fitting_group_box.setVisible(False)
@@ -36,15 +50,18 @@ def __init__(self, viewer: napari.Viewer):
         self.layout().addWidget(self.fitting_group_box)
 
         # connect events to sync changes in the model to the UI
-        self.model.events.active_spline_id.connect(self._update_active_level_id)
+        self.model.events.active_surface_id.connect(self._update_active_surface)
+        self.model.events.active_level_id.connect(self._update_active_level)
 
     def _draw_surface(self):
-        self.model._update_splines()
         self.model._draw_splines()
         self.model._draw_surface()
 
-    def _on_current_filament_id_changed(self, event: Event):
-        self.model.active_spline_id = self.active_level_index_spinbox.value()
+    def _on_current_surface_changed(self, event: Event):
+        self.model.active_surface_id = self.active_surface_spinbox.value()
+
+    def _on_current_level_changed(self, event: Event):
+        self.model.active_spline_id = self.active_level_spinbox.value()
 
     def on_activate_button_click(self, event: Event):
         """Callback function when the activate button is clicked"""
@@ -57,8 +74,12 @@ def on_activate_button_click(self, event: Event):
             self.activate_button.setText('activate')
             self.fitting_group_box.setVisible(False)
 
-    def _update_active_level_id(self):
-        """Update the spline id spinbox when the model has changed value"""
-        self.active_level_index_spinbox.setValue(self.model.active_spline_id)
+    def _update_active_surface(self):
+        """Update surface spinbox when surface id has changed on the model."""
+        self.active_surface_spinbox.setValue(self.model.active_surface_id)
+
+    def _update_active_level(self):
+        """Update the spline id spinbox when the model has changed value."""
+        self.active_level_spinbox.setValue(self.model.active_level_id)
 
 
diff --git a/src/napari_threedee/annotators/base.py b/src/napari_threedee/annotators/base.py
new file mode 100644
index 0000000..707bf50
--- /dev/null
+++ b/src/napari_threedee/annotators/base.py
@@ -0,0 +1,30 @@
+import os
+from abc import abstractmethod
+
+import napari
+import zarr
+from pydantic import BaseModel
+
+
+class N3dDataModel(BaseModel):
+    class Config:
+        allow_mutation = False
+        arbitrary_types_allowed = True
+
+    @classmethod
+    @abstractmethod
+    def from_layer(cls, layer: napari.layers.Layer):
+        pass
+
+    @abstractmethod
+    def as_layer(self) -> napari.layers.Layer:
+        pass
+
+    @classmethod
+    @abstractmethod
+    def from_n3d_zarr(self, path: os.PathLike):
+        pass
+
+    @abstractmethod
+    def to_n3d_zarr(self, path: os.PathLike) -> None:
+        pass
diff --git a/src/napari_threedee/annotators/io/constants.py b/src/napari_threedee/annotators/constants.py
similarity index 100%
rename from src/napari_threedee/annotators/io/constants.py
rename to src/napari_threedee/annotators/constants.py
diff --git a/src/napari_threedee/annotators/io/__init__.py b/src/napari_threedee/annotators/io/__init__.py
deleted file mode 100644
index a872787..0000000
--- a/src/napari_threedee/annotators/io/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .constants import ANNOTATION_TYPE_KEY, N3D_METADATA_KEY
diff --git a/src/napari_threedee/annotators/io/_napari.py b/src/napari_threedee/annotators/io/_napari.py
deleted file mode 100644
index eaf7d6d..0000000
--- a/src/napari_threedee/annotators/io/_napari.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import os
-from typing import List, Callable, Optional
-
-import numpy as np
-import zarr
-from napari.layers import Layer
-from napari.types import LayerDataTuple
-
-from . import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
-from ..spline_annotator import SplineAnnotator
-from ..sphere_annotator import SphereAnnotator
-from ..point_annotator import PointAnnotator
-import napari_threedee.annotators.io.sphere as sphere_io
-import napari_threedee.annotators.io.spline as spline_io
-import napari_threedee.annotators.io.point as point_io
-
-IMPLEMENTATIONS: dict = {
-    SphereAnnotator.ANNOTATION_TYPE: {
-        'reader': sphere_io.n3d_zarr_to_layer_data_tuple,
-        'writer': sphere_io.layer_to_n3d_zarr,
-        'layer_validator': sphere_io.validate_layer,
-        'zarr_validator': sphere_io.validate_zarr,
-    },
-    SplineAnnotator.ANNOTATION_TYPE: {
-        'reader': spline_io.n3d_zarr_to_layer_data_tuple,
-        'writer': spline_io.layer_to_n3d_zarr,
-        'layer_validator': spline_io.validate_layer,
-        'zarr_validator': spline_io.validate_zarr,
-    },
-    PointAnnotator.ANNOTATION_TYPE: {
-        'reader': point_io.n3d_zarr_to_layer_data_tuple,
-        'writer': point_io.layer_to_n3d_zarr,
-        'layer_validator': point_io.validate_layer,
-        'zarr_validator': point_io.validate_zarr,
-    }
-}
-
-
-def write_n3d_zarr(
-    path: str, data: np.ndarray, attributes: dict
-) -> List[str]:
-    """Write an n3d zarr file from napari layer data."""
-    layer = Layer.create(data=data, meta=attributes, layer_type='points')
-    annotation_type = layer.metadata[N3D_METADATA_KEY][ANNOTATION_TYPE_KEY]
-    to_zarr = IMPLEMENTATIONS[annotation_type]['writer']
-    to_zarr(layer, path)
-    return [path]
-
-
-def read_n3d_zarr(path: os.PathLike) -> Optional[Callable]:
-    """Return a function for reading .n3d files if possible."""
-    return _read_n3d_zarr if str(path).endswith('.n3d') else None
-
-
-def _read_n3d_zarr(path: os.PathLike) -> List[LayerDataTuple]:
-    """Dispatches to specific readers based on annotation type."""
-    n3d_zarr = zarr.open_array(path)
-    annotation_type = n3d_zarr.attrs[ANNOTATION_TYPE_KEY]
-    to_layer_data_tuple = IMPLEMENTATIONS[annotation_type]['reader']
-    return [to_layer_data_tuple(n3d_zarr)]
diff --git a/src/napari_threedee/annotators/io/point.py b/src/napari_threedee/annotators/io/point.py
deleted file mode 100644
index 71a687f..0000000
--- a/src/napari_threedee/annotators/io/point.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import os
-
-import napari
-import numpy as np
-import zarr
-from napari.types import LayerDataTuple
-
-from napari_threedee.annotators import PointAnnotator
-from napari_threedee.annotators.io import ANNOTATION_TYPE_KEY, N3D_METADATA_KEY
-
-
-def validate_layer(layer: napari.layers.Points):
-    """Ensure a sphere layer matches the specification."""
-    if N3D_METADATA_KEY not in layer.metadata:
-        raise ValueError(f"{N3D_METADATA_KEY} not found")
-    n3d_metadata = layer.metadata[N3D_METADATA_KEY]
-    if n3d_metadata[ANNOTATION_TYPE_KEY] != PointAnnotator.ANNOTATION_TYPE:
-        raise ValueError("Cannot read as n3d points layer.")
-
-
-def validate_zarr(n3d_zarr: zarr.Array):
-    """Ensure an n3d zarr array contains data for n3d points layer."""
-    if ANNOTATION_TYPE_KEY not in n3d_zarr.attrs:
-        raise ValueError("cannot read as n3d sphere.")
-    if n3d_zarr.attrs[ANNOTATION_TYPE_KEY] != PointAnnotator.ANNOTATION_TYPE:
-        raise ValueError("cannot read as n3d sphere.")
-
-
-def layer_to_n3d_zarr(layer: napari.layers.Points, path: os.PathLike) -> zarr.Array:
-    """Convert an n3d sphere points layer into an n3d zarr array."""
-    validate_layer(layer)
-    n3d_zarr = zarr.open_array(
-        store=path,
-        shape=layer.data.shape,
-        dtype=layer.data.dtype,
-        mode="w"
-    )
-    n3d_zarr[...] = layer.data
-    n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = PointAnnotator.ANNOTATION_TYPE
-    return n3d_zarr
-
-
-def n3d_zarr_to_layer_data_tuple(n3d_zarr: zarr.Array) -> LayerDataTuple:
-    """Convert an n3d zarr array into an n3d sphere points layer data tuple."""
-    validate_zarr(n3d_zarr)
-    layer_kwargs = {
-        "metadata": {N3D_METADATA_KEY: {
-            ANNOTATION_TYPE_KEY: PointAnnotator.ANNOTATION_TYPE,
-        }},
-    }
-    return (np.array(n3d_zarr), layer_kwargs, "points")
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/io/sphere.py b/src/napari_threedee/annotators/io/sphere.py
deleted file mode 100644
index e63d7e4..0000000
--- a/src/napari_threedee/annotators/io/sphere.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import os
-
-import napari
-import numpy as np
-import zarr
-from napari.types import LayerDataTuple
-
-from napari_threedee.annotators import SphereAnnotator
-from napari_threedee.annotators.io import ANNOTATION_TYPE_KEY, N3D_METADATA_KEY
-
-
-def validate_layer(layer: napari.layers.Points):
-    """Ensure a sphere layer matches the specification."""
-    for feature in (SphereAnnotator.SPHERE_ID_FEATURES_KEY,
-                    SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY):
-        if feature not in layer.features:
-            raise ValueError(f"{feature} not in layer features.")
-
-
-def validate_zarr(n3d_zarr: zarr.Array):
-    """Ensure an n3d zarr array contains data for n3d sphere points layer."""
-    if ANNOTATION_TYPE_KEY not in n3d_zarr.attrs:
-        raise ValueError("cannot read as n3d sphere.")
-    if n3d_zarr.attrs[ANNOTATION_TYPE_KEY] != SphereAnnotator.ANNOTATION_TYPE:
-        raise ValueError("cannot read as n3d sphere.")
-
-
-def layer_to_n3d_zarr(layer: napari.layers.Points, path: os.PathLike) -> zarr.Array:
-    """Convert an n3d sphere points layer into an n3d zarr array."""
-    validate_layer(layer)
-    n3d_zarr = zarr.open_array(
-        store=path,
-        shape=layer.data.shape,
-        dtype=layer.data.dtype,
-        mode="w"
-    )
-    n3d_zarr[...] = layer.data
-    n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = SphereAnnotator.ANNOTATION_TYPE
-
-    # get sphere id and radius from layer features
-    id_key = SphereAnnotator.SPHERE_ID_FEATURES_KEY
-    n3d_zarr.attrs[id_key] = list(layer.features[id_key])
-    radius_key = SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY
-    n3d_zarr.attrs[radius_key] = list(layer.features[radius_key])
-    return n3d_zarr
-
-
-def n3d_zarr_to_layer_data_tuple(n3d_zarr: zarr.Array) -> LayerDataTuple:
-    """Convert an n3d zarr array into an n3d sphere points layer data tuple."""
-    validate_zarr(n3d_zarr)
-    id = n3d_zarr.attrs[SphereAnnotator.SPHERE_ID_FEATURES_KEY]
-    radii = n3d_zarr.attrs[SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY]
-    layer_kwargs = {
-        "features": {
-            SphereAnnotator.SPHERE_ID_FEATURES_KEY: id,
-            SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY: radii,
-        },
-        "metadata": {N3D_METADATA_KEY: {
-            ANNOTATION_TYPE_KEY: SphereAnnotator.ANNOTATION_TYPE,
-        }},
-    }
-    return (np.array(n3d_zarr), layer_kwargs, "points")
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/io/spline.py b/src/napari_threedee/annotators/io/spline.py
deleted file mode 100644
index a4dfddd..0000000
--- a/src/napari_threedee/annotators/io/spline.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import os
-
-import napari
-from napari.types import LayerDataTuple
-import numpy as np
-import zarr
-
-from .constants import ANNOTATION_TYPE_KEY, N3D_METADATA_KEY
-from ..spline_annotator import SplineAnnotator
-
-
-def validate_layer(layer: napari.layers.Points):
-    """Ensure a spline layer matches the spline layer specification."""
-    if N3D_METADATA_KEY not in layer.metadata:
-        raise ValueError(f"{N3D_METADATA_KEY} not in layer metadata.")
-    n3d_metadata = layer.metadata[N3D_METADATA_KEY]
-    if n3d_metadata[ANNOTATION_TYPE_KEY] != SplineAnnotator.ANNOTATION_TYPE:
-        error = f"Annotation type is not {SplineAnnotator.ANNOTATION_TYPE}"
-        raise ValueError(error)
-    elif SplineAnnotator.SPLINE_ID_FEATURES_KEY not in layer.features:
-        error = f"{SplineAnnotator.SPLINE_ID_FEATURES_KEY} not in layer features."
-        raise ValueError(error)
-
-
-def validate_zarr(n3d_zarr: zarr.Array):
-    """Ensure an n3d zarr array can be converted to a n3d spline points
-    layer."""
-    if ANNOTATION_TYPE_KEY not in n3d_zarr.attrs:
-        raise ValueError("cannot read as n3d spline.")
-    elif n3d_zarr.attrs[ANNOTATION_TYPE_KEY] != SplineAnnotator.ANNOTATION_TYPE:
-        raise ValueError("cannot read as n3d spline.")
-    elif SplineAnnotator.SPLINE_ID_FEATURES_KEY not in n3d_zarr.attrs:
-        raise ValueError(f"{SplineAnnotator.SPLINE_ID_FEATURES_KEY} not found.")
-
-
-def layer_to_n3d_zarr(layer: napari.layers.Points,
-                      path: os.PathLike) -> zarr.Array:
-    """Convert a napari points layer into an n3d zarr array."""
-    validate_layer(layer)
-    n3d_zarr = zarr.open_array(
-        store=path,
-        shape=layer.data.shape,
-        dtype=layer.data.dtype,
-        mode="w"
-    )
-    n3d_zarr[...] = layer.data
-    n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = SplineAnnotator.ANNOTATION_TYPE
-
-    spline_id_key = SplineAnnotator.SPLINE_ID_FEATURES_KEY
-    n3d_zarr.attrs[spline_id_key] = list(layer.features[spline_id_key])
-    return n3d_zarr
-
-
-def n3d_zarr_to_layer_data_tuple(n3d_zarr: zarr.Array) -> LayerDataTuple:
-    """Convert an n3d zarr array to an n3d spline points layer data tuple."""
-    validate_zarr(n3d_zarr)
-    spline_id = n3d_zarr.attrs[SplineAnnotator.SPLINE_ID_FEATURES_KEY]
-    layer_kwargs = {
-        "features": {SplineAnnotator.SPLINE_ID_FEATURES_KEY: spline_id},
-        "metadata": {
-            N3D_METADATA_KEY: {
-                ANNOTATION_TYPE_KEY: SplineAnnotator.ANNOTATION_TYPE,
-            }
-        }
-    }
-    return (np.array(n3d_zarr), layer_kwargs, "points")
diff --git a/src/napari_threedee/annotators/paths/__init__.py b/src/napari_threedee/annotators/paths/__init__.py
new file mode 100644
index 0000000..9ffe520
--- /dev/null
+++ b/src/napari_threedee/annotators/paths/__init__.py
@@ -0,0 +1,1 @@
+from .annotator import PathAnnotator
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/paths/annotator.py b/src/napari_threedee/annotators/paths/annotator.py
new file mode 100644
index 0000000..9f5fbbb
--- /dev/null
+++ b/src/napari_threedee/annotators/paths/annotator.py
@@ -0,0 +1,169 @@
+import napari
+from napari.layers import Image, Points, Shapes
+from napari.utils.events.event import EmitterGroup, Event
+import numpy as np
+from typing import Optional, Dict
+
+from napari_threedee._backend.threedee_model import N3dComponent
+from napari_threedee.annotators.paths.constants import (
+    PATH_ANNOTATION_TYPE_KEY,
+    PATH_ID_FEATURES_KEY,
+    PATH_COLOR_FEATURES_KEY,
+    COLOR_CYCLE,
+)
+from napari_threedee.utils.mouse_callbacks import add_point_on_plane
+from napari_threedee.utils.napari_utils import add_mouse_callback_safe, \
+    remove_mouse_callback_safe
+from napari_threedee.annotators.constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+
+
+class PathAnnotator(N3dComponent):
+    def __init__(
+        self,
+        viewer: napari.Viewer,
+        image_layer: Optional[Image] = None,
+        points_layer: Optional[Points] = None,
+        enabled: bool = False
+    ):
+        self.events = EmitterGroup(
+            source=self,
+            active_spline_id=Event,
+            paths_updated=Event
+        )
+
+        self.viewer = viewer
+        self._active_spline_id: int = 0
+
+        self.image_layer = image_layer
+        self.points_layer = points_layer
+        self.shapes_layer = None
+
+        self.auto_fit_spline = True
+
+        if image_layer is not None:
+            self.set_layers(self.image_layer)
+
+        self.enabled = enabled
+
+    @property
+    def active_spline_id(self):
+        return self._active_spline_id
+
+    @active_spline_id.setter
+    def active_spline_id(self, id: int):
+        self._active_spline_id = np.clip(id, 0, None)
+        if self.points_layer is not None:
+            self.points_layer.selected_data = {}
+            self.points_layer.current_properties = {
+                PATH_ID_FEATURES_KEY: self.active_spline_id
+            }
+        self.events.active_spline_id()
+
+    def next_spline(self, event=None):
+        self.active_spline_id += 1
+
+    def previous_spline(self, event=None):
+        self.active_spline_id -= 1
+
+    def _mouse_callback(self, viewer, event):
+        if (self.image_layer is None) or (self.points_layer is None):
+            return
+        add_point_on_plane(
+            viewer=viewer,
+            event=event,
+            points_layer=self.points_layer,
+            image_layer=self.image_layer
+        )
+
+    def _create_points_layer(self) -> Optional[Points]:
+        layer = Points(
+            data=[0] * self.image_layer.data.ndim,
+            ndim=self.image_layer.data.ndim,
+            name="n3d paths (control points)",
+            size=3,
+            features={PATH_ID_FEATURES_KEY: [0]},
+            face_color=PATH_ID_FEATURES_KEY,
+            face_color_cycle=COLOR_CYCLE,
+            metadata={
+                N3D_METADATA_KEY: {
+                    ANNOTATION_TYPE_KEY: PATH_ANNOTATION_TYPE_KEY,
+                }
+            }
+        )
+        layer.selected_data = {0}
+        layer.remove_selected()
+        self.active_spline_id = self.active_spline_id
+        return layer
+
+    def _create_shapes_layer(self) -> Shapes:
+        return Shapes(
+            ndim=self.image_layer.data.ndim,
+            name="n3d paths (smooth fit)",
+            edge_color="green"
+        )
+
+    def set_layers(self, image_layer: napari.layers.Image):
+        self.image_layer = image_layer
+        if self.image_layer is not None:
+            if self.points_layer is None:
+                self.points_layer = self._create_points_layer()
+            if self.points_layer not in self.viewer.layers:
+                self.viewer.add_layer(self.points_layer)
+            if self.shapes_layer is None:
+                self.shapes_layer = self._create_shapes_layer()
+            if self.shapes_layer not in self.viewer.layers:
+                self.viewer.add_layer(self.shapes_layer)
+            self._draw_paths()
+
+    def _on_enable(self):
+        if self.points_layer is not None:
+            add_mouse_callback_safe(
+                self.viewer.mouse_drag_callbacks, self._mouse_callback
+            )
+            self.points_layer.events.data.connect(self._on_point_data_changed)
+            self.viewer.bind_key('n', self.next_spline, overwrite=True)
+            self.viewer.layers.selection.active = self.image_layer
+
+    def _on_disable(self):
+        remove_mouse_callback_safe(
+            self.viewer.mouse_drag_callbacks, self._mouse_callback
+        )
+        if self.points_layer is not None:
+            self.points_layer.events.data.disconnect(
+                self._on_point_data_changed
+            )
+        self.viewer.bind_key('n', None, overwrite=True)
+
+    def _on_point_data_changed(self, event=None):
+        if self.auto_fit_spline is True:
+            self._draw_paths()
+        self.events.paths_updated()
+
+    def _get_path_colors(self) -> Dict[int, np.ndarray]:
+        self.points_layer.features[PATH_COLOR_FEATURES_KEY] = \
+            list(self.points_layer.face_color)
+        grouped_points_features = self.points_layer.features.groupby(
+            PATH_ID_FEATURES_KEY
+        )
+        spline_colors = dict()
+        for spline_id, spline_df in grouped_points_features:
+            spline_colors[spline_id] = spline_df[PATH_COLOR_FEATURES_KEY].iloc[0]
+        return spline_colors
+
+    def _clear_shapes_layer(self):
+        """Delete all shapes in the shapes layer."""
+        if self.shapes_layer is None:
+            return
+        n_shapes = len(self.shapes_layer.data)
+        self.shapes_layer.selected_data = set(np.arange(n_shapes))
+        self.shapes_layer.remove_selected()
+
+    def _draw_paths(self):
+        from napari_threedee.data_models import N3dPaths
+        paths = N3dPaths.from_layer(self.points_layer)
+        spline_colors = self._get_path_colors()
+        self._clear_shapes_layer()
+        for spline_id, path in enumerate(paths):
+            if len(path) >= 2:
+                points = path.sample(n=2000)
+                self.shapes_layer.add_paths(points, edge_color=spline_colors[spline_id])
diff --git a/src/napari_threedee/annotators/paths/constants.py b/src/napari_threedee/annotators/paths/constants.py
new file mode 100644
index 0000000..11e5d63
--- /dev/null
+++ b/src/napari_threedee/annotators/paths/constants.py
@@ -0,0 +1,16 @@
+from ..constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+PATH_ANNOTATION_TYPE_KEY = "path"
+COLOR_CYCLE = [
+    '#1f77b4',
+    '#ff7f0e',
+    '#2ca02c',
+    '#d62728',
+    '#9467bd',
+    '#8c564b',
+    '#e377c2',
+    '#7f7f7f',
+    '#bcbd22',
+    '#17becf',
+]
+PATH_ID_FEATURES_KEY = "path_id"
+PATH_COLOR_FEATURES_KEY = "spline_color"
diff --git a/src/napari_threedee/annotators/paths/validation.py b/src/napari_threedee/annotators/paths/validation.py
new file mode 100644
index 0000000..21a8d53
--- /dev/null
+++ b/src/napari_threedee/annotators/paths/validation.py
@@ -0,0 +1,28 @@
+import napari
+import zarr
+
+from napari_threedee.annotators.paths.constants import N3D_METADATA_KEY, \
+    ANNOTATION_TYPE_KEY, PATH_ANNOTATION_TYPE_KEY, PATH_ID_FEATURES_KEY
+
+
+def validate_layer(layer: napari.layers.Points):
+    """Ensure a spline layer matches the spline layer specification."""
+    if N3D_METADATA_KEY not in layer.metadata:
+        raise ValueError(f"{N3D_METADATA_KEY} not in layer metadata.")
+    n3d_metadata = layer.metadata[N3D_METADATA_KEY]
+    if n3d_metadata[ANNOTATION_TYPE_KEY] != PATH_ANNOTATION_TYPE_KEY:
+        error = f"Annotation type is not {PATH_ANNOTATION_TYPE_KEY}"
+        raise ValueError(error)
+    elif PATH_ID_FEATURES_KEY not in layer.features:
+        error = f"{PATH_ID_FEATURES_KEY} not in layer features."
+        raise ValueError(error)
+
+
+def validate_n3d_zarr(n3d_zarr: zarr.Array):
+    """Ensure an n3d zarr array can be converted to a n3d spline points layer."""
+    if ANNOTATION_TYPE_KEY not in n3d_zarr.attrs:
+        raise ValueError("cannot read as n3d paths.")
+    elif n3d_zarr.attrs[ANNOTATION_TYPE_KEY] != PATH_ANNOTATION_TYPE_KEY:
+        raise ValueError("cannot read as n3d paths.")
+    elif PATH_ID_FEATURES_KEY not in n3d_zarr.attrs:
+        raise ValueError(f"{PATH_ID_FEATURES_KEY} not found.")
diff --git a/src/napari_threedee/annotators/point_annotator.py b/src/napari_threedee/annotators/point_annotator.py
deleted file mode 100644
index 06a4a38..0000000
--- a/src/napari_threedee/annotators/point_annotator.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from typing import Optional
-
-import napari
-from napari.layers import Image, Points
-
-from napari_threedee._backend.threedee_model import ThreeDeeModel
-from ..mouse_callbacks import add_point_on_plane
-from napari_threedee.utils.napari_utils import add_mouse_callback_safe, remove_mouse_callback_safe
-
-
-class PointAnnotator(ThreeDeeModel):
-    ANNOTATION_TYPE: str = "point"
-
-    def __init__(
-            self,
-            viewer: napari.Viewer,
-            image_layer: Optional[Image] = None,
-            points_layer: Optional[Points] = None,
-            enabled: bool = False
-    ):
-        self.viewer = viewer
-        self.image_layer = image_layer
-        self.points_layer = points_layer
-        if points_layer is None and image_layer is not None:
-            self.points_layer = Points(data=[], ndim=image_layer.data.ndim)
-            self.viewer.add_layer(self.points_layer)
-        self.enabled = enabled
-
-    def _mouse_callback(self, viewer, event):
-        if (self.image_layer is None) or (self.points_layer is None):
-            return
-        add_point_on_plane(
-            viewer=viewer,
-            event=event,
-            points_layer=self.points_layer,
-            plane_layer=self.image_layer
-        )
-
-    def set_layers(
-            self,
-            image_layer: napari.layers.Image,
-            points_layer: napari.layers.Points
-    ):
-        self.image_layer = image_layer
-        self.points_layer = points_layer
-
-    def _on_enable(self):
-        add_mouse_callback_safe(
-            self.viewer.mouse_drag_callbacks, self._mouse_callback
-        )
-
-    def _on_disable(self):
-        remove_mouse_callback_safe(
-            self.viewer.mouse_drag_callbacks, self._mouse_callback
-        )
diff --git a/src/napari_threedee/annotators/points/__init__.py b/src/napari_threedee/annotators/points/__init__.py
new file mode 100644
index 0000000..bb4ba3e
--- /dev/null
+++ b/src/napari_threedee/annotators/points/__init__.py
@@ -0,0 +1,1 @@
+from .annotator import PointAnnotator
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/points/annotator.py b/src/napari_threedee/annotators/points/annotator.py
new file mode 100644
index 0000000..bc1e492
--- /dev/null
+++ b/src/napari_threedee/annotators/points/annotator.py
@@ -0,0 +1,60 @@
+from typing import Optional
+
+import napari.layers
+import napari.types
+import numpy as np
+
+from napari_threedee._backend.threedee_model import N3dComponent
+from napari_threedee.annotators.points.validation import validate_layer
+from napari_threedee.utils.mouse_callbacks import add_point_on_plane
+from napari_threedee.utils.napari_utils import add_mouse_callback_safe, \
+    remove_mouse_callback_safe
+
+
+class PointAnnotator(N3dComponent):
+    def __init__(
+        self,
+        viewer: napari.Viewer,
+        image_layer: Optional[napari.layers.Image] = None,
+        points_layer: Optional[napari.layers.Points] = None,
+        enabled: bool = False
+    ):
+        self.viewer = viewer
+        self.image_layer = image_layer
+        if points_layer is None:
+            points_layer = self._create_points_layer()
+        self.points_layer = points_layer
+        self.enabled = enabled
+
+    def _mouse_callback(self, viewer, event):
+        if (self.image_layer is None) or (self.points_layer is None):
+            return
+        add_point_on_plane(
+            viewer=viewer,
+            event=event,
+            points_layer=self.points_layer,
+            image_layer=self.image_layer
+        )
+
+    def _create_points_layer(self) -> napari.layers.Points:
+        from napari_threedee.data_models import N3dPoints
+        ndim = self.image_layer.ndim if self.image_layer is not None else 3
+        return N3dPoints(data=np.empty(shape=(0, ndim))).as_layer()
+
+    def set_layers(
+        self,
+        image_layer: napari.layers.Image,
+        points_layer: napari.layers.Points
+    ):
+        self.image_layer = image_layer
+        self.points_layer = points_layer
+
+    def _on_enable(self):
+        add_mouse_callback_safe(
+            self.viewer.mouse_drag_callbacks, self._mouse_callback
+        )
+
+    def _on_disable(self):
+        remove_mouse_callback_safe(
+            self.viewer.mouse_drag_callbacks, self._mouse_callback
+        )
diff --git a/src/napari_threedee/annotators/points/constants.py b/src/napari_threedee/annotators/points/constants.py
new file mode 100644
index 0000000..0cdc1dd
--- /dev/null
+++ b/src/napari_threedee/annotators/points/constants.py
@@ -0,0 +1,3 @@
+from ..constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+
+POINT_ANNOTATION_TYPE_KEY = "points"
diff --git a/src/napari_threedee/annotators/points/validation.py b/src/napari_threedee/annotators/points/validation.py
new file mode 100644
index 0000000..4948ae7
--- /dev/null
+++ b/src/napari_threedee/annotators/points/validation.py
@@ -0,0 +1,22 @@
+import napari.layers
+import zarr
+
+from napari_threedee.annotators.constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+from napari_threedee.annotators.points.constants import POINT_ANNOTATION_TYPE_KEY
+
+
+def validate_layer(layer: napari.layers.Points):
+    """Ensure a napari points layer matches the n3d layer specification."""
+    if N3D_METADATA_KEY not in layer.metadata:
+        raise ValueError(f"{N3D_METADATA_KEY} not found")
+    n3d_metadata = layer.metadata[N3D_METADATA_KEY]
+    if n3d_metadata[ANNOTATION_TYPE_KEY] != POINT_ANNOTATION_TYPE_KEY:
+        raise ValueError("Cannot read as n3d points layer.")
+
+
+def validate_n3d_zarr(n3d_zarr: zarr.Array):
+    """Ensure an n3d zarr array contains data for n3d points layer."""
+    if ANNOTATION_TYPE_KEY not in n3d_zarr.attrs:
+        raise ValueError("cannot read as n3d points.")
+    if n3d_zarr.attrs[ANNOTATION_TYPE_KEY] != POINT_ANNOTATION_TYPE_KEY:
+        raise ValueError("cannot read as n3d points.")
diff --git a/src/napari_threedee/annotators/spheres/__init__.py b/src/napari_threedee/annotators/spheres/__init__.py
new file mode 100644
index 0000000..45d7cfb
--- /dev/null
+++ b/src/napari_threedee/annotators/spheres/__init__.py
@@ -0,0 +1,2 @@
+from .annotator import SphereAnnotator, SphereAnnotatorMode
+from .constants import SPHERE_ID_FEATURES_KEY, SPHERE_RADIUS_FEATURES_KEY
diff --git a/src/napari_threedee/annotators/sphere_annotator.py b/src/napari_threedee/annotators/spheres/annotator.py
similarity index 65%
rename from src/napari_threedee/annotators/sphere_annotator.py
rename to src/napari_threedee/annotators/spheres/annotator.py
index 2a13773..59cf32c 100644
--- a/src/napari_threedee/annotators/sphere_annotator.py
+++ b/src/napari_threedee/annotators/spheres/annotator.py
@@ -7,13 +7,14 @@
 from napari.layers import Image, Points, Surface
 from napari.utils.events import EmitterGroup, Event
 from napari.layers.utils.layer_utils import features_to_pandas_dataframe
-from vispy.geometry import create_sphere
 
-from .._backend import ThreeDeeModel
-from ..mouse_callbacks import add_point_on_plane
-from ..utils.napari_utils import add_mouse_callback_safe, \
+from napari_threedee._backend import N3dComponent
+from napari_threedee.annotators.spheres.constants import SPHERE_ID_FEATURES_KEY, \
+    SPHERE_RADIUS_FEATURES_KEY, SPHERE_MESH_METADATA_KEY
+from napari_threedee.utils.mouse_callbacks import add_point_on_plane
+from napari_threedee.utils.napari_utils import add_mouse_callback_safe, \
     remove_mouse_callback_safe
-from .io import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+from napari_threedee.annotators.constants import N3D_METADATA_KEY
 
 
 class SphereAnnotatorMode(Enum):
@@ -21,35 +22,12 @@ class SphereAnnotatorMode(Enum):
     EDIT = auto()
 
 
-class SphereAnnotator(ThreeDeeModel):
-    COLOR_CYCLE = [
-        '#1f77b4',
-        '#ff7f0e',
-        '#2ca02c',
-        '#d62728',
-        '#9467bd',
-        '#8c564b',
-        '#e377c2',
-        '#7f7f7f',
-        '#bcbd22',
-        '#17becf',
-    ]
-    ANNOTATION_TYPE: str = "sphere"
-
-    # column names in points layer features
-    SPHERE_ID_FEATURES_KEY: str = "sphere_id"
-    SPHERE_RADIUS_FEATURES_KEY: str = "radius"
-
-    # metadata
-    SPHERE_MESH_METADATA_KEY: str = "mesh_data"
-
-    # parameter defaults
-    DEFAULT_SPHERE_RADIUS = 5
-
+class SphereAnnotator(N3dComponent):
     def __init__(
         self,
         viewer: napari.Viewer,
         image_layer: Optional[Image] = None,
+        points_layer: Optional[Points] = None,
         enabled: bool = False
     ):
         self.events = EmitterGroup(
@@ -59,8 +37,10 @@ def __init__(
 
         self.viewer = viewer
         self.image_layer = image_layer
-        self.points_layer = None
+        self.points_layer = points_layer
         self.surface_layer = None
+        if self.points_layer is not None:
+            self._update_spheres()
         self.enabled = enabled
         self.mode = SphereAnnotatorMode.ADD
 
@@ -71,7 +51,7 @@ def __init__(
     def active_sphere_id(self) -> Union[int, None]:
         if self.points_layer is None:
             return None
-        elif self.points_layer.selected_data != {}:
+        elif list(self.points_layer.selected_data) != []:
             return int(list(self.points_layer.selected_data)[0])
         else:
             return None
@@ -86,7 +66,8 @@ def active_sphere_radius(self) -> Union[float, None]:
         if len(df) == 0:
             return None
         else:
-            return float(df[self.SPHERE_RADIUS_FEATURES_KEY].iloc[self._active_sphere_index])
+            return float(
+                df[SPHERE_RADIUS_FEATURES_KEY].iloc[self._active_sphere_index])
 
     @property
     def _active_sphere_index(self) -> Union[int, None]:
@@ -94,7 +75,7 @@ def _active_sphere_index(self) -> Union[int, None]:
         if self.active_sphere_id is None:
             return None
         df = features_to_pandas_dataframe(self.points_layer.features)
-        idx = df[self.SPHERE_ID_FEATURES_KEY] == self.active_sphere_id
+        idx = df[SPHERE_ID_FEATURES_KEY] == self.active_sphere_id
         df = df.loc[idx, :]
         return df.index.values[0]
 
@@ -109,7 +90,7 @@ def mode(self, value: SphereAnnotatorMode):
             if self.points_layer is None:
                 sphere_ids = []
             else:
-                sphere_ids = self.points_layer.features[self.SPHERE_ID_FEATURES_KEY]
+                sphere_ids = self.points_layer.features[SPHERE_ID_FEATURES_KEY]
                 self.points_layer.selected_data = {}
             if len(sphere_ids) == 0:
                 new_sphere_id = 1
@@ -117,21 +98,20 @@ def mode(self, value: SphereAnnotatorMode):
                 new_sphere_id = np.max(sphere_ids) + 1
             self._update_current_properties(sphere_id=new_sphere_id)
 
-    def _enable_add_mode(self, event=None):
-        """Callback for enabling add mode."""
-        self.mode = SphereAnnotatorMode.ADD
-
     def _mouse_callback(self, viewer, event):
         if (self.image_layer is None) or (self.points_layer is None):
             return
+        if ('Alt' not in event.modifiers):
+            return
         replace_selected = True if self.mode == SphereAnnotatorMode.EDIT else False
-        add_point_on_plane(
-            viewer=viewer,
-            event=event,
-            points_layer=self.points_layer,
-            plane_layer=self.image_layer,
-            replace_selected=replace_selected,
-        )
+        with self.points_layer.events.highlight.blocker():
+            add_point_on_plane(
+                viewer=viewer,
+                event=event,
+                points_layer=self.points_layer,
+                image_layer=self.image_layer,
+                replace_selected=replace_selected,
+            )
         self.mode = SphereAnnotatorMode.EDIT
 
     def _set_radius_from_mouse_event(self, event: Event = None):
@@ -160,7 +140,9 @@ def _set_radius_from_mouse_event(self, event: Event = None):
     def _update_active_sphere_radius(self, radius: float):
         with warnings.catch_warnings():
             warnings.filterwarnings("ignore")
-            self.points_layer.features[self.SPHERE_RADIUS_FEATURES_KEY].iloc[self._active_sphere_index] = radius
+            self.points_layer.features[SPHERE_RADIUS_FEATURES_KEY].iloc[
+                self._active_sphere_index
+            ] = radius
 
     def _update_current_properties(
         self,
@@ -170,34 +152,20 @@ def _update_current_properties(
         if self.points_layer is None:
             return
         if sphere_id is None:
-            sphere_id = self.points_layer.current_properties[self.SPHERE_ID_FEATURES_KEY][0]
+            sphere_id = self.points_layer.current_properties[SPHERE_ID_FEATURES_KEY][0]
         if radius is None:
-            radius = self.points_layer.current_properties[self.SPHERE_RADIUS_FEATURES_KEY][0]
+            radius = self.points_layer.current_properties[SPHERE_RADIUS_FEATURES_KEY][0]
         with warnings.catch_warnings():
             warnings.filterwarnings("ignore")
             self.points_layer.current_properties = {
-                self.SPHERE_ID_FEATURES_KEY: [sphere_id],
-                self.SPHERE_RADIUS_FEATURES_KEY: [radius]
+                SPHERE_ID_FEATURES_KEY: [sphere_id],
+                SPHERE_RADIUS_FEATURES_KEY: [radius]
             }
 
     def _create_points_layer(self) -> Optional[Points]:
+        from napari_threedee.data_models.spheres import N3dSpheres
         ndim = self.image_layer.data.ndim if self.image_layer is not None else 3
-        layer = Points(
-            data=[0] * ndim,
-            ndim=ndim,
-            name="sphere centers",
-            size=7,
-            features={
-                self.SPHERE_ID_FEATURES_KEY: [0],
-                self.SPHERE_RADIUS_FEATURES_KEY: [self.DEFAULT_SPHERE_RADIUS]},
-            face_color=self.SPHERE_ID_FEATURES_KEY,
-            face_color_cycle=self.COLOR_CYCLE,
-            metadata={
-                N3D_METADATA_KEY: {
-                    ANNOTATION_TYPE_KEY: self.ANNOTATION_TYPE,
-                    self.SPHERE_MESH_METADATA_KEY: None}
-            }
-        )
+        layer = N3dSpheres(centers=[0] * ndim, radii=[0]).as_layer()
         layer.selected_data = {0}
         layer.remove_selected()
         return layer
@@ -205,7 +173,7 @@ def _create_points_layer(self) -> Optional[Points]:
     def _create_surface_layer(self) -> Surface:
         return Surface(
             data=(np.array([[0, 0, 0]]), np.array([[0, 0, 0]])),
-            name="spheres",
+            name="sphere meshes",
             opacity=0.7,
         )
 
@@ -225,6 +193,7 @@ def _on_enable(self):
                 callback=self._mouse_callback
             )
             self.points_layer.events.data.connect(self._on_point_data_changed)
+            self.points_layer.events.highlight.connect(self._on_highlight_change)
             self.viewer.bind_key(
                 'r', self._set_radius_from_mouse_event, overwrite=True
             )
@@ -241,40 +210,34 @@ def _on_disable(self):
         if self.points_layer is not None:
             self.points_layer.events.data.disconnect(self._on_point_data_changed)
         self.viewer.bind_key('n', None, overwrite=True)
+        self.viewer.bind_key('r', None, overwrite=True)
 
     def _on_point_data_changed(self, event=None):
         self._update_spheres()
 
     def _update_spheres(self):
+        from napari_threedee.data_models import N3dSpheres
+        vertices, faces = N3dSpheres.from_layer(self.points_layer).to_mesh()
         n3d_metadata = self.points_layer.metadata[N3D_METADATA_KEY]
-        n3d_metadata[self.SPHERE_MESH_METADATA_KEY] = None
-        grouped_points_features = self.points_layer.features.groupby(self.SPHERE_ID_FEATURES_KEY)
-        sphere_vertices = []
-        sphere_faces = []
-        face_index_offset = 0
-        for sphere_id, sphere_df in grouped_points_features:
-            point_index = int(sphere_df.index.values[0])
-            position = self.points_layer.data[point_index]
-            radius = float(sphere_df[self.SPHERE_RADIUS_FEATURES_KEY].iloc[0])
-            mesh_data = create_sphere(radius=radius, rows=20, cols=20)
-            vertex_data = mesh_data.get_vertices() + position
-            sphere_vertices.append(vertex_data)
-            sphere_faces.append(mesh_data.get_faces() + face_index_offset)
-            face_index_offset += len(vertex_data)
-        if len(sphere_vertices) > 0:
-            sphere_vertices = np.concatenate(sphere_vertices, axis=0)
-            sphere_faces = np.concatenate(sphere_faces, axis=0)
-            n3d_metadata[self.SPHERE_MESH_METADATA_KEY] = (
-                sphere_vertices, sphere_faces
-            )
+        if len(vertices) > 0:
+            n3d_metadata[SPHERE_MESH_METADATA_KEY] = (vertices, faces)
             self._draw_spheres()
         else:
-            n3d_metadata[self.SPHERE_MESH_METADATA_KEY] = None
+            n3d_metadata[SPHERE_MESH_METADATA_KEY] = None
 
     def _draw_spheres(self):
         n3d_metadata = self.points_layer.metadata[N3D_METADATA_KEY]
         if self.surface_layer is None:
             self.surface_layer = self._create_surface_layer()
+        if self.surface_layer not in self.viewer.layers:
             self.viewer.layers.append(self.surface_layer)
-        else:
-            self.surface_layer.data = n3d_metadata[self.SPHERE_MESH_METADATA_KEY]
+        self.surface_layer.data = n3d_metadata[SPHERE_MESH_METADATA_KEY]
+
+    def _enable_add_mode(self, event=None):
+        """Callback for enabling add mode."""
+        self.mode = SphereAnnotatorMode.ADD
+
+    def _on_highlight_change(self, event=None):
+        """Callback for enabling edit mode."""
+        self.mode = SphereAnnotatorMode.EDIT
+
diff --git a/src/napari_threedee/annotators/spheres/constants.py b/src/napari_threedee/annotators/spheres/constants.py
new file mode 100644
index 0000000..056ad57
--- /dev/null
+++ b/src/napari_threedee/annotators/spheres/constants.py
@@ -0,0 +1,18 @@
+from ..constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+SPHERE_ANNOTATION_TYPE_KEY = "spheres"
+SPHERE_ID_FEATURES_KEY: str = "sphere_id"
+SPHERE_RADIUS_FEATURES_KEY: str = "radius"
+SPHERE_MESH_METADATA_KEY: str = "mesh_data"
+DEFAULT_SPHERE_RADIUS = 5
+COLOR_CYCLE = [
+    '#1f77b4',
+    '#ff7f0e',
+    '#2ca02c',
+    '#d62728',
+    '#9467bd',
+    '#8c564b',
+    '#e377c2',
+    '#7f7f7f',
+    '#bcbd22',
+    '#17becf',
+]
diff --git a/src/napari_threedee/annotators/spheres/validation.py b/src/napari_threedee/annotators/spheres/validation.py
new file mode 100644
index 0000000..e51284e
--- /dev/null
+++ b/src/napari_threedee/annotators/spheres/validation.py
@@ -0,0 +1,21 @@
+import napari
+import zarr
+
+from napari_threedee.annotators.constants import ANNOTATION_TYPE_KEY
+from napari_threedee.annotators.spheres.constants import SPHERE_ANNOTATION_TYPE_KEY, \
+    SPHERE_ID_FEATURES_KEY, SPHERE_RADIUS_FEATURES_KEY
+
+
+def validate_layer(layer: napari.layers.Points):
+    """Ensure a sphere layer matches the specification."""
+    for feature in (SPHERE_ID_FEATURES_KEY, SPHERE_RADIUS_FEATURES_KEY):
+        if feature not in layer.features:
+            raise ValueError(f"{feature} not in layer features.")
+
+
+def validate_n3d_zarr(n3d_zarr: zarr.Array):
+    """Ensure an n3d zarr array contains data for n3d sphere points layer."""
+    if ANNOTATION_TYPE_KEY not in n3d_zarr.attrs:
+        raise ValueError("cannot read as n3d sphere.")
+    if n3d_zarr.attrs[ANNOTATION_TYPE_KEY] != SPHERE_ANNOTATION_TYPE_KEY:
+        raise ValueError("cannot read as n3d sphere.")
diff --git a/src/napari_threedee/annotators/spline_annotator.py b/src/napari_threedee/annotators/spline_annotator.py
deleted file mode 100644
index 762524d..0000000
--- a/src/napari_threedee/annotators/spline_annotator.py
+++ /dev/null
@@ -1,304 +0,0 @@
-
-import einops
-
-import napari
-from napari.layers import Image, Points, Shapes
-from napari.utils.events.event import EmitterGroup, Event
-import numpy as np
-from psygnal import EventedModel
-from pydantic import validator, PrivateAttr
-from scipy.interpolate import splprep, splev
-from typing import Tuple, Union, Optional, Dict
-
-from napari_threedee._backend.threedee_model import ThreeDeeModel
-from ..mouse_callbacks import add_point_on_plane
-from napari_threedee.utils.napari_utils import add_mouse_callback_safe, \
-    remove_mouse_callback_safe
-from .io import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
-
-
-class _NDimensionalFilament(EventedModel):
-    points: np.ndarray
-    _n_spline_samples = 10000
-    _raw_spline_tck = PrivateAttr(Tuple)
-    _equidistant_spline_tck = PrivateAttr(Tuple)
-    _length = PrivateAttr(float)
-
-    class Config:
-        arbitrary_types_allowed = True
-
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
-        self._prepare_splines()
-
-    @property
-    def _ndim(self) -> int:
-        return self.points.shape[-1]
-
-    @validator('points')
-    def is_coordinate_array(cls, v):
-        points = np.atleast_2d(np.array(v))
-        if points.ndim != 2:
-            raise ValueError('must be an (n, d) array')
-        return points
-
-    def __setattr__(self, name, value):
-        super().__setattr__(name, value)
-        if name == 'points':  # ensure splines stay in sync
-            self._prepare_splines()
-
-    def _prepare_splines(self):
-        self._calculate_filament_spline_parameters()
-        self._calculate_equidistant_spline_parameters()
-
-    def _calculate_filament_spline_parameters(self):
-        """Spline parametrisation mapping [0, 1] to a smooth curve through filament points.
-        Note: equidistant sampling of this spline parametrisation will not yield equidistant
-        samples in Euclidean space.
-        """
-        self._raw_spline_tck, _ = splprep(self.points.T, s=0, k=3)
-
-    def _calculate_equidistant_spline_parameters(self):
-        """Calculate a mapping of normalised cumulative distance to linear samples range [0, 1].
-        * Normalised cumulative distance is the cumulative euclidean distance along the filament
-          rescaled to a range of [0, 1].
-        * The spline parametrisation calculated here can be used to map linearly spaced values
-        which when used in the filament spline parametrisation, yield equidistant points in
-        Euclidean space.
-        """
-        # sample the current filament spline parametrisation, yielding non-equidistant samples
-        u = np.linspace(0, 1, self._n_spline_samples)
-        filament_samples = splev(u, self._raw_spline_tck)
-        filament_samples = np.stack(filament_samples, axis=1)
-
-        # calculate the cumulative length of line segments as we move along the filament.
-        inter_point_differences = np.diff(filament_samples, axis=0)
-        # inter_point_differences = np.r_[np.zeros((1, 3)), inter_point_differences]  # prepend a row of zeros
-        inter_point_distances = np.linalg.norm(inter_point_differences, axis=1)
-        cumulative_distance = np.cumsum(inter_point_distances)
-
-        # calculate spline mapping normalised cumulative distance to linear samples in [0, 1]
-        self._length = cumulative_distance[-1]
-        cumulative_distance /= self._length
-        # prepend a zero, no distance has been covered at start of spline parametrisation
-        cumulative_distance = np.r_[[0], cumulative_distance]
-        self._equidistant_spline_tck, _ = splprep(
-            [u], u=cumulative_distance, s=0, k=3
-        )
-
-    def _sample_backbone(
-        self, u: Union[float, np.ndarray], derivative: int = 0
-    ):
-        """Sample points or derivatives along the backbone of the filament.
-        This function
-        * maps values in the range [0, 1] to points on the smooth filament backbone.
-        * yields equidistant samples along the filament for linearly spaced values of u.
-        If calculate_derivate=True then the derivative will be evaluated and returned instead of
-        backbone points.
-        """
-        u = splev([np.asarray(u)], self._equidistant_spline_tck)  # [
-        samples = splev(u, self._raw_spline_tck, der=derivative)
-        return einops.rearrange(samples, 'c 1 1 b -> b c')
-
-    def _get_equidistant_u(self, separation: float) -> np.ndarray:
-        """Get values for u which yield backbone samples with a defined Euclidean separation."""
-        n_points = int(self._length // separation)
-        remainder = (self._length % separation) / self._length
-        return np.linspace(0, 1 - remainder, n_points)
-
-    def _get_equidistant_backbone_samples(
-        self, separation: float, calculate_derivative: bool = False
-    ) -> np.ndarray:
-        """Calculate equidistant backbone samples with a defined separation in Euclidean space."""
-        u = self._get_equidistant_u(separation)
-        return self._sample_backbone(u, derivative=calculate_derivative)
-
-
-class SplineAnnotator(ThreeDeeModel):
-    COLOR_CYCLE = [
-        '#1f77b4',
-        '#ff7f0e',
-        '#2ca02c',
-        '#d62728',
-        '#9467bd',
-        '#8c564b',
-        '#e377c2',
-        '#7f7f7f',
-        '#bcbd22',
-        '#17becf',
-    ]
-    ANNOTATION_TYPE = "spline"
-    # keys for data stored in features table
-    SPLINE_ID_FEATURES_KEY = "spline_id"
-    SPLINE_COLOR_FEATURES_KEY = "spline_color"
-
-    # metadata and associated keys
-    SPLINES_KEY = "splines"
-    SPLINE_ORDER = 3
-
-    def __init__(
-        self,
-        viewer: napari.Viewer,
-        image_layer: Optional[Image] = None,
-        enabled: bool = False
-    ):
-        self.events = EmitterGroup(
-            source=self,
-            active_spline_id=Event,
-            splines_updated=Event,
-        )
-
-        self.viewer = viewer
-        self.image_layer = image_layer
-        self.points_layer = None
-        self.shapes_layer = None
-        self.auto_fit_spline = True
-        self.enabled = enabled
-
-        self.active_spline_id: int = 0
-
-        # storage for the spline objects
-        # each spline is in its own object
-        self._splines = dict()
-
-        if image_layer is not None:
-            self.set_layers(self.image_layer)
-
-    @property
-    def active_spline_id(self):
-        return self._active_spline_id
-
-    @active_spline_id.setter
-    def active_spline_id(self, id: int):
-        self._active_spline_id = np.clip(id, 0, None)
-        if self.points_layer is not None:
-            self.points_layer.selected_data = {}
-            self.points_layer.current_properties = {
-                self.SPLINE_ID_FEATURES_KEY: self.active_spline_id
-            }
-        self.events.active_spline_id()
-
-    def next_spline(self, event=None):
-        self.active_spline_id += 1
-
-    def previous_spline(self, event=None):
-        self.active_spline_id -= 1
-
-    def _mouse_callback(self, viewer, event):
-        if (self.image_layer is None) or (self.points_layer is None):
-            return
-        add_point_on_plane(
-            viewer=viewer,
-            event=event,
-            points_layer=self.points_layer,
-            plane_layer=self.image_layer
-        )
-
-    def _create_points_layer(self) -> Optional[Points]:
-        layer = Points(
-            data=[0] * self.image_layer.data.ndim,
-            ndim=self.image_layer.data.ndim,
-            name="spline control points",
-            size=3,
-            features={self.SPLINE_ID_FEATURES_KEY: [0]},
-            face_color=self.SPLINE_ID_FEATURES_KEY,
-            face_color_cycle=self.COLOR_CYCLE,
-            metadata={
-                N3D_METADATA_KEY: {
-                    ANNOTATION_TYPE_KEY: self.ANNOTATION_TYPE,
-                    self.SPLINES_KEY: dict,
-                }
-            }
-        )
-        layer.selected_data = {0}
-        layer.remove_selected()
-        self.active_spline_id = self.active_spline_id
-        return layer
-
-    def _create_shapes_layer(self) -> Shapes:
-        return Shapes(
-            ndim=self.image_layer.data.ndim,
-            name="splines",
-            edge_color="green"
-        )
-
-    def set_layers(self, image_layer: napari.layers.Image):
-        self.image_layer = image_layer
-        if self.points_layer is None and self.image_layer is not None:
-            self.points_layer = self._create_points_layer()
-            self.viewer.add_layer(self.points_layer)
-            self.shapes_layer = self._create_shapes_layer()
-            self.viewer.add_layer(self.shapes_layer)
-
-    def _on_enable(self):
-        if self.points_layer is not None:
-            add_mouse_callback_safe(
-                self.viewer.mouse_drag_callbacks, self._mouse_callback
-            )
-            self.points_layer.events.data.connect(self._on_point_data_changed)
-            self.viewer.bind_key('n', self.next_spline, overwrite=True)
-            self.viewer.layers.selection.active = self.image_layer
-
-    def _on_disable(self):
-        remove_mouse_callback_safe(
-            self.viewer.mouse_drag_callbacks, self._mouse_callback
-        )
-        if self.points_layer is not None:
-            self.points_layer.events.data.disconnect(
-                self._on_point_data_changed
-            )
-        self.viewer.bind_key('n', None, overwrite=True)
-
-    def _on_point_data_changed(self, event=None):
-        if self.auto_fit_spline is True:
-            self._update_splines()
-            self._draw_splines()
-
-    def _update_splines(self):
-        grouped_points_features = self.points_layer.features.groupby(
-            self.SPLINE_ID_FEATURES_KEY
-        )
-        splines = dict()
-        for spline_name, spline_df in grouped_points_features:
-            point_indices = spline_df.index.tolist()
-            if len(point_indices) > self.SPLINE_ORDER:
-                # the number of points must be greater than the spline order to properly fit
-                spline_coordinates = self.points_layer.data[point_indices]
-                splines[spline_name] = _NDimensionalFilament(
-                    points=spline_coordinates, k=self.SPLINE_ORDER
-                )
-        metadata = {
-            self.SPLINES_KEY: splines
-        }
-        self.points_layer.metadata[N3D_METADATA_KEY].update(metadata)
-        self.events.splines_updated()
-
-    def _get_spline_colors(self) -> Dict[int, np.ndarray]:
-        self.points_layer.features[self.SPLINE_COLOR_FEATURES_KEY] = \
-            list(self.points_layer.face_color)
-        grouped_points_features = self.points_layer.features.groupby(
-            self.SPLINE_ID_FEATURES_KEY
-        )
-        spline_colors = dict()
-        for spline_id, spline_df in grouped_points_features:
-            spline_colors[spline_id] = spline_df[self.SPLINE_COLOR_FEATURES_KEY].iloc[0]
-        return spline_colors
-
-    def _clear_shapes_layer(self):
-        """Delete all shapes in the shapes layer."""
-        if self.shapes_layer is None:
-            return
-        n_shapes = len(self.shapes_layer.data)
-        self.shapes_layer.selected_data = set(np.arange(n_shapes))
-        self.shapes_layer.remove_selected()
-
-    def _draw_splines(self):
-        self._clear_shapes_layer()
-        splines = self.points_layer.metadata[N3D_METADATA_KEY][self.SPLINES_KEY]
-        spline_colors = self._get_spline_colors()
-        for spline_id, spline_object in splines.items():
-            spline_points = spline_object._sample_backbone(
-                u=np.linspace(0, 1, 1000)
-            )
-            spline_color = spline_colors[spline_id]
-            self.shapes_layer.add_paths(spline_points, edge_color=spline_color)
diff --git a/src/napari_threedee/annotators/surfaces/__init__.py b/src/napari_threedee/annotators/surfaces/__init__.py
new file mode 100644
index 0000000..c3ed3c1
--- /dev/null
+++ b/src/napari_threedee/annotators/surfaces/__init__.py
@@ -0,0 +1,1 @@
+from .annotator import SurfaceAnnotator
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/surface_annotator.py b/src/napari_threedee/annotators/surfaces/annotator.py
similarity index 74%
rename from src/napari_threedee/annotators/surface_annotator.py
rename to src/napari_threedee/annotators/surfaces/annotator.py
index 8e8cc6f..5fa7ad9 100644
--- a/src/napari_threedee/annotators/surface_annotator.py
+++ b/src/napari_threedee/annotators/surfaces/annotator.py
@@ -11,11 +11,20 @@
 
 from morphosamplers.surface_spline import GriddedSplineSurface
 
-from napari_threedee._backend.threedee_model import ThreeDeeModel
-from ..mouse_callbacks import add_point_on_plane
+from napari_threedee._backend.threedee_model import N3dComponent
+from napari_threedee.utils.mouse_callbacks import add_point_on_plane
 from napari_threedee.utils.napari_utils import add_mouse_callback_safe, \
     remove_mouse_callback_safe
-from .io import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+from .constants import (
+    N3D_METADATA_KEY,
+    ANNOTATION_TYPE_KEY,
+    SURFACE_ANNOTATION_TYPE_KEY,
+    SURFACE_ID_FEATURES_KEY,
+    LEVEL_ID_FEATURES_KEY,
+    SPLINES_METADATA_KEY,
+    SPLINE_COLOR_FEATURES_KEY,
+    COLOR_CYCLE,
+)
 
 
 class _NDimensionalFilament(EventedModel):
@@ -45,7 +54,7 @@ def is_coordinate_array(cls, v):
 
     def __setattr__(self, name, value):
         super().__setattr__(name, value)
-        if name == 'points':  # ensure splines stay in sync
+        if name == 'points':  # ensure paths stay in sync
             self._prepare_splines()
 
     def _prepare_splines(self):
@@ -115,29 +124,17 @@ def _get_equidistant_backbone_samples(
         return self._sample_backbone(u, derivative=calculate_derivative)
 
 
-class SurfaceAnnotator(ThreeDeeModel):
-    COLOR_CYCLE = [
-        '#1f77b4',
-    ]
-    ANNOTATION_TYPE = "surface"
-    # keys for data stored in features table
-    SPLINE_ID_FEATURES_KEY = "spline_id"
-    SPLINE_COLOR_FEATURES_KEY = "spline_color"
-
-    # metadata and associated keys
-    SPLINES_KEY = "splines"
-    SPLINE_ORDER = 3
-
+class SurfaceAnnotator(N3dComponent):
     def __init__(
         self,
         viewer: napari.Viewer,
         image_layer: Optional[Image] = None,
-        enabled: bool = False
+        enabled: bool = True
     ):
         self.events = EmitterGroup(
             source=self,
-            active_spline_id=Event,
-            splines_updated=Event,
+            active_surface_id=Event,
+            active_level_id=Event,
         )
 
         self.viewer = viewer
@@ -148,7 +145,8 @@ def __init__(
         self.auto_fit_spline = True
         self.enabled = enabled
 
-        self.active_spline_id: int = 0
+        self.active_surface_id: int = 0
+        self.active_level_id: int = 0
 
         # storage for the spline objects
         # each spline is in its own object
@@ -158,24 +156,46 @@ def __init__(
             self.set_layers(self.image_layer)
 
     @property
-    def active_spline_id(self):
+    def active_surface_id(self):
+        return self._active_surface_id
+
+    @active_surface_id.setter
+    def active_surface_id(self, id: int):
+        self._active_surface_id = np.clip(id, 0, None)
+        if self.points_layer is not None:
+            self.points_layer.selected_data = {}
+            self.points_layer.current_properties = {
+                SURFACE_ID_FEATURES_KEY: self.active_surface_id,
+                LEVEL_ID_FEATURES_KEY: self.active_level_id,
+            }
+        self.events.active_surface_id()
+
+    @property
+    def active_level_id(self):
         return self._active_spline_id
 
-    @active_spline_id.setter
-    def active_spline_id(self, id: int):
+    @active_level_id.setter
+    def active_level_id(self, id: int):
         self._active_spline_id = np.clip(id, 0, None)
         if self.points_layer is not None:
             self.points_layer.selected_data = {}
             self.points_layer.current_properties = {
-                self.SPLINE_ID_FEATURES_KEY: self.active_spline_id
+                SURFACE_ID_FEATURES_KEY: self.active_surface_id,
+                LEVEL_ID_FEATURES_KEY: self.active_level_id,
             }
-        self.events.active_spline_id()
+        self.events.active_level_id()
 
     def next_spline(self, event=None):
-        self.active_spline_id += 1
+        self.active_level_id += 1
 
     def previous_spline(self, event=None):
-        self.active_spline_id -= 1
+        self.active_level_id -= 1
+
+    def next_surface(self, event=None):
+        self.active_surface_id += 1
+
+    def previous_surface(self, event=None):
+        self.active_surface_id -= 1
 
     def _mouse_callback(self, viewer, event):
         if (self.image_layer is None) or (self.points_layer is None):
@@ -184,44 +204,50 @@ def _mouse_callback(self, viewer, event):
             viewer=viewer,
             event=event,
             points_layer=self.points_layer,
-            plane_layer=self.image_layer
+            image_layer=self.image_layer
         )
 
     def _create_points_layer(self) -> Optional[Points]:
         layer = Points(
             data=[0] * self.image_layer.data.ndim,
             ndim=self.image_layer.data.ndim,
-            name="spline control points",
-            size=3,
-            features={self.SPLINE_ID_FEATURES_KEY: [0]},
-            face_color=self.SPLINE_ID_FEATURES_KEY,
-            face_color_cycle=self.COLOR_CYCLE,
+            name="n3d surfaces",
+            size=10,
+            features={
+                SURFACE_ID_FEATURES_KEY: [0],
+                LEVEL_ID_FEATURES_KEY: [0],
+            },
+            face_color=SURFACE_ID_FEATURES_KEY,
+            face_color_cycle=COLOR_CYCLE,
             metadata={
                 N3D_METADATA_KEY: {
-                    ANNOTATION_TYPE_KEY: self.ANNOTATION_TYPE,
-                    self.SPLINES_KEY: dict,
+                    ANNOTATION_TYPE_KEY: SURFACE_ANNOTATION_TYPE_KEY,
+                    SPLINES_METADATA_KEY: dict,
                 }
             }
         )
         layer.selected_data = {0}
         layer.remove_selected()
-        self.active_spline_id = self.active_spline_id
+        self.active_level_id = self.active_level_id
         return layer
 
     def _create_shapes_layer(self) -> Shapes:
         return Shapes(
             ndim=self.image_layer.data.ndim,
-            name="splines",
+            name="n3d surfaces (paths)",
             edge_color="green"
         )
 
     def set_layers(self, image_layer: napari.layers.Image):
+        original_enabled_state = self.enabled
+        self.enabled = False
         self.image_layer = image_layer
         if self.points_layer is None and self.image_layer is not None:
             self.points_layer = self._create_points_layer()
             self.viewer.add_layer(self.points_layer)
             self.shapes_layer = self._create_shapes_layer()
             self.viewer.add_layer(self.shapes_layer)
+        self.enabled = original_enabled_state
 
     def _on_enable(self):
         if self.points_layer is not None:
@@ -244,37 +270,17 @@ def _on_disable(self):
 
     def _on_point_data_changed(self, event=None):
         if self.auto_fit_spline is True:
-            self._update_splines()
             self._draw_splines()
 
-    def _update_splines(self):
-        grouped_points_features = self.points_layer.features.groupby(
-            self.SPLINE_ID_FEATURES_KEY
-        )
-        splines = dict()
-        for spline_name, spline_df in grouped_points_features:
-            point_indices = spline_df.index.tolist()
-            if len(point_indices) > self.SPLINE_ORDER:
-                # the number of points must be greater than the spline order to properly fit
-                spline_coordinates = self.points_layer.data[point_indices]
-                splines[spline_name] = _NDimensionalFilament(
-                    points=spline_coordinates, k=self.SPLINE_ORDER
-                )
-        metadata = {
-            self.SPLINES_KEY: splines
-        }
-        self.points_layer.metadata[N3D_METADATA_KEY].update(metadata)
-        self.events.splines_updated()
-
-    def _get_spline_colors(self) -> Dict[int, np.ndarray]:
-        self.points_layer.features[self.SPLINE_COLOR_FEATURES_KEY] = \
+    def _get_path_colors(self) -> Dict[int, np.ndarray]:
+        self.points_layer.features[SPLINE_COLOR_FEATURES_KEY] = \
             list(self.points_layer.face_color)
         grouped_points_features = self.points_layer.features.groupby(
-            self.SPLINE_ID_FEATURES_KEY
+            LEVEL_ID_FEATURES_KEY
         )
         spline_colors = dict()
         for spline_id, spline_df in grouped_points_features:
-            spline_colors[spline_id] = spline_df[self.SPLINE_COLOR_FEATURES_KEY].iloc[0]
+            spline_colors[spline_id] = spline_df[SPLINE_COLOR_FEATURES_KEY].iloc[0]
         return spline_colors
 
     def _clear_shapes_layer(self):
@@ -286,19 +292,20 @@ def _clear_shapes_layer(self):
         self.shapes_layer.remove_selected()
 
     def _draw_splines(self):
+        from napari_threedee.data_models import N3dSurfaces, N3dPath
+        surfaces = N3dSurfaces.from_layer(self.points_layer)
         self._clear_shapes_layer()
-        splines = self.points_layer.metadata[N3D_METADATA_KEY][self.SPLINES_KEY]
-        spline_colors = self._get_spline_colors()
-        for spline_id, spline_object in splines.items():
-            spline_points = spline_object._sample_backbone(
-                u=np.linspace(0, 1, 1000)
-            )
-            spline_color = spline_colors[spline_id]
-            self.shapes_layer.add_paths(spline_points, edge_color=spline_color)
+        path_colors = self._get_path_colors()
+        for idx, surface in enumerate(surfaces):
+            for level_points in surface:
+                if len(level_points) >= 2:
+                    path = N3dPath(data=level_points).sample(n=1000)
+                    path_color = path_colors[idx]
+                    self.shapes_layer.add_paths(path, edge_color=path_color)
 
     def _draw_surface(self):
         grouped_features = self.points_layer.features.groupby(
-            self.SPLINE_ID_FEATURES_KEY
+            LEVEL_ID_FEATURES_KEY
         )
         surface_levels = [
             self.points_layer.data[df.index]
@@ -307,7 +314,7 @@ def _draw_surface(self):
         surface = GriddedSplineSurface(points=surface_levels, separation=3)
         surface_points, triangle_idx = surface.mesh()
         valid_triangle_idx = np.all(np.isin(triangle_idx, np.argwhere(surface.mask)),
-                                 axis=1)
+                                    axis=1)
         triangle_idx = triangle_idx[valid_triangle_idx]
 
         if self.surface_layer is None:
diff --git a/src/napari_threedee/annotators/surfaces/constants.py b/src/napari_threedee/annotators/surfaces/constants.py
new file mode 100644
index 0000000..1f9506b
--- /dev/null
+++ b/src/napari_threedee/annotators/surfaces/constants.py
@@ -0,0 +1,19 @@
+from ..constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+COLOR_CYCLE = [
+    '#1f77b4',
+    '#ff7f0e',
+    '#2ca02c',
+    '#d62728',
+    '#9467bd',
+    '#8c564b',
+    '#e377c2',
+    '#7f7f7f',
+    '#bcbd22',
+    '#17becf',
+]
+SURFACE_ANNOTATION_TYPE_KEY = "surface"
+SURFACE_ID_FEATURES_KEY = "surface_id"
+LEVEL_ID_FEATURES_KEY = "level_id"
+SPLINE_COLOR_FEATURES_KEY = "spline_color"
+SPLINES_METADATA_KEY = "paths"
+SPLINE_ORDER = 3
diff --git a/src/napari_threedee/annotators/surfaces/validation.py b/src/napari_threedee/annotators/surfaces/validation.py
new file mode 100644
index 0000000..1c67d45
--- /dev/null
+++ b/src/napari_threedee/annotators/surfaces/validation.py
@@ -0,0 +1,35 @@
+import napari
+import zarr
+
+from napari_threedee.annotators.surfaces.constants import (
+    N3D_METADATA_KEY,
+    ANNOTATION_TYPE_KEY,
+    SURFACE_ANNOTATION_TYPE_KEY,
+    SURFACE_ID_FEATURES_KEY,
+    LEVEL_ID_FEATURES_KEY,
+)
+
+
+def validate_layer(layer: napari.layers.Points):
+    """Ensure a points layer matches the n3d surface layer specification."""
+    if N3D_METADATA_KEY not in layer.metadata:
+        raise ValueError(f"{N3D_METADATA_KEY} not in layer metadata.")
+    n3d_metadata = layer.metadata[N3D_METADATA_KEY]
+    if n3d_metadata[ANNOTATION_TYPE_KEY] != SURFACE_ANNOTATION_TYPE_KEY:
+        error = f"Annotation type is not {SURFACE_ANNOTATION_TYPE_KEY}"
+        raise ValueError(error)
+    for key in (SURFACE_ID_FEATURES_KEY, LEVEL_ID_FEATURES_KEY):
+        if key not in layer.features:
+            error = f"{key} not in layer features."
+            raise ValueError(error)
+
+
+def validate_n3d_zarr(n3d_zarr: zarr.Array):
+    """Ensure an n3d zarr array can be converted to a n3d surface points layer."""
+    if ANNOTATION_TYPE_KEY not in n3d_zarr.attrs:
+        raise ValueError("cannot read as n3d surfaces.")
+    if n3d_zarr.attrs[ANNOTATION_TYPE_KEY] != SURFACE_ANNOTATION_TYPE_KEY:
+        raise ValueError("cannot read as n3d surfaces.")
+    for key in (SURFACE_ID_FEATURES_KEY, LEVEL_ID_FEATURES_KEY):
+        if key not in n3d_zarr.attrs:
+            raise ValueError(f"{key} not found.")
diff --git a/src/napari_threedee/data_models/__init__.py b/src/napari_threedee/data_models/__init__.py
new file mode 100644
index 0000000..79fbe00
--- /dev/null
+++ b/src/napari_threedee/data_models/__init__.py
@@ -0,0 +1,4 @@
+from .points import N3dPoints
+from .paths import N3dPath, N3dPaths
+from .spheres import N3dSpheres
+from .surfaces import N3dSurface, N3dSurfaces
diff --git a/src/napari_threedee/data_models/paths.py b/src/napari_threedee/data_models/paths.py
new file mode 100644
index 0000000..cd1ae09
--- /dev/null
+++ b/src/napari_threedee/data_models/paths.py
@@ -0,0 +1,123 @@
+import os
+from typing import List
+
+import napari
+import numpy as np
+import zarr
+from pydantic import BaseModel, validator
+
+from napari_threedee.annotators.base import N3dDataModel
+from napari_threedee.annotators.constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+from napari_threedee.data_models.spline_sampler import SplineSampler
+from napari_threedee.annotators.paths.validation import (
+    validate_layer,
+    validate_n3d_zarr,
+)
+from napari_threedee.annotators.paths.constants import (
+    PATH_ID_FEATURES_KEY,
+    PATH_ANNOTATION_TYPE_KEY,
+    COLOR_CYCLE,
+)
+
+
+class N3dPath(BaseModel):
+    data: np.ndarray
+
+    class Config:
+        allow_mutation = False
+        arbitrary_types_allowed = True
+
+    @property
+    def ndim(self) -> int:
+        return self.data.shape[-1]
+
+    @property
+    def n_points(self) -> int:
+        return self.data.shape[0]
+
+    def sample(self, n: int = 10000, derivative:int = 0) -> np.ndarray:
+        """Sample equidistant points between data points."""
+        sampler = SplineSampler(points=self.data)
+        return sampler(u=np.linspace(0, 1, num=n), derivative=derivative)
+
+    @validator('data', pre=True)
+    def ensure_float32_ndarray(cls, value):
+        return np.asarray(value, dtype=np.float32)
+
+    def __len__(self) -> int:
+        return len(self.data)
+
+
+class N3dPaths(N3dDataModel):
+    data: List[N3dPath]
+
+    @property
+    def n_points(self) -> int:
+        return np.sum([path.n_points for path in self])
+
+    @property
+    def ndim(self) -> int:
+        return self.data[0].data.shape[-1]
+
+    @property
+    def spline_ids(self) -> np.ndarray:
+        spline_ids = [[idx] * len(spline.data) for idx, spline in enumerate(self.data)]
+        return np.concatenate(spline_ids)
+
+    @classmethod
+    def from_layer(cls, layer: napari.layers.Layer):
+        grouped_points_features = layer.features.groupby(PATH_ID_FEATURES_KEY)
+        splines = [
+            N3dPath(data=layer.data[df.index.tolist()])
+            for name, df in grouped_points_features
+        ]
+        return cls(data=splines)
+
+    def as_layer(self) -> napari.layers.Points:
+        data = np.concatenate([spline.data for spline in self.data])
+        metadata = {
+            N3D_METADATA_KEY: {
+                ANNOTATION_TYPE_KEY: PATH_ANNOTATION_TYPE_KEY,
+            }
+        }
+        features = {PATH_ID_FEATURES_KEY: self.spline_ids}
+        layer = napari.layers.Points(
+            data=data,
+            metadata=metadata,
+            features=features,
+            face_color=PATH_ID_FEATURES_KEY,
+            face_color_cycle=COLOR_CYCLE,
+            name='n3d paths',
+            ndim=data.shape[-1],
+        )
+        validate_layer(layer)
+        return layer
+
+    @classmethod
+    def from_n3d_zarr(cls, path: os.PathLike):
+        n3d_zarr = zarr.open(path)
+        validate_n3d_zarr(n3d_zarr)
+        points = np.asarray(n3d_zarr)
+        spline_ids = n3d_zarr.attrs[PATH_ID_FEATURES_KEY]
+        splines = [
+            N3dPath(data=points[spline_ids == idx])
+            for idx in np.unique(spline_ids)
+        ]
+        return cls(data=splines)
+
+    def to_n3d_zarr(self, path: os.PathLike) -> None:
+        n3d_zarr = zarr.open_array(
+            store=path,
+            shape=(self.n_points, self.ndim),
+            dtype=np.float32,
+            mode="w",
+        )
+        n3d_zarr[...] = np.concatenate([spline.data for spline in self.data])
+        n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = PATH_ANNOTATION_TYPE_KEY
+        n3d_zarr.attrs[PATH_ID_FEATURES_KEY] = list(self.spline_ids)
+
+    def __getitem__(self, idx: int) -> N3dPath:
+        return self.data[idx]
+
+    def __iter__(self) -> N3dPath:
+        yield from self.data
\ No newline at end of file
diff --git a/src/napari_threedee/data_models/points.py b/src/napari_threedee/data_models/points.py
new file mode 100644
index 0000000..171316e
--- /dev/null
+++ b/src/napari_threedee/data_models/points.py
@@ -0,0 +1,53 @@
+import os
+
+import napari.layers
+import numpy as np
+import zarr
+from pydantic import validator
+
+from napari_threedee.annotators.base import N3dDataModel
+from napari_threedee.annotators.constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+from napari_threedee.annotators.points.constants import POINT_ANNOTATION_TYPE_KEY
+from napari_threedee.annotators.points.validation import (
+    validate_layer,
+    validate_n3d_zarr,
+)
+
+
+class N3dPoints(N3dDataModel):
+    data: np.ndarray
+
+    @classmethod
+    def from_layer(cls, layer: napari.layers.Points):
+        return cls(data=layer.data)
+
+    def as_layer(self) -> napari.layers.Points:
+        metadata = {N3D_METADATA_KEY: {ANNOTATION_TYPE_KEY: POINT_ANNOTATION_TYPE_KEY}}
+        layer = napari.layers.Points(
+            data=self.data,
+            metadata=metadata,
+            name='n3d points',
+            ndim=self.data.shape[-1],
+        )
+        validate_layer(layer)
+        return layer
+
+    @classmethod
+    def from_n3d_zarr(cls, path: os.PathLike):
+        n3d_zarr = zarr.open(path)
+        validate_n3d_zarr(n3d_zarr)
+        return cls(data=np.array(n3d_zarr))
+
+    def to_n3d_zarr(self, path: os.PathLike) -> None:
+        n3d_zarr = zarr.open_array(
+            store=path,
+            shape=self.data.shape,
+            dtype=self.data.dtype,
+            mode="w"
+        )
+        n3d_zarr[...] = self.data
+        n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = POINT_ANNOTATION_TYPE_KEY
+
+    @validator('data', pre=True)
+    def ensure_2d_float32_array(cls, value):
+        return np.atleast_2d(np.asarray(value, dtype=np.float32))
diff --git a/src/napari_threedee/data_models/spheres.py b/src/napari_threedee/data_models/spheres.py
new file mode 100644
index 0000000..bf798b4
--- /dev/null
+++ b/src/napari_threedee/data_models/spheres.py
@@ -0,0 +1,94 @@
+import os
+from typing import Tuple
+
+import napari
+import numpy as np
+import zarr
+from pydantic import validator
+
+from napari_threedee.annotators.base import N3dDataModel
+from napari_threedee.annotators.spheres.validation import (
+    validate_layer,
+    validate_n3d_zarr,
+)
+from napari_threedee.annotators.spheres.constants import (
+    N3D_METADATA_KEY,
+    ANNOTATION_TYPE_KEY,
+    SPHERE_ANNOTATION_TYPE_KEY,
+    SPHERE_RADIUS_FEATURES_KEY,
+    SPHERE_ID_FEATURES_KEY,
+    COLOR_CYCLE,
+)
+
+
+class N3dSpheres(N3dDataModel):
+    centers: np.ndarray
+    radii: np.ndarray
+
+    @classmethod
+    def from_layer(cls, layer: napari.layers.Points):
+        centers = np.array(layer.data, dtype=np.float32)
+        radii = np.array(layer.features[SPHERE_RADIUS_FEATURES_KEY], dtype=np.float32)
+        return cls(centers=centers, radii=radii)
+
+    def as_layer(self) -> napari.layers.Points:
+        metadata = {N3D_METADATA_KEY: {ANNOTATION_TYPE_KEY: SPHERE_ANNOTATION_TYPE_KEY}}
+        features = {
+            SPHERE_RADIUS_FEATURES_KEY: self.radii,
+            SPHERE_ID_FEATURES_KEY: np.arange(len(self.centers))
+        }
+        layer = napari.layers.Points(
+            data=self.centers,
+            name="n3d spheres",
+            metadata=metadata,
+            features=features,
+            face_color=SPHERE_ID_FEATURES_KEY,
+            face_color_cycle=COLOR_CYCLE,
+        )
+        validate_layer(layer)
+        return layer
+
+    @classmethod
+    def from_n3d_zarr(cls, path: os.PathLike):
+        n3d_zarr = zarr.open(path)
+        validate_n3d_zarr(n3d_zarr)
+        centers = np.array(n3d_zarr, dtype=np.float32)
+        radii = np.array(n3d_zarr.attrs[SPHERE_RADIUS_FEATURES_KEY], dtype=np.float32)
+        return cls(centers=centers, radii=radii)
+
+    def to_n3d_zarr(self, path: os.PathLike) -> None:
+        n3d_zarr = zarr.open_array(
+            store=path,
+            shape=self.centers.shape,
+            dtype=self.centers.dtype,
+            mode="w"
+        )
+        n3d_zarr[...] = self.centers
+        n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = SPHERE_ANNOTATION_TYPE_KEY
+        n3d_zarr.attrs[SPHERE_RADIUS_FEATURES_KEY] = list(self.radii)
+
+    @validator('centers', 'radii', pre=True)
+    def ensure_float32_ndarray(cls, value):
+        return np.asarray(value, dtype=np.float32)
+
+    @validator('centers')
+    def ensure_at_least_2d(cls, value):
+        return np.atleast_2d(value)
+
+    def to_mesh(self) -> Tuple[np.ndarray, np.ndarray]:
+        from vispy.geometry import create_sphere
+        sphere_vertices = []
+        sphere_faces = []
+        face_index_offset = 0
+        for idx, (center, radius) in enumerate(zip(self.centers, self.radii)):
+            mesh_data = create_sphere(radius=radius, rows=20, cols=20)
+            vertex_data = mesh_data.get_vertices() + center
+            sphere_vertices.append(vertex_data)
+            sphere_faces.append(mesh_data.get_faces() + face_index_offset)
+            face_index_offset += len(vertex_data)
+        if len(sphere_vertices) > 0:
+            sphere_vertices = np.concatenate(sphere_vertices, axis=0)
+            sphere_faces = np.concatenate(sphere_faces, axis=0)
+        else:
+            sphere_vertices, sphere_faces = np.array([]), np.array([])
+        return sphere_vertices, sphere_faces
diff --git a/src/napari_threedee/data_models/spline_sampler.py b/src/napari_threedee/data_models/spline_sampler.py
new file mode 100644
index 0000000..1040de2
--- /dev/null
+++ b/src/napari_threedee/data_models/spline_sampler.py
@@ -0,0 +1,113 @@
+from typing import Tuple, Union
+
+import einops
+import numpy as np
+from psygnal import EventedModel
+from pydantic import PrivateAttr, validator
+from scipy.interpolate import splprep, splev
+
+
+class SplineSampler(EventedModel):
+    points: np.ndarray
+    _n_spline_samples = 10000
+    _raw_spline_tck = PrivateAttr(Tuple)
+    _equidistant_spline_tck = PrivateAttr(Tuple)
+    _length = PrivateAttr(float)
+
+    class Config:
+        arbitrary_types_allowed = True
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self._prepare_splines()
+
+    def __call__(self, u: Union[float, np.ndarray], derivative:int = 0):
+        """Sample equidistant points along a spline interpolation of points.
+
+        `u` in the range `[0, 1]` covers the path through `points[0]` to `points[-1]`.
+        `derivative` is the derivative order to sample
+        """
+        return self._sample_backbone(u, derivative=derivative)
+
+    @property
+    def _ndim(self) -> int:
+        return self.points.shape[-1]
+
+    @validator('points')
+    def is_coordinate_array(cls, v):
+        points = np.atleast_2d(np.array(v))
+        if points.ndim != 2:
+            raise ValueError('must be an (n, d) array')
+        return points
+
+    def __setattr__(self, name, value):
+        super().__setattr__(name, value)
+        if name == 'points':  # ensure data stay in sync
+            self._prepare_splines()
+
+    def _prepare_splines(self):
+        spline_order = 3 if len(self.points) > 3 else len(self.points) - 1
+        self._calculate_backbone_spline_parameters(k=spline_order)
+        self._calculate_equidistant_spline_parameters(k=spline_order)
+
+    def _calculate_backbone_spline_parameters(self, k: int):
+        """Spline parametrisation mapping [0, 1] to a smooth curve through filament points.
+        Note: equidistant sampling of this spline parametrisation will not yield equidistant
+        samples in Euclidean space.
+        """
+        self._raw_spline_tck, _ = splprep(self.points.T, s=0, k=k)
+
+    def _calculate_equidistant_spline_parameters(self, k: int):
+        """Calculate a mapping of normalised cumulative distance to linear samples range [0, 1].
+        * Normalised cumulative distance is the cumulative euclidean distance along the filament
+          rescaled to a range of [0, 1].
+        * The spline parametrisation calculated here can be used to map linearly spaced values
+        which when used in the filament spline parametrisation, yield equidistant points in
+        Euclidean space.
+        """
+        # sample the current filament spline parametrisation, yielding non-equidistant samples
+        u = np.linspace(0, 1, self._n_spline_samples)
+        filament_samples = splev(u, self._raw_spline_tck)
+        filament_samples = np.stack(filament_samples, axis=1)
+
+        # calculate the cumulative length of line segments as we move along the filament.
+        inter_point_differences = np.diff(filament_samples, axis=0)
+        # inter_point_differences = np.r_[np.zeros((1, 3)), inter_point_differences]  # prepend a row of zeros
+        inter_point_distances = np.linalg.norm(inter_point_differences, axis=1)
+        cumulative_distance = np.cumsum(inter_point_distances)
+
+        # calculate spline mapping normalised cumulative distance to linear samples in [0, 1]
+        self._length = cumulative_distance[-1]
+        cumulative_distance /= self._length
+        # prepend a zero, no distance has been covered at start of spline parametrisation
+        cumulative_distance = np.r_[[0], cumulative_distance]
+        self._equidistant_spline_tck, _ = splprep(
+            [u], u=cumulative_distance, s=0, k=k
+        )
+
+    def _sample_backbone(
+        self, u: Union[float, np.ndarray], derivative: int = 0
+    ):
+        """Sample points or derivatives along the backbone of the filament.
+        This function
+        * maps values in the range [0, 1] to points on the smooth filament backbone.
+        * yields equidistant samples along the filament for linearly spaced values of u.
+        If calculate_derivate=True then the derivative will be evaluated and returned instead of
+        backbone points.
+        """
+        u = splev([np.asarray(u)], self._equidistant_spline_tck)  # [
+        samples = splev(u, self._raw_spline_tck, der=derivative)
+        return einops.rearrange(samples, 'c 1 1 b -> b c')
+
+    def _get_equidistant_u(self, separation: float) -> np.ndarray:
+        """Get values for u which yield backbone samples with a defined Euclidean separation."""
+        n_points = int(self._length // separation)
+        remainder = (self._length % separation) / self._length
+        return np.linspace(0, 1 - remainder, n_points)
+
+    def _get_equidistant_backbone_samples(
+        self, separation: float, calculate_derivative: bool = False
+    ) -> np.ndarray:
+        """Calculate equidistant backbone samples with a defined separation in Euclidean space."""
+        u = self._get_equidistant_u(separation)
+        return self._sample_backbone(u, derivative=calculate_derivative)
diff --git a/src/napari_threedee/data_models/surfaces.py b/src/napari_threedee/data_models/surfaces.py
new file mode 100644
index 0000000..f5ba828
--- /dev/null
+++ b/src/napari_threedee/data_models/surfaces.py
@@ -0,0 +1,176 @@
+import os
+from typing import List
+
+import napari
+import numpy as np
+import pandas as pd
+import zarr
+from pydantic import BaseModel, validator
+
+from napari_threedee.annotators.base import N3dDataModel
+from napari_threedee.annotators.constants import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
+from napari_threedee.annotators.surfaces.constants import LEVEL_ID_FEATURES_KEY, \
+    SURFACE_ID_FEATURES_KEY, SURFACE_ANNOTATION_TYPE_KEY, COLOR_CYCLE
+from napari_threedee.annotators.surfaces.validation import validate_layer, \
+    validate_n3d_zarr
+
+
+class N3dSurface(BaseModel):
+    data: List[np.ndarray]
+
+    class Config:
+        arbitrary_types_allowed = True
+
+    @property
+    def ndim(self) -> int:
+        return self.data[0].shape[-1]
+
+    @validator('data', pre=True)
+    def ensure_float32_ndarray_list(cls, value):
+        data = [
+            np.asarray(arr, dtype=np.float32)
+            for arr
+            in value
+        ]
+        return data
+
+    @property
+    def n_points(self) -> int:
+        return sum(len(arr) for arr in self.data)
+
+    @property
+    def n_levels(self) -> int:
+        return len(self.data)
+
+    @property
+    def points(self) -> np.ndarray:
+        return np.concatenate([level for level in self.data])
+
+    @property
+    def level_ids(self) -> np.ndarray:
+        level_ids = [
+            [idx] * len(level)
+            for idx, level
+            in enumerate(self.data)
+        ]
+        return np.concatenate(level_ids)
+
+    def __iter__(self) -> np.ndarray:
+        yield from self.data
+
+
+class N3dSurfaces(N3dDataModel):
+    data: List[N3dSurface]
+
+    @property
+    def n_points(self) -> int:
+        return len(self.points)
+
+    @property
+    def ndim(self) -> int:
+        return self.data[0].ndim
+
+    @property
+    def points(self) -> np.ndarray:
+        return np.concatenate([surface.points for surface in self.data])
+
+    @property
+    def surface_ids(self) -> np.ndarray:
+        surface_ids = [
+            [idx] * surface.n_points
+            for idx, surface
+            in enumerate(self.data)
+        ]
+        return np.concatenate(surface_ids)
+
+    @property
+    def level_ids(self) -> np.ndarray:
+        level_ids = np.concatenate(
+            [
+                surface.level_ids
+                for surface
+                in self.data
+            ]
+        )
+        return level_ids
+
+    @classmethod
+    def from_layer(cls, layer: napari.layers.Layer):
+        surfaces = [
+            N3dSurface(
+                data=[
+                    layer.data[level_df.index.tolist()]
+                    for _, level_df
+                    in surface_df.groupby(LEVEL_ID_FEATURES_KEY)
+                ]
+            )
+            for _, surface_df
+            in layer.features.groupby(SURFACE_ID_FEATURES_KEY)
+        ]
+        return cls(data=surfaces)
+
+    def as_layer(self) -> napari.layers.Points:
+        metadata = {
+            N3D_METADATA_KEY: {
+                ANNOTATION_TYPE_KEY: SURFACE_ANNOTATION_TYPE_KEY,
+            }
+        }
+        features = {
+            SURFACE_ID_FEATURES_KEY: self.surface_ids,
+            LEVEL_ID_FEATURES_KEY: self.level_ids,
+        }
+        layer = napari.layers.Points(
+            data=self.points,
+            metadata=metadata,
+            features=features,
+            face_color=SURFACE_ID_FEATURES_KEY,
+            face_color_cycle=COLOR_CYCLE,
+            name='n3d surfaces',
+            ndim=self.points.shape[-1],
+        )
+        validate_layer(layer)
+        return layer
+
+    @classmethod
+    def from_n3d_zarr(cls, path: os.PathLike):
+        n3d_zarr = zarr.open(path)
+        validate_n3d_zarr(n3d_zarr)
+        points = np.asarray(n3d_zarr)
+        surface_ids = n3d_zarr.attrs[SURFACE_ID_FEATURES_KEY]
+        level_ids = n3d_zarr.attrs[LEVEL_ID_FEATURES_KEY]
+        df = pd.DataFrame(
+            {
+                SURFACE_ID_FEATURES_KEY: surface_ids,
+                LEVEL_ID_FEATURES_KEY: level_ids,
+            }
+        )
+        surfaces = [
+            N3dSurface(
+                data=[
+                    points[level_df.index.tolist()]
+                    for _, level_df
+                    in surface_df.groupby(LEVEL_ID_FEATURES_KEY)
+                ]
+            )
+            for _, surface_df
+            in df.groupby(SURFACE_ID_FEATURES_KEY)
+        ]
+        return cls(data=surfaces)
+
+    def to_n3d_zarr(self, path: os.PathLike) -> None:
+        n3d_zarr = zarr.open_array(
+            store=path,
+            shape=(self.n_points, self.ndim),
+            dtype=np.float32,
+            mode="w",
+        )
+        n3d_zarr[...] = self.points
+        n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = SURFACE_ANNOTATION_TYPE_KEY
+        n3d_zarr.attrs[SURFACE_ID_FEATURES_KEY] = list(self.surface_ids)
+        n3d_zarr.attrs[LEVEL_ID_FEATURES_KEY] = list(self.level_ids)
+
+    def __getitem__(self, idx: int) -> N3dSurface:
+        return self.data[idx]
+
+    def __iter__(self) -> N3dSurface:
+        yield from self.data
\ No newline at end of file
diff --git a/src/napari_threedee/dock_widgets.py b/src/napari_threedee/dock_widgets.py
index c2931e5..e0ad8f7 100644
--- a/src/napari_threedee/dock_widgets.py
+++ b/src/napari_threedee/dock_widgets.py
@@ -6,7 +6,9 @@
 
 from .annotators._qt import (
     QtPointAnnotatorWidget,
-    QtSplineAnnotatorWidget,
+    QtPathAnnotatorWidget,
+    QtSphereAnnotatorWidget,
+    QtSurfaceAnnotatorWidget,
 )
 
 from .visualization._qt import (
diff --git a/src/napari_threedee/geometry_utils.py b/src/napari_threedee/geometry_utils.py
deleted file mode 100644
index e1f2938..0000000
--- a/src/napari_threedee/geometry_utils.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import numpy as np
-
-from napari_threedee.utils.napari_utils import get_dims_displayed
-
-
-def point_in_bounding_box(point: np.ndarray, bounding_box: np.ndarray) -> bool:
-    """Determine whether an nD point is inside an nD bounding box.
-    Parameters
-    ----------
-    point : np.ndarray
-        (n,) array containing nD point coordinates to check.
-    bounding_box : np.ndarray
-        (2, n) array containing the min and max of the nD bounding box.
-        As returned by `Layer._extent_data`.
-    """
-    if np.all(point > bounding_box[0]) and np.all(point < bounding_box[1]):
-        return True
-    return False
-
-
-def point_in_layer_bounding_box(point, layer):
-    dims_displayed = get_dims_displayed(self.layer)
-    bbox = layer._display_bounding_box(dims_displayed).T
-    if np.any(point < bbox[0]) or np.any(point > bbox[1]):
-        return False
-    else:
-        return True
diff --git a/src/napari_threedee/manipulators/base_manipulator.py b/src/napari_threedee/manipulators/base_manipulator.py
index 2ee562d..d454d8f 100644
--- a/src/napari_threedee/manipulators/base_manipulator.py
+++ b/src/napari_threedee/manipulators/base_manipulator.py
@@ -5,7 +5,7 @@
 import numpy as np
 from napari.viewer import Viewer
 
-from napari_threedee._backend.threedee_model import ThreeDeeModel
+from napari_threedee._backend.threedee_model import N3dComponent
 from .._backend.manipulator.axis_model import AxisModel
 from .._backend.manipulator.napari_manipulator_backend import \
     NapariManipulatorBackend
@@ -13,7 +13,7 @@
     remove_mouse_callback_safe
 
 
-class BaseManipulator(ThreeDeeModel, ABC):
+class BaseManipulator(N3dComponent, ABC):
     """Base class for manipulator implementations.
 
     To implement:
diff --git a/src/napari_threedee/napari.yaml b/src/napari_threedee/napari.yaml
index b8ed418..bfe95e0 100644
--- a/src/napari_threedee/napari.yaml
+++ b/src/napari_threedee/napari.yaml
@@ -7,9 +7,17 @@ contributions:
       title: Create point annotator
       python_name: napari_threedee.dock_widgets:QtPointAnnotatorWidget
 
-    - id: napari-threedee.QtSplineAnnotatorWidget
-      title: Create filament annotator
-      python_name: napari_threedee.dock_widgets:QtSplineAnnotatorWidget
+    - id: napari-threedee.QtPathAnnotatorWidget
+      title: Create path annotator
+      python_name: napari_threedee.dock_widgets:QtPathAnnotatorWidget
+
+    - id: napari-threedee.QtSphereAnnotatorWidget
+      title: Create sphere annotator
+      python_name: napari_threedee.dock_widgets:QtSphereAnnotatorWidget
+
+    - id: napari-threedee.QtSurfaceAnnotatorWidget
+      title: Create surface annotator
+      python_name: napari_threedee.dock_widgets:QtSurfaceAnnotatorWidget
 
     # Manipulators
     - id: napari-threedee.QtRenderPlaneManipulatorWidget
@@ -50,8 +58,14 @@ contributions:
     - command: napari-threedee.QtPointAnnotatorWidget
       display_name: point annotator
 
-    - command: napari-threedee.QtSplineAnnotatorWidget
-      display_name: spline annotator
+    - command: napari-threedee.QtPathAnnotatorWidget
+      display_name: path annotator
+
+    - command: napari-threedee.QtSphereAnnotatorWidget
+      display_name: sphere annotator
+
+    - command: napari-threedee.QtSurfaceAnnotatorWidget
+      display_name: surface annotator
 
     # Manipulators
     - command: napari-threedee.QtRenderPlaneManipulatorWidget
diff --git a/src/napari_threedee/utils/geometry.py b/src/napari_threedee/utils/geometry.py
index 70f078b..8db184f 100644
--- a/src/napari_threedee/utils/geometry.py
+++ b/src/napari_threedee/utils/geometry.py
@@ -1,7 +1,10 @@
 import numpy as np
 
+from napari_threedee.utils.napari_utils import get_dims_displayed
 
-def signed_angle_between_vectors(vector_0, vector_1, rotation_axis: np.ndarray) -> float:
+
+def signed_angle_between_vectors(vector_0, vector_1,
+                                 rotation_axis: np.ndarray) -> float:
     """ Returns the angle in radians between vectors 'v1' and 'v2'.
 
     Parameters
@@ -56,16 +59,40 @@ def rotation_matrix_around_vector_3d(angle: float, vector: np.ndarray) -> np.nda
     sine_term = np.sin(angle)
 
     rotation_matrix = np.zeros((3, 3))
-    rotation_matrix[0, 0] = np.cos(angle) + (u_0**2) * cos_term
+    rotation_matrix[0, 0] = np.cos(angle) + (u_0 ** 2) * cos_term
     rotation_matrix[0, 1] = (u_0 * u_1 * cos_term) - u_2 * sine_term
     rotation_matrix[0, 2] = (u_0 * u_2 * cos_term) + u_1 * sine_term
 
     rotation_matrix[1, 0] = (u_1 * u_0 * cos_term) + u_2 * sine_term
-    rotation_matrix[1, 1] = np.cos(angle) + (u_1**2) * cos_term
+    rotation_matrix[1, 1] = np.cos(angle) + (u_1 ** 2) * cos_term
     rotation_matrix[1, 2] = (u_1 * u_2 * cos_term) - u_0 * sine_term
 
     rotation_matrix[2, 0] = (u_0 * u_2 * cos_term) - u_1 * sine_term
     rotation_matrix[2, 1] = u_1 * u_2 * cos_term + u_0 * sine_term
-    rotation_matrix[2, 2] = np.cos(angle) + (u_2**2) * cos_term
+    rotation_matrix[2, 2] = np.cos(angle) + (u_2 ** 2) * cos_term
 
     return rotation_matrix
+
+
+def point_in_bounding_box(point: np.ndarray, bounding_box: np.ndarray) -> bool:
+    """Determine whether an nD point is inside an nD bounding box.
+    Parameters
+    ----------
+    point : np.ndarray
+        (n,) array containing nD point coordinates to check.
+    bounding_box : np.ndarray
+        (2, n) array containing the min and max of the nD bounding box.
+        As returned by `Layer._extent_data`.
+    """
+    if np.all(point > bounding_box[0]) and np.all(point < bounding_box[1]):
+        return True
+    return False
+
+
+def point_in_layer_bounding_box(point, layer):
+    dims_displayed = get_dims_displayed(layer)
+    bbox = layer._display_bounding_box(dims_displayed).T
+    if np.any(point < bbox[0]) or np.any(point > bbox[1]):
+        return False
+    else:
+        return True
diff --git a/src/napari_threedee/mouse_callbacks.py b/src/napari_threedee/utils/mouse_callbacks.py
similarity index 73%
rename from src/napari_threedee/mouse_callbacks.py
rename to src/napari_threedee/utils/mouse_callbacks.py
index cc82ffb..4ad74c3 100644
--- a/src/napari_threedee/mouse_callbacks.py
+++ b/src/napari_threedee/utils/mouse_callbacks.py
@@ -4,7 +4,7 @@
 from napari.layers.utils.interactivity_utils import drag_data_to_projected_distance
 import numpy as np
 
-from .geometry_utils import point_in_bounding_box
+from napari_threedee.utils.geometry import point_in_bounding_box
 
 if TYPE_CHECKING:
     import napari
@@ -16,21 +16,21 @@ def add_point_on_plane(
         viewer: napari.viewer.Viewer,
         event: Event,
         points_layer: napari.layers.Points = None,
-        plane_layer: napari.layers.Image = None,
+        image_layer: napari.layers.Image = None,
         replace_selected: bool = False,
 ):
     # Early exit if not alt-clicked
     if 'Alt' not in event.modifiers:
         return
 
-    # Early exit if plane_layer isn't visible
-    if not plane_layer.visible:
+    # Early exit if image_layer isn't visible
+    if image_layer.visible is False or image_layer.depiction != 'plane':
         return
 
     # Calculate intersection of click with plane through data in displayed data (scene) coordinates
     displayed_dims = np.asarray(viewer.dims.displayed)[list(viewer.dims.displayed_order)]
     cursor_position_3d = np.asarray(event.position)[displayed_dims]
-    intersection_3d = plane_layer.plane.intersect_with_line(
+    intersection_3d = image_layer.plane.intersect_with_line(
         line_position=cursor_position_3d,
         line_direction=event.view_direction[displayed_dims]
     )
@@ -39,9 +39,11 @@ def add_point_on_plane(
 
     # Check if click was on plane by checking if intersection occurs within
     # data bounding box. If not, exit early.
-    if not point_in_bounding_box(intersection_nd, plane_layer.extent.data):
+    if not point_in_bounding_box(intersection_nd, image_layer.extent.data):
         return
 
     if replace_selected:
         points_layer.remove_selected()
+    if points_layer.data.shape[-1] < len(intersection_nd):
+        intersection_nd = intersection_3d
     points_layer.add(intersection_nd)
diff --git a/src/napari_threedee/visualization/camera_spline.py b/src/napari_threedee/visualization/camera_spline.py
index c008a7d..5203a7f 100644
--- a/src/napari_threedee/visualization/camera_spline.py
+++ b/src/napari_threedee/visualization/camera_spline.py
@@ -7,9 +7,11 @@
 from napari.utils.geometry import rotation_matrix_from_vectors_3d
 import numpy as np
 
-from .._backend.threedee_model import ThreeDeeModel
-from ..annotators.spline_annotator import SplineAnnotator
-from ..annotators.io import N3D_METADATA_KEY
+from .._backend.threedee_model import N3dComponent
+from ..annotators.paths import PathAnnotator
+from ..annotators.constants import N3D_METADATA_KEY
+from ..data_models import N3dPaths
+from ..data_models.spline_sampler import SplineSampler
 
 
 class CameraSplineMode(Enum):
@@ -18,7 +20,7 @@ class CameraSplineMode(Enum):
     ANNOTATE = "annotate"
 
 
-class CameraSpline(ThreeDeeModel):
+class CameraSpline(N3dComponent):
     """Model for a spline that is used to direct the camera path."""
     COLOR_CYCLE = [
         '#1f77b4',
@@ -56,8 +58,8 @@ def __init__(
         self._view_direction_transformation = np.eye(3)
         self._current_spline_coordinate = 0
 
-        self.spline_annotator_model = SplineAnnotator(viewer=viewer, image_layer=None, enabled=False)
-        self.spline_annotator_model.events.splines_updated.connect(self._check_if_spline_valid)
+        self.spline_annotator_model = PathAnnotator(viewer=viewer, image_layer=None, enabled=False)
+        self.spline_annotator_model.events.paths_updated.connect(self._check_if_spline_valid)
 
         self._check_if_spline_valid()
         self.enabled = enabled
@@ -188,7 +190,7 @@ def current_spline_coordinate(self, current_spline_coordinate: float):
     def calculate_transform_from_spline_tangent_to_view_direction(self):
         current_view_direction = self.viewer.camera.view_direction
         n3d_metadata = self.spline_annotator_model.points_layer.metadata[N3D_METADATA_KEY]
-        spline_dict = n3d_metadata[SplineAnnotator.SPLINES_KEY]
+        spline_dict = n3d_metadata[PathAnnotator.SPLINES_KEY]
 
         # only one spline
         spline_object = spline_dict[0]
@@ -204,17 +206,19 @@ def calculate_transform_from_spline_tangent_to_view_direction(self):
 
     def _check_if_spline_valid(self, event=None):
         """Check if there is a valid spline to slice along"""
+        from napari_threedee.data_models import N3dPaths
         spline_points_layer = self.spline_annotator_model.points_layer
+
         if spline_points_layer is None:
+            # if the points layer isn't set, the path can't be valid
             self.spline_valid = False
             return
-        n3d_metadata = spline_points_layer.metadata[N3D_METADATA_KEY]
-        spline_dict = n3d_metadata[SplineAnnotator.SPLINES_KEY]
-        spline_object = spline_dict.get(0, None)
-        if spline_object is None:
-            self.spline_valid = False
-        else:
+
+        paths = N3dPaths.from_layer(spline_points_layer)
+        if len(paths.data) > 0:
             self.spline_valid = True
+        else:
+            self.spline_valid = False
 
     @property
     def image_layer(self) -> Image:
@@ -258,15 +262,15 @@ def set_camera_position(self, spline_coordinate: float):
         if self.spline_valid is False:
             # do not do anything if there isn't a valid spline
             return
-        n3d_metadata = self.spline_annotator_model.points_layer.metadata[N3D_METADATA_KEY]
-        spline_dict = n3d_metadata[SplineAnnotator.SPLINES_KEY]
+        paths = N3dPaths.from_layer(self.spline_annotator_model.points_layer)
 
         # only one spline
-        spline_object = spline_dict[0]
-        spline_point = spline_object._sample_backbone(u=[spline_coordinate])
+        spline_object = paths[0]
+        spline_sampler = SplineSampler(points=spline_object.data)
+        spline_point = spline_sampler(u=[spline_coordinate], derivative=0)
         self.viewer.camera.center = np.squeeze(spline_point)
 
-        view_direction = np.squeeze(spline_object._sample_backbone(u=[spline_coordinate], derivative=1))
+        view_direction = np.squeeze(spline_sampler(u=[spline_coordinate], derivative=1))
         view_direction_displayed = view_direction[list(self.viewer.dims.displayed)]
 
         view_direction_transformed = tuple(self._transform_view_direction(view_direction_displayed))
diff --git a/src/napari_threedee/visualization/lighting_control.py b/src/napari_threedee/visualization/lighting_control.py
index 2f43d1d..b113c80 100644
--- a/src/napari_threedee/visualization/lighting_control.py
+++ b/src/napari_threedee/visualization/lighting_control.py
@@ -3,14 +3,14 @@
 import numpy as np
 import napari
 
-from napari_threedee._backend.threedee_model import ThreeDeeModel
+from napari_threedee._backend.threedee_model import N3dComponent
 from napari_threedee.utils.napari_utils import (
-    get_dims_displayed,
     get_napari_visual,
+    get_dims_displayed,
 )
 
 
-class LightingControl(ThreeDeeModel):
+class LightingControl(N3dComponent):
     def __init__(self, viewer: napari.Viewer):
         self._viewer = viewer
         self._selected_layers = []


Test patch:
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index ad74e21..20094ee 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -66,6 +66,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v2
+    - uses: tlambert03/setup-qt-libs@v1
     # Install dependencies
     - name: Set up Python 3.8
       uses: actions/setup-python@v2
@@ -74,10 +75,12 @@ jobs:
     - name: Install dependencies
       run: |
         pip install -r requirements.txt
+        pip list
     # Build the book
     - name: Build the book
-      run: |
-        mkdocs build --strict
+      uses: aganders3/headless-gui@v1
+      with:
+        run: mkdocs build --strict
     # Push the book's HTML to github-pages whenever the main repo branch changes
     - name: GitHub Pages action
       if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
diff --git a/src/napari_threedee/_tests/test_dock_widget.py b/src/napari_threedee/_tests/test_dock_widget.py
index f274135..d7370f1 100644
--- a/src/napari_threedee/_tests/test_dock_widget.py
+++ b/src/napari_threedee/_tests/test_dock_widget.py
@@ -7,7 +7,7 @@
     "point manipulator",
     "layer manipulator",
     "point annotator",
-    "spline annotator",
+    "path annotator",
     "mesh lighting controls",
     "camera spline control"
 ]
diff --git a/src/napari_threedee/_tests/test_mouse_callbacks.py b/src/napari_threedee/_tests/test_mouse_callbacks.py
index 703de37..b5caa2f 100644
--- a/src/napari_threedee/_tests/test_mouse_callbacks.py
+++ b/src/napari_threedee/_tests/test_mouse_callbacks.py
@@ -1,6 +1,6 @@
 import numpy as np
 
-from napari_threedee.mouse_callbacks import add_point_on_plane
+from napari_threedee.utils.mouse_callbacks import add_point_on_plane
 
 
 def test_add_point_on_plane_3d(viewer_with_plane_and_points_3d):
@@ -17,7 +17,7 @@ class DummyMouseEvent:
         viewer=viewer_with_plane_and_points_3d,
         event=DummyMouseEvent,
         points_layer=points_layer,
-        plane_layer=plane_layer,
+        image_layer=plane_layer,
     )
     assert len(points_layer.data) == 1
     np.testing.assert_array_almost_equal(points_layer.data[0], (14, 14, 14))
@@ -37,7 +37,7 @@ class DummyMouseEvent:
         viewer=viewer_with_plane_and_points_4d,
         event=DummyMouseEvent,
         points_layer=points_layer,
-        plane_layer=plane_layer,
+        image_layer=plane_layer,
     )
     assert len(points_layer.data) == 1
     np.testing.assert_array_almost_equal(points_layer.data[0], (14, 14, 14, 14))
diff --git a/src/napari_threedee/annotators/_tests/__init__.py b/src/napari_threedee/annotators/_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/napari_threedee/annotators/_tests/conftest.py b/src/napari_threedee/annotators/_tests/conftest.py
index afbc930..173a3b1 100644
--- a/src/napari_threedee/annotators/_tests/conftest.py
+++ b/src/napari_threedee/annotators/_tests/conftest.py
@@ -1,6 +1,6 @@
 import pytest
 
-from napari_threedee.annotators import PointAnnotator, SplineAnnotator, \
+from napari_threedee.annotators import PointAnnotator, PathAnnotator, \
     SphereAnnotator
 
 
@@ -19,7 +19,7 @@ def point_annotator(make_napari_viewer, points_layer_4d, blobs_layer_4d_plane):
 def spline_annotator(make_napari_viewer, blobs_layer_4d_plane):
     viewer = make_napari_viewer(ndisplay=3)
     plane_layer = viewer.add_layer(blobs_layer_4d_plane)
-    annotator = SplineAnnotator(viewer=viewer, image_layer=plane_layer)
+    annotator = PathAnnotator(viewer=viewer, image_layer=plane_layer)
     return annotator
 
 
diff --git a/src/napari_threedee/annotators/_tests/test_plane_point_annotator.py b/src/napari_threedee/annotators/_tests/test_point_annotator.py
similarity index 85%
rename from src/napari_threedee/annotators/_tests/test_plane_point_annotator.py
rename to src/napari_threedee/annotators/_tests/test_point_annotator.py
index f157ecd..4f9d4b5 100644
--- a/src/napari_threedee/annotators/_tests/test_plane_point_annotator.py
+++ b/src/napari_threedee/annotators/_tests/test_point_annotator.py
@@ -1,6 +1,6 @@
 from napari.layers import Points
 
-from napari_threedee.annotators.point_annotator import PointAnnotator
+from napari_threedee.annotators.points import PointAnnotator
 
 
 def test_plane_point_annotator_instantiation(viewer_with_plane_and_points_3d):
@@ -24,4 +24,4 @@ def test_plane_point_annotator_auto_creation_of_points_layer(viewer_with_plane_a
         points_layer=None
     )
     assert isinstance(annotator.points_layer, Points)
-    assert annotator.points_layer.ndim == 3
\ No newline at end of file
+    assert annotator.points_layer.ndim == 3   # matches ndim of image layer
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/_tests/test_sphere_annotator.py b/src/napari_threedee/annotators/_tests/test_sphere_annotator.py
index 0487d17..b93531f 100644
--- a/src/napari_threedee/annotators/_tests/test_sphere_annotator.py
+++ b/src/napari_threedee/annotators/_tests/test_sphere_annotator.py
@@ -1,5 +1,6 @@
 from napari_threedee.annotators import SphereAnnotator
-from napari_threedee.annotators.sphere_annotator import SphereAnnotatorMode
+from napari_threedee.annotators.spheres import SphereAnnotatorMode, \
+    SPHERE_ID_FEATURES_KEY, SPHERE_RADIUS_FEATURES_KEY
 
 
 def test_sphere_annotator_instantiation(viewer_with_plane_and_points_3d):
@@ -19,18 +20,16 @@ def test_sphere_annotator_layer_creation(sphere_annotator):
 
 def test_sphere_annotator_update_current_properties(sphere_annotator):
     sphere_annotator._update_current_properties(sphere_id=1, radius=None)
-    assert sphere_annotator.points_layer.current_properties[SphereAnnotator.SPHERE_ID_FEATURES_KEY] == 1
+    assert sphere_annotator.points_layer.current_properties[SPHERE_ID_FEATURES_KEY] == 1
 
     sphere_annotator._update_current_properties(sphere_id=0, radius=1)
-    assert sphere_annotator.points_layer.current_properties[SphereAnnotator.SPHERE_ID_FEATURES_KEY] == 0
-    assert sphere_annotator.points_layer.current_properties[
-               SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY] == 1
+    assert sphere_annotator.points_layer.current_properties[SPHERE_ID_FEATURES_KEY] == 0
+    assert sphere_annotator.points_layer.current_properties[SPHERE_RADIUS_FEATURES_KEY] == 1
 
     # passing none should have no effect
     sphere_annotator._update_current_properties(sphere_id=None, radius=None)
-    assert sphere_annotator.points_layer.current_properties[SphereAnnotator.SPHERE_ID_FEATURES_KEY] == 0
-    assert sphere_annotator.points_layer.current_properties[
-               SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY] == 1
+    assert sphere_annotator.points_layer.current_properties[SPHERE_ID_FEATURES_KEY] == 0
+    assert sphere_annotator.points_layer.current_properties[SPHERE_RADIUS_FEATURES_KEY] == 1
 
 
 def test_sphere_annotator_enable_add_mode_side_effects(sphere_annotator):
@@ -45,4 +44,4 @@ def test_sphere_annotator_enable_add_mode_side_effects(sphere_annotator):
     assert sphere_annotator.points_layer.selected_data == set()
 
     # check sphere id for next point updated
-    assert sphere_annotator.points_layer.current_properties[sphere_annotator.SPHERE_ID_FEATURES_KEY] == 1
+    assert sphere_annotator.points_layer.current_properties[SPHERE_ID_FEATURES_KEY] == 1
diff --git a/src/napari_threedee/annotators/_tests/test_spline_annotator.py b/src/napari_threedee/annotators/_tests/test_spline_annotator.py
index e0d7168..5f0943e 100644
--- a/src/napari_threedee/annotators/_tests/test_spline_annotator.py
+++ b/src/napari_threedee/annotators/_tests/test_spline_annotator.py
@@ -1,23 +1,24 @@
 from napari.layers import Points
 
-from napari_threedee.annotators.spline_annotator import SplineAnnotator
+import napari_threedee.annotators.paths.constants
+from napari_threedee.annotators.paths import PathAnnotator
 
 
 def test_spline_annotator_instantiation(make_napari_viewer, blobs_layer_4d_plane):
     viewer = make_napari_viewer(ndisplay=3)
     plane_layer = viewer.add_layer(blobs_layer_4d_plane)
-    annotator = SplineAnnotator(
+    annotator = PathAnnotator(
         viewer=viewer,
         image_layer=plane_layer,
     )
     assert isinstance(annotator.points_layer, Points)
-    assert annotator.SPLINE_ID_FEATURES_KEY in annotator.points_layer.features
+    assert napari_threedee.annotators.paths.constants.PATH_ID_FEATURES_KEY in annotator.points_layer.features
     assert len(annotator.points_layer.data) == 0
 
 
 def test_add_point(spline_annotator):
     points_layer = spline_annotator.points_layer
-    label = spline_annotator.SPLINE_ID_FEATURES_KEY
+    label = napari_threedee.annotators.paths.constants.PATH_ID_FEATURES_KEY
 
     # start empty
     assert len(points_layer.data) == 0
@@ -28,7 +29,7 @@ def test_add_point(spline_annotator):
     assert points_layer.features[label][0] == spline_annotator.active_spline_id
 
     # change filemanet_id in annotator, add point, check matches
-    spline_annotator.active_spline_id = 534
+    spline_annotator.active_level_id = 534
     points_layer.add([2, 3, 4, 5])
     assert points_layer.features[label][1] == spline_annotator.active_spline_id
 
@@ -41,6 +42,6 @@ def test_get_colors(spline_annotator):
     spline_annotator.active_spline_id = 1
     points_layer.add([2, 3, 4, 5])
 
-    spline_colors = spline_annotator._get_spline_colors()
+    spline_colors = spline_annotator._get_path_colors()
     for spline_id in (0, 1):
         assert spline_id in spline_colors
diff --git a/src/napari_threedee/annotators/io/_tests/conftest.py b/src/napari_threedee/annotators/io/_tests/conftest.py
deleted file mode 100644
index 78b40bc..0000000
--- a/src/napari_threedee/annotators/io/_tests/conftest.py
+++ /dev/null
@@ -1,108 +0,0 @@
-from typing import List
-
-import napari.layers
-import numpy as np
-import pytest
-import zarr
-from napari.layers import Points
-
-from napari_threedee.annotators import SplineAnnotator, SphereAnnotator, \
-    PointAnnotator
-from napari_threedee.annotators.io import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
-
-
-@pytest.fixture
-def valid_n3d_layers() -> List[napari.layers.Layer]:
-    return [
-        _valid_n3d_spline_layer(),
-        _valid_n3d_sphere_layer(),
-        _valid_n3d_point_layer(),
-    ]
-
-
-def _valid_n3d_spline_layer() -> napari.layers.Points:
-    layer = napari.layers.Points(
-        data=np.random.normal(size=(2, 3)),
-        features={SplineAnnotator.SPLINE_ID_FEATURES_KEY: [0, 1]},
-        metadata={
-            N3D_METADATA_KEY: {
-                ANNOTATION_TYPE_KEY: SplineAnnotator.ANNOTATION_TYPE
-            }
-        }
-    )
-    return layer
-
-
-@pytest.fixture
-def valid_n3d_spline_layer() -> napari.layers.Points:
-    return _valid_n3d_spline_layer()
-
-
-def _valid_n3d_spline_zarr() -> zarr.Array:
-    n3d_zarr = zarr.array(np.random.normal(size=(2, 3)))
-    n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = SplineAnnotator.ANNOTATION_TYPE
-    n3d_zarr.attrs[SplineAnnotator.SPLINE_ID_FEATURES_KEY] = [0, 1]
-    return n3d_zarr
-
-
-@pytest.fixture
-def valid_n3d_spline_zarr() -> zarr.Array:
-    return _valid_n3d_spline_zarr()
-
-
-def _valid_n3d_sphere_layer() -> napari.layers.Points:
-    layer = Points(
-        data=np.random.normal(size=(2, 3)),
-        features={
-            SphereAnnotator.SPHERE_ID_FEATURES_KEY: [0, 1],
-            SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY: [5, 5]
-        },
-        metadata={N3D_METADATA_KEY: {
-            ANNOTATION_TYPE_KEY: SphereAnnotator.ANNOTATION_TYPE
-        }}
-    )
-    return layer
-
-
-@pytest.fixture
-def valid_n3d_sphere_layer() -> napari.layers.Points:
-    return _valid_n3d_sphere_layer()
-
-
-def _valid_n3d_sphere_zarr() -> zarr.Array:
-    n3d_zarr = zarr.array(np.random.normal(size=(2, 3)))
-    n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = SphereAnnotator.ANNOTATION_TYPE
-    n3d_zarr.attrs[SphereAnnotator.SPHERE_ID_FEATURES_KEY] = [0, 1]
-    n3d_zarr.attrs[SphereAnnotator.SPHERE_RADIUS_FEATURES_KEY] = [5, 5]
-    return n3d_zarr
-
-
-@pytest.fixture
-def valid_n3d_sphere_zarr() -> zarr.Array:
-    return _valid_n3d_sphere_zarr()
-
-
-def _valid_n3d_point_layer() -> napari.layers.Points:
-    layer = Points(
-        data=np.random.normal(size=(2, 3)),
-        metadata={N3D_METADATA_KEY: {
-            ANNOTATION_TYPE_KEY: PointAnnotator.ANNOTATION_TYPE
-        }}
-    )
-    return layer
-
-
-@pytest.fixture
-def valid_n3d_point_layer() -> napari.layers.Points:
-    return _valid_n3d_point_layer()
-
-
-def _valid_n3d_point_zarr() -> zarr.Array:
-    n3d_zarr = zarr.array(np.random.normal(size=(2, 3)))
-    n3d_zarr.attrs[ANNOTATION_TYPE_KEY] = PointAnnotator.ANNOTATION_TYPE
-    return n3d_zarr
-
-
-@pytest.fixture
-def valid_n3d_point_zarr() -> zarr.Array:
-    return _valid_n3d_point_zarr()
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/io/_tests/test_napari.py b/src/napari_threedee/annotators/io/_tests/test_napari.py
deleted file mode 100644
index 0751e10..0000000
--- a/src/napari_threedee/annotators/io/_tests/test_napari.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import shutil
-
-import zarr
-from napari.layers import Layer
-
-from napari_threedee.annotators.io import N3D_METADATA_KEY, ANNOTATION_TYPE_KEY
-from napari_threedee.annotators.io._napari import read_n3d_zarr, \
-    write_n3d_zarr, IMPLEMENTATIONS
-
-
-def test_read_and_write_n3d_zarr(valid_n3d_layers, tmp_path):
-    # need n3d extension for reading with napari reader
-    tmp_path = tmp_path.with_suffix('.n3d')
-
-    for layer in valid_n3d_layers:
-        # validate layer
-        annotation_type = layer.metadata[N3D_METADATA_KEY][ANNOTATION_TYPE_KEY]
-        layer_validator = IMPLEMENTATIONS[annotation_type]['layer_validator']
-        layer_validator(layer)
-
-        # write layer
-        data, attributes, layer_type = layer.as_layer_data_tuple()
-        write_n3d_zarr(
-            path=tmp_path,
-            data=data,
-            attributes=attributes
-        )
-
-        # read and validate output as layer and zarr
-        assert tmp_path.exists() and tmp_path.is_dir()
-        reader = read_n3d_zarr(tmp_path)
-        [layer_data_tuple] = reader(tmp_path)
-        layer = Layer.create(*layer_data_tuple)
-        layer_validator(layer)
-
-        n3d_zarr = zarr.open(tmp_path)
-        zarr_validator = IMPLEMENTATIONS[annotation_type]['zarr_validator']
-        zarr_validator(n3d_zarr)
-
-        # cleanup for next layer
-        shutil.rmtree(tmp_path)
-
diff --git a/src/napari_threedee/annotators/io/_tests/test_point.py b/src/napari_threedee/annotators/io/_tests/test_point.py
deleted file mode 100644
index f34dd97..0000000
--- a/src/napari_threedee/annotators/io/_tests/test_point.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import napari.layers
-import numpy as np
-import pytest
-import zarr
-from napari.layers import Points
-
-from napari_threedee.annotators.io.point import layer_to_n3d_zarr, \
-    n3d_zarr_to_layer_data_tuple, validate_zarr, validate_layer
-
-
-def test_layer_to_zarr_from_layer_matching_spec(
-    valid_n3d_point_layer, tmp_path
-):
-    """Assert successful if layer matches spec."""
-    layer = valid_n3d_point_layer
-    validate_layer(layer)
-    n3d_zarr = layer_to_n3d_zarr(layer, tmp_path)
-    validate_zarr(n3d_zarr)
-    assert isinstance(n3d_zarr, zarr.Array)
-
-
-def test_layer_to_zarr_from_incompatible_layer():
-    """Assert non-succesful if zarr doesn't match spec."""
-    layer = Points(data=np.random.normal(size=(2, 3)))
-    with pytest.raises(ValueError):
-        validate_layer(layer)
-        layer_to_n3d_zarr(layer)
-
-
-def test_zarr_to_layer_data_tuple_from_compatible_zarr(valid_n3d_point_zarr):
-    """Assert succesful if zarr matches spec."""
-    n3d_zarr = valid_n3d_point_zarr
-    validate_zarr(n3d_zarr)
-    layer_data_tuple = n3d_zarr_to_layer_data_tuple(n3d_zarr)
-    points = napari.layers.Layer.create(*layer_data_tuple)
-    validate_layer(points)
-
-
-def test_zarr_to_layer_data_tuple_from_incompatible_zarr():
-    """Assert unsuccesful if zarr doesn't match spec."""
-    n3d_zarr = zarr.array(np.random.normal(size=(2, 3)))
-    with pytest.raises(ValueError):
-        validate_zarr(n3d_zarr)
-        n3d_zarr_to_layer_data_tuple(n3d_zarr)
diff --git a/src/napari_threedee/annotators/io/_tests/test_sphere.py b/src/napari_threedee/annotators/io/_tests/test_sphere.py
deleted file mode 100644
index d663733..0000000
--- a/src/napari_threedee/annotators/io/_tests/test_sphere.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import napari.layers
-import numpy as np
-import pytest
-import zarr
-from napari.layers import Points
-
-from napari_threedee.annotators.io.sphere import layer_to_n3d_zarr, \
-    n3d_zarr_to_layer_data_tuple, validate_zarr, validate_layer
-
-
-def test_layer_to_zarr_from_layer_matching_spec(valid_n3d_sphere_layer, tmp_path):
-    """Assert successful if layer matches spec."""
-    layer = valid_n3d_sphere_layer
-    validate_layer(layer)
-    n3d_zarr = layer_to_n3d_zarr(layer, tmp_path)
-    validate_zarr(n3d_zarr)
-    assert isinstance(n3d_zarr, zarr.Array)
-
-
-def test_layer_to_zarr_from_incompatible_layer():
-    """Assert non-succesful if zarr doesn't match spec."""
-    layer = Points(data=np.random.normal(size=(2, 3)))
-    with pytest.raises(ValueError):
-        validate_layer(layer)
-        layer_to_n3d_zarr(layer)
-
-
-def test_zarr_to_layer_data_tuple_from_compatible_zarr(valid_n3d_sphere_zarr):
-    """Assert succesful if zarr matches spec."""
-    n3d_zarr = valid_n3d_sphere_zarr
-    validate_zarr(n3d_zarr)
-    layer_data_tuple = n3d_zarr_to_layer_data_tuple(n3d_zarr)
-    points = napari.layers.Layer.create(*layer_data_tuple)
-    validate_layer(points)
-
-
-def test_zarr_to_layer_data_tuple_from_incompatible_zarr():
-    """Assert unsuccesful if zarr doesn't match spec."""
-    n3d_zarr = zarr.array(np.random.normal(size=(2, 3)))
-    with pytest.raises(ValueError):
-        validate_zarr(n3d_zarr)
-        n3d_zarr_to_layer_data_tuple(n3d_zarr)
diff --git a/src/napari_threedee/annotators/io/_tests/test_spline.py b/src/napari_threedee/annotators/io/_tests/test_spline.py
deleted file mode 100644
index ff0a4f7..0000000
--- a/src/napari_threedee/annotators/io/_tests/test_spline.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import napari.layers
-import numpy as np
-import pytest
-import zarr
-from napari.layers import Points
-
-from napari_threedee.annotators.io.spline import layer_to_n3d_zarr, \
-    n3d_zarr_to_layer_data_tuple, validate_zarr, validate_layer
-from napari_threedee.annotators import SplineAnnotator
-from napari_threedee.annotators.io import ANNOTATION_TYPE_KEY, N3D_METADATA_KEY
-
-
-def test_layer_to_zarr_from_layer_matching_spec(valid_n3d_spline_layer, tmp_path):
-    """Assert successful if layer matches spec."""
-    layer = valid_n3d_spline_layer
-    validate_layer(layer)
-    n3d_zarr = layer_to_n3d_zarr(layer, tmp_path)
-    validate_zarr(n3d_zarr)
-    assert isinstance(n3d_zarr, zarr.Array)
-
-
-def test_layer_to_zarr_from_incompatible_layer():
-    """Assert non-succesful if zarr doesn't match spec."""
-    layer = Points(data=np.random.normal(size=(2, 3)))
-    with pytest.raises(ValueError):
-        validate_layer(layer)
-        layer_to_n3d_zarr(layer)
-
-
-def test_zarr_to_layer_data_tuple_from_compatible_zarr(valid_n3d_spline_zarr):
-    """Assert succesful if zarr matches spec."""
-    n3d_zarr = valid_n3d_spline_zarr
-    validate_zarr(n3d_zarr)
-    layer_data_tuple = n3d_zarr_to_layer_data_tuple(n3d_zarr)
-    points = napari.layers.Layer.create(*layer_data_tuple)
-    validate_layer(points)
-
-
-def test_zarr_to_layer_data_tuple_from_incompatible_zarr():
-    """Assert unsuccesful if zarr doesn't match spec."""
-    n3d_zarr = zarr.array(np.random.normal(size=(2, 3)))
-    with pytest.raises(ValueError):
-        validate_zarr(n3d_zarr)
-        n3d_zarr_to_layer_data_tuple(n3d_zarr)
diff --git a/src/napari_threedee/annotators/points/_tests/__init__.py b/src/napari_threedee/annotators/points/_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/napari_threedee/annotators/points/_tests/test_data_model.py b/src/napari_threedee/annotators/points/_tests/test_data_model.py
new file mode 100644
index 0000000..5268f2c
--- /dev/null
+++ b/src/napari_threedee/annotators/points/_tests/test_data_model.py
@@ -0,0 +1,27 @@
+import numpy as np
+import zarr
+
+from napari_threedee.data_models import N3dPoints
+from napari_threedee.annotators.points.validation import validate_layer, validate_n3d_zarr
+
+
+def test_instantiation():
+    points = N3dPoints(data=np.random.uniform(low=0, high=10, size=(10, 3)))
+    assert isinstance(points, N3dPoints)
+
+
+def test_to_and_from_layer():
+    points = N3dPoints(data=np.random.uniform(low=0, high=10, size=(10, 3)))
+    layer = points.as_layer()
+    validate_layer(layer)
+    points = N3dPoints.from_layer(layer)
+    assert isinstance(points, N3dPoints)
+
+
+def test_to_and_from_n3d_zarr(tmp_path):
+    points = N3dPoints(data=np.random.uniform(low=0, high=10, size=(10, 3)))
+    points.to_n3d_zarr(tmp_path)
+    n3d_zarr = zarr.open(tmp_path)
+    validate_n3d_zarr(n3d_zarr)
+    points = N3dPoints.from_n3d_zarr(tmp_path)
+    assert isinstance(points, N3dPoints)
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/spheres/_tests/__init__.py b/src/napari_threedee/annotators/spheres/_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/napari_threedee/annotators/spheres/_tests/test_data_model.py b/src/napari_threedee/annotators/spheres/_tests/test_data_model.py
new file mode 100644
index 0000000..3c04968
--- /dev/null
+++ b/src/napari_threedee/annotators/spheres/_tests/test_data_model.py
@@ -0,0 +1,33 @@
+import numpy as np
+import zarr
+
+from napari_threedee.data_models import N3dSpheres
+from napari_threedee.annotators.spheres.validation import validate_layer, validate_n3d_zarr
+
+
+def test_n3d_spheres_instantiation():
+    centers = np.random.uniform(low=0, high=10, size=(10, 3))
+    radii = np.random.uniform(low=0, high=5, size=(10, ))
+    spheres = N3dSpheres(centers=centers, radii=radii)
+    assert isinstance(spheres, N3dSpheres)
+
+
+def test_spheres_to_and_from_layer():
+    centers = np.random.uniform(low=0, high=10, size=(10, 3))
+    radii = np.random.uniform(low=0, high=5, size=(10, ))
+    spheres = N3dSpheres(centers=centers, radii=radii)
+    layer = spheres.as_layer()
+    validate_layer(layer)
+    spheres = N3dSpheres.from_layer(layer)
+    assert isinstance(spheres, N3dSpheres)
+
+
+def test_spheres_to_and_from_n3d_zarr(tmp_path):
+    centers = np.random.uniform(low=0, high=10, size=(10, 3))
+    radii = np.random.uniform(low=0, high=5, size=(10, ))
+    spheres = N3dSpheres(centers=centers, radii=radii)
+    spheres.to_n3d_zarr(tmp_path)
+    n3d_zarr = zarr.open(tmp_path)
+    validate_n3d_zarr(n3d_zarr)
+    spheres = N3dSpheres.from_n3d_zarr(tmp_path)
+    assert isinstance(spheres, N3dSpheres)
\ No newline at end of file
diff --git a/src/napari_threedee/annotators/surfaces/_tests/__init__.py b/src/napari_threedee/annotators/surfaces/_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/napari_threedee/annotators/surfaces/_tests/test_data_model.py b/src/napari_threedee/annotators/surfaces/_tests/test_data_model.py
new file mode 100644
index 0000000..bb96185
--- /dev/null
+++ b/src/napari_threedee/annotators/surfaces/_tests/test_data_model.py
@@ -0,0 +1,39 @@
+import numpy as np
+import zarr
+
+from napari_threedee.data_models import N3dSurface, N3dSurfaces
+from napari_threedee.annotators.surfaces.validation import validate_layer, \
+    validate_n3d_zarr
+
+
+def test_single_surface_instantiation():
+    surface_data = [np.random.uniform(0, 10, size=(10, 3))]
+    surface = N3dSurface(data=surface_data)
+    assert isinstance(surface, N3dSurface)
+
+
+def test_splines_instantiation():
+    surface = N3dSurface(data=[np.random.uniform(0, 10, size=(10, 3))])
+    surfaces = N3dSurfaces(data=[surface, surface])
+    assert isinstance(surfaces, N3dSurfaces)
+    assert surfaces.n_points == 20
+    assert surfaces.ndim == 3
+
+
+def test_splines_to_and_from_layer():
+    surface = N3dSurface(data=[np.random.uniform(0, 10, size=(10, 3))])
+    surfaces = N3dSurfaces(data=[surface, surface])
+    layer = surfaces.as_layer()
+    validate_layer(layer)
+    splines = N3dSurfaces.from_layer(layer)
+    assert isinstance(splines, N3dSurfaces)
+
+
+def test_splines_to_and_from_n3d_zarr(tmp_path):
+    surface = N3dSurface(data=[np.random.uniform(0, 10, size=(10, 3))])
+    surfaces = N3dSurfaces(data=[surface, surface])
+    surfaces.to_n3d_zarr(tmp_path)
+    n3d_zarr = zarr.open(tmp_path)
+    validate_n3d_zarr(n3d_zarr)
+    splines = N3dSurfaces.from_n3d_zarr(tmp_path)
+    assert isinstance(splines, N3dSurfaces)
diff --git a/src/napari_threedee/data_models/_tests/__init__.py b/src/napari_threedee/data_models/_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/napari_threedee/data_models/_tests/test_paths.py b/src/napari_threedee/data_models/_tests/test_paths.py
new file mode 100644
index 0000000..03b5940
--- /dev/null
+++ b/src/napari_threedee/data_models/_tests/test_paths.py
@@ -0,0 +1,43 @@
+import numpy as np
+import zarr
+
+from napari_threedee.data_models.paths import N3dPath, N3dPaths
+from napari_threedee.annotators.paths.validation import validate_layer, validate_n3d_zarr
+
+
+def test_single_path_instantiation():
+    path = N3dPath(data=np.random.uniform(0, 10, size=(10, 3)))
+    assert isinstance(path, N3dPath)
+
+
+def test_single_path_interpolation():
+    path = N3dPath(data=np.random.uniform(0, 10, size=(10, 3)))
+    result = path.sample(n=10000)
+    assert result.shape == (10000, 3)
+
+
+def test_paths_instantiation():
+    path = N3dPath(data=np.random.uniform(0, 10, size=(10, 3)))
+    paths = N3dPaths(data=[path, path])
+    assert isinstance(paths, N3dPaths)
+    assert paths.n_points == 20
+    assert paths.ndim == 3
+
+
+def test_paths_to_and_from_layer():
+    path = N3dPath(data=np.random.uniform(0, 10, size=(10, 3)))
+    paths = N3dPaths(data=[path, path])
+    layer = paths.as_layer()
+    validate_layer(layer)
+    paths = N3dPaths.from_layer(layer)
+    assert isinstance(paths, N3dPaths)
+
+
+def test_paths_to_and_from_n3d_zarr(tmp_path):
+    path = N3dPath(data=np.random.uniform(0, 10, size=(10, 3)))
+    paths = N3dPaths(data=[path, path])
+    paths.to_n3d_zarr(tmp_path)
+    n3d_zarr = zarr.open(tmp_path)
+    validate_n3d_zarr(n3d_zarr)
+    paths = N3dPaths.from_n3d_zarr(tmp_path)
+    assert isinstance(paths, N3dPaths)
\ No newline at end of file
diff --git a/src/napari_threedee/visualization/_tests/test_camera_spline.py b/src/napari_threedee/visualization/_tests/test_camera_spline.py
index 292cd27..1630c85 100644
--- a/src/napari_threedee/visualization/_tests/test_camera_spline.py
+++ b/src/napari_threedee/visualization/_tests/test_camera_spline.py
@@ -1,12 +1,11 @@
 import napari
-import platform
 from typing import Tuple
 
 import numpy as np
-import pytest
 
-from napari_threedee.annotators import SplineAnnotator
-from napari_threedee.annotators.io import N3D_METADATA_KEY
+from napari_threedee.annotators import PathAnnotator
+from napari_threedee.annotators.constants import N3D_METADATA_KEY
+from napari_threedee.data_models import N3dPaths
 from napari_threedee.visualization.camera_spline import CameraSpline, CameraSplineMode
 
 
@@ -74,8 +73,9 @@ def test_annotate_spline(make_napari_viewer):
     # add 4 points and check that the spline was created
     points_layer.add(np.random.random((4, 3)))
     assert camera_spline.spline_valid is True
-    n3d_metadata = points_layer.metadata[N3D_METADATA_KEY]
-    assert len(n3d_metadata[SplineAnnotator.SPLINES_KEY]) == 1
+
+    paths = N3dPaths.from_layer(points_layer)
+    assert len(paths.data) == 1
 
 
 def test_spline_explore(make_napari_viewer):