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

Issue:
[WIP] manipulator refactor
Haven't been committing and want to keep track of where I'm at with this

- [x] separate models for manipulator/translator and associated visual data
- [x] add tests construction of models
- [x]  produce correct visual data from individual models
- [x] produce correct visual data from manipulator itself
- [x] hook up to Kevin's vispy visual
- [x] highlighting logic

### Also todo but maybe in separate PRs:
- [ ] hook up existing manipulators
- [ ] add docs


Discussion / hints:
@kevinyamauchi just to check, is it right to say that which central axes are shown is implied by the set of translators/rotators used?
> @kevinyamauchi just to check, is it right to say that which central axes are shown is implied by the set of translators/rotators used?

Yup! Each rotator and translator should specify a set of central axes they require. Then the included central axes would be the union of those sets.
This looks great so far! Psyched to have this in, @alisterburt !
> Also todo but maybe in separate PRs:
>
> hook up existing manipulators
> add docs

I am okay with doing both things in separate PRs. This is a solid chunk of work and the models stand alone (as the should).
okay, think this is ready for a review!

I realise this is a lot - feel free to optimistically merge/I'm happy to take you through it on Friday

https://user-images.githubusercontent.com/7307488/196538030-11eccc25-d104-4cca-808d-3912124d3062.mp4




# [Codecov](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Base: **43.58**% // Head: **59.02**% // Increases project coverage by **`+15.43%`** :tada:
> Coverage data is based on head [(`3512c50`)](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) compared to base [(`a75e27d`)](https://codecov.io/gh/napari-threedee/napari-threedee/commit/a75e27de93d40f2cc1979e36dd636f9807f33fce?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> Patch coverage: 68.36% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             main      #63       +/-   ##
===========================================
+ Coverage   43.58%   59.02%   +15.43%     
===========================================
  Files          31       44       +13     
  Lines        1716     2006      +290     
===========================================
+ Hits          748     1184      +436     
+ Misses        968      822      -146     
```


| [Impacted Files](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/napari\_threedee/\_base\_model.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9fYmFzZV9tb2RlbC5weQ==) | `80.85% <ø> (ø)` | |
| [.../napari\_threedee/manipulator/napari\_manipulator.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvci9uYXBhcmlfbWFuaXB1bGF0b3IucHk=) | `0.00% <0.00%> (ø)` | |
| [...i\_threedee/manipulator/vispy\_manipulator\_visual.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvci92aXNweV9tYW5pcHVsYXRvcl92aXN1YWwucHk=) | `0.00% <0.00%> (ø)` | |
| [...edee/manipulators/\_tests/test\_manipulator\_utils.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvcnMvX3Rlc3RzL3Rlc3RfbWFuaXB1bGF0b3JfdXRpbHMucHk=) | `100.00% <ø> (ø)` | |
| [src/napari\_threedee/utils/selection\_utils.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS91dGlscy9zZWxlY3Rpb25fdXRpbHMucHk=) | `11.76% <ø> (ø)` | |
| [src/napari\_threedee/manipulator/utils.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvci91dGlscy5weQ==) | `40.00% <40.00%> (ø)` | |
| [...c/napari\_threedee/manipulator/vispy\_visual\_data.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvci92aXNweV92aXN1YWxfZGF0YS5weQ==) | `71.82% <71.82%> (ø)` | |
| [src/napari\_threedee/manipulator/axis\_model.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvci9heGlzX21vZGVsLnB5) | `80.00% <80.00%> (ø)` | |
| [src/napari\_threedee/manipulator/\_interface.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvci9faW50ZXJmYWNlLnB5) | `85.71% <85.71%> (ø)` | |
| [src/napari\_threedee/manipulator/translator.py](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL25hcGFyaV90aHJlZWRlZS9tYW5pcHVsYXRvci90cmFuc2xhdG9yLnB5) | `93.93% <93.93%> (ø)` | |
| ... and [20 more](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/napari-threedee/napari-threedee/pull/63?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).

Thanks @kevinyamauchi !

> Quick question about the new directory structure. What is the intended usage of `napari_threedee/manipulator` and `napari_threedee/manipulators`? Is this just temporary as we transition to the new manipulator?

It's temporary as you said but I'm not sure how this will pan out, where different manipulators should live vs the manipulator infra itself... will have a think in some followup.

In the meantime, I've fixed the tests here and will start on docs/hooking up our existing manipulators :)


Fix patch:
diff --git a/examples/library/layer_manipulator.py b/examples/library/layer_manipulator.py
index 8ba3683..aeffcf0 100644
--- a/examples/library/layer_manipulator.py
+++ b/examples/library/layer_manipulator.py
@@ -1,7 +1,7 @@
 import napari
 import numpy as np
 
-from napari_threedee.manipulators.qt_manipulators import QtLayerManipulatorWidget
+from napari_threedee.manipulators._qt import QtLayerManipulatorWidget
 
 data = np.random.random((100, 100, 100))
 
diff --git a/examples/library/mesh_headlight.py b/examples/library/mesh_headlight.py
index 3112d3d..f8bf2e4 100644
--- a/examples/library/mesh_headlight.py
+++ b/examples/library/mesh_headlight.py
@@ -3,7 +3,7 @@
 
 import napari
 
-from napari_threedee.lighting.qt_lighting_control import QtLightingControlWidget
+from napari_threedee.lighting._qt.qt_lighting_control import QtLightingControlWidget
 
 # Fetch datasets - this will download dataset if datasets are not found
 nki_dataset = datasets.fetch_surf_nki_enhanced(n_subjects=1)
diff --git a/examples/library/render_plane_manipulator.py.py b/examples/library/render_plane_manipulator.py
similarity index 67%
rename from examples/library/render_plane_manipulator.py.py
rename to examples/library/render_plane_manipulator.py
index 0e8465a..7ff0563 100644
--- a/examples/library/render_plane_manipulator.py.py
+++ b/examples/library/render_plane_manipulator.py
@@ -1,25 +1,17 @@
+from napari_threedee.manipulators._qt import QtRenderPlaneManipulatorWidget
+
 import napari
 from skimage import data
 
-from napari_threedee.manipulators.qt_manipulators import QtRenderPlaneManipulatorWidget
-
 viewer = napari.Viewer(ndisplay=3)
-
-# add a volume
 blobs = data.binary_blobs(
     length=64, volume_fraction=0.1, n_dim=3
 ).astype(float)
-volume_layer = viewer.add_image(
-    blobs, rendering='mip', name='volume', blending='additive', opacity=0.25
-)
 
-# add the same volume and render as plane
-# plane should be in 'additive' blending mode or depth looks all wrong
 plane_parameters = {
     'position': (32, 32, 32),
     'normal': (1, 0, 0),
     'thickness': 10,
-    'enabled': True
 }
 
 plane_layer = viewer.add_image(
@@ -29,9 +21,13 @@
     colormap='bop orange',
     blending='additive',
     opacity=0.5,
-    experimental_slicing_plane=plane_parameters
+    depiction='plane',
+    plane=plane_parameters
+)
+volume_layer = viewer.add_image(
+    blobs, rendering='mip', name='volume', blending='additive', opacity=0.25
 )
 
 widget = QtRenderPlaneManipulatorWidget(viewer)
 viewer.window.add_dock_widget(widget)
-napari.run()
\ No newline at end of file
+napari.run()
diff --git a/examples/new_manipulator.py b/examples/new_manipulator.py
index 1b9f2ed..88091e4 100644
--- a/examples/new_manipulator.py
+++ b/examples/new_manipulator.py
@@ -1,13 +1,13 @@
 import napari
 import numpy as np
 
-from napari_threedee.manipulator.napari_manipulator import NapariManipulator
+from napari_threedee._backend.manipulator.napari_manipulator_backend import NapariManipulatorBackend
 
 viewer = napari.Viewer(ndisplay=3)
 viewer.axes.visible = True
 image_layer = viewer.add_image(np.zeros((50, 50, 50)))
 
-manipulator = NapariManipulator(
+manipulator = NapariManipulatorBackend(
     rotator_axes='xyz',
     translator_axes='xyz',
     viewer=viewer,
diff --git a/examples/plugin/mesh_headlight.py b/examples/plugin/mesh_headlight.py
index df96196..4bf2581 100644
--- a/examples/plugin/mesh_headlight.py
+++ b/examples/plugin/mesh_headlight.py
@@ -8,7 +8,7 @@
 
 import napari
 
-from napari_threedee.lighting.qt_lighting_control import QtLightingControlWidget
+from napari_threedee.lighting._qt.qt_lighting_control import QtLightingControlWidget
 
 
 # Fetch datasets - this will download dataset if datasets are not found
diff --git a/examples/plugin/points_manipulator.py b/examples/plugin/points_manipulator.py
index f6f9ab4..5ddaecd 100644
--- a/examples/plugin/points_manipulator.py
+++ b/examples/plugin/points_manipulator.py
@@ -9,7 +9,6 @@
 
 import napari
 
-
 points_data = np.array(
     [
         [0, 0, 0],
@@ -21,8 +20,4 @@
 viewer = napari.Viewer(ndisplay=3)
 points_layer = viewer.add_points(points_data, size=5)
 
-viewer.window.add_plugin_dock_widget(
-    plugin_name="napari-threedee", widget_name="point manipulator"
-)
-
 napari.run()
diff --git a/src/napari_threedee/__init__.py b/src/napari_threedee/__init__.py
index 737a939..96d0391 100644
--- a/src/napari_threedee/__init__.py
+++ b/src/napari_threedee/__init__.py
@@ -3,4 +3,3 @@
 except ImportError:
     __version__ = "unknown"
 
-from . import _dock_widgets
diff --git a/src/napari_threedee/manipulator/__init__.py b/src/napari_threedee/_backend/__init__.py
similarity index 100%
rename from src/napari_threedee/manipulator/__init__.py
rename to src/napari_threedee/_backend/__init__.py
diff --git a/src/napari_threedee/manipulator/_tests/__init__.py b/src/napari_threedee/_backend/manipulator/__init__.py
similarity index 100%
rename from src/napari_threedee/manipulator/_tests/__init__.py
rename to src/napari_threedee/_backend/manipulator/__init__.py
diff --git a/src/napari_threedee/manipulator/_interface.py b/src/napari_threedee/_backend/manipulator/_interface.py
similarity index 100%
rename from src/napari_threedee/manipulator/_interface.py
rename to src/napari_threedee/_backend/manipulator/_interface.py
diff --git a/src/napari_threedee/manipulator/axis_model.py b/src/napari_threedee/_backend/manipulator/axis_model.py
similarity index 100%
rename from src/napari_threedee/manipulator/axis_model.py
rename to src/napari_threedee/_backend/manipulator/axis_model.py
diff --git a/src/napari_threedee/manipulator/central_axis.py b/src/napari_threedee/_backend/manipulator/central_axis.py
similarity index 90%
rename from src/napari_threedee/manipulator/central_axis.py
rename to src/napari_threedee/_backend/manipulator/central_axis.py
index 15322d4..255abdc 100644
--- a/src/napari_threedee/manipulator/central_axis.py
+++ b/src/napari_threedee/_backend/manipulator/central_axis.py
@@ -3,7 +3,7 @@
 import numpy as np
 from pydantic import BaseModel
 
-from napari_threedee.manipulator.axis_model import AxisModel, AxisSet
+from napari_threedee._backend.manipulator.axis_model import AxisModel, AxisSet
 
 
 class CentralAxis(BaseModel):
diff --git a/src/napari_threedee/manipulator/drag_managers.py b/src/napari_threedee/_backend/manipulator/drag_managers.py
similarity index 85%
rename from src/napari_threedee/manipulator/drag_managers.py
rename to src/napari_threedee/_backend/manipulator/drag_managers.py
index ea6a110..1189a5c 100644
--- a/src/napari_threedee/manipulator/drag_managers.py
+++ b/src/napari_threedee/_backend/manipulator/drag_managers.py
@@ -2,8 +2,8 @@
 from napari.utils.geometry import intersect_line_with_plane_3d
 import numpy as np
 
-from napari_threedee.utils.geometry import signed_angle_between_vectors, rotation_matrix_around_vector_3d
-from napari_threedee.utils.napari_utils import mouse_event_to_layer_data_displayed
+from ...utils.geometry import signed_angle_between_vectors, rotation_matrix_around_vector_3d
+from ...utils.napari_utils import get_mouse_position_in_displayed_layer_data_coordinates
 
 
 class RotatorDragManager:
@@ -22,8 +22,8 @@ def setup_drag(self, layer: napari.layers.Layer, mouse_event, translation: np.nd
                    rotation_matrix=np.ndarray):
         self._layer = layer
 
-        click_point_data, click_dir_data_3d = mouse_event_to_layer_data_displayed(layer,
-                                                                                  mouse_event)
+        click_point_data, click_dir_data_3d = get_mouse_position_in_displayed_layer_data_coordinates(layer,
+                                                                                                     mouse_event)
         click_on_rotation_plane = intersect_line_with_plane_3d(
             line_position=click_point_data,
             line_direction=click_dir_data_3d,
@@ -70,11 +70,11 @@ def setup_drag(self, layer: napari.layers.Layer, mouse_event, translation: np.nd
                    rotation_matrix=np.ndarray):
         self._layer = layer
 
-        _, click_dir_data_3d = mouse_event_to_layer_data_displayed(layer, mouse_event)
+        _, click_dir_data_3d = get_mouse_position_in_displayed_layer_data_coordinates(layer, mouse_event)
 
         self._initial_position_world = mouse_event.position
-        self._initial_rotation_matrix = rotation_matrix.copy()
-        self._initial_translation = translation
+        self._initial_rotation_matrix = np.copy(rotation_matrix)
+        self._initial_translation = np.copy(translation)
         self._view_direction = click_dir_data_3d
 
     def update_drag(self, mouse_event):
diff --git a/src/napari_threedee/manipulator/model.py b/src/napari_threedee/_backend/manipulator/manipulator_model.py
similarity index 58%
rename from src/napari_threedee/manipulator/model.py
rename to src/napari_threedee/_backend/manipulator/manipulator_model.py
index 71c62ed..01fd8c0 100644
--- a/src/napari_threedee/manipulator/model.py
+++ b/src/napari_threedee/_backend/manipulator/manipulator_model.py
@@ -1,12 +1,12 @@
-from typing import Tuple, Optional
+from typing import Tuple, Optional, Literal, Union, Any
 
 import numpy as np
 from psygnal import EventedModel
 from pydantic import validator, Field
 
 from .central_axis import CentralAxisSet
-from .rotator import RotatorSet
-from .translator import TranslatorSet
+from .rotator import RotatorSet, Rotator
+from .translator import TranslatorSet, Translator
 
 
 class ManipulatorModel(EventedModel):
@@ -18,10 +18,36 @@ class ManipulatorModel(EventedModel):
     rotation_matrix: np.ndarray = Field(default_factory=lambda: np.eye(3))
 
     selected_axis_id: Optional[int]
+    selected_object_type: Optional[Literal['translator', 'rotator']]
 
     class Config:
         arbitrary_types_allowed = True
 
+    @property
+    def selected_object(self) -> Optional[Union[Translator, Rotator]]:
+        if self.selected_axis_id is None:
+            return None
+        if self.selected_object_type == 'translator':
+            [translator] = [
+                translator
+                for translator in self.translators
+                if translator.axis.id == self.selected_axis_id
+            ]
+            return translator
+        elif self.selected_object_type == 'rotator':
+            [rotator] = [
+                rotator
+                for rotator in self.rotators
+                if rotator.axis.id == self.selected_axis_id
+            ]
+            return rotator
+
+    def __setattr__(self, name: str, value: Any) -> None:
+        """Overwritten so that selected object type is unset when selection is removed."""
+        super().__setattr__(name, value)
+        if name == 'selected_axis_id' and value is None:
+            self.selected_object_type = None
+
     @validator('central_axes', pre=True)
     def central_axes_from_string(cls, v):
         if isinstance(v, str):
diff --git a/src/napari_threedee/manipulator/napari_manipulator.py b/src/napari_threedee/_backend/manipulator/napari_manipulator_backend.py
similarity index 58%
rename from src/napari_threedee/manipulator/napari_manipulator.py
rename to src/napari_threedee/_backend/manipulator/napari_manipulator_backend.py
index 4bd8952..e7cd205 100644
--- a/src/napari_threedee/manipulator/napari_manipulator.py
+++ b/src/napari_threedee/_backend/manipulator/napari_manipulator_backend.py
@@ -6,16 +6,18 @@
 from vispy.visuals.transforms import MatrixTransform
 
 from .axis_model import AxisModel
-from .model import ManipulatorModel
+from .manipulator_model import ManipulatorModel
 from .vispy_visual_data import ManipulatorVisualData
 from .vispy_manipulator_visual import ManipulatorVisual
-from napari_threedee.manipulator.drag_managers import RotatorDragManager, TranslatorDragManager
-from ..utils.napari_utils import get_vispy_node, mouse_event_to_layer_data_displayed, \
-    add_mouse_callback_safe
-from ..utils.selection_utils import select_sphere_from_click
+from napari_threedee._backend.manipulator.drag_managers import RotatorDragManager, \
+    TranslatorDragManager
+from ...utils.napari_utils import get_vispy_node, \
+    get_mouse_position_in_displayed_layer_data_coordinates, \
+    add_mouse_callback_safe, remove_mouse_callback_safe
+from ...utils.selection_utils import select_sphere_from_click
 
 
-class NapariManipulator:
+class NapariManipulatorBackend:
     def __init__(
             self,
             translator_axes: str,
@@ -23,31 +25,54 @@ def __init__(
             viewer: napari.viewer.Viewer,
             layer: Optional[Type[napari.layers.Layer]] = None,
     ):
-        self.manipulator = ManipulatorModel.from_strings(
+        self.manipulator_model = ManipulatorModel.from_strings(
             translators=translator_axes, rotators=rotator_axes
         )
-        self.vispy_visual_data = ManipulatorVisualData.from_manipulator(self.manipulator)
-        self.vispy_visual = ManipulatorVisual(parent=None, manipulator_visual_data=self.vispy_visual_data)
+        self.vispy_visual_data = ManipulatorVisualData.from_manipulator(self.manipulator_model)
+        self.vispy_visual = ManipulatorVisual(parent=None,
+                                              manipulator_visual_data=self.vispy_visual_data)
         self._viewer = viewer
         self._layer = layer
+        self.is_dragging = False
 
         if self._layer is not None:
-            self._connect_vispy_visual(self._layer)
+            self._connect_vispy_visual()
             self._connect_mouse_callback()
 
         self._connect_transformation_events()
         self.vispy_visual.update()
         self.vispy_visual.update_visuals_from_manipulator_visual_data()
 
-    def _connect_vispy_visual(self, layer: napari.layers.Layer):
-        parent = get_vispy_node(self._viewer, layer)
+    @property
+    def layer(self) -> napari.layers.Layer:
+        return self._layer
+
+    @layer.setter
+    def layer(self, layer: napari.layers.Layer):
+        self.vispy_visual.parent = None
+        if self._layer is not None:
+            self._disconnect_mouse_callback()
+        self._layer = layer
+        self._connect_vispy_visual()
+        self._connect_mouse_callback()
+
+    @property
+    def is_dragging(self) -> bool:
+        return self._is_dragging
+
+    @is_dragging.setter
+    def is_dragging(self, value: bool):
+        self._is_dragging = value
+
+    def _connect_vispy_visual(self):
+        parent = get_vispy_node(self._viewer, self.layer)
         self.vispy_visual.parent = parent
         self.vispy_visual.transform = MatrixTransform()
+        self.vispy_visual.canvas._backend.destroyed.connect(self._set_canvas_none)
 
     def _connect_transformation_events(self):
         # updating the model should update the view
-        self.manipulator.events.origin.connect(self._on_transformation_changed)
-        self.manipulator.events.origin.connect(self._on_transformation_changed)
+        self.manipulator_model.events.origin.connect(self._on_transformation_changed)
 
     def _connect_mouse_callback(self):
         add_mouse_callback_safe(
@@ -56,42 +81,54 @@ def _connect_mouse_callback(self):
             index=0
         )
 
+    def _disconnect_mouse_callback(self):
+        remove_mouse_callback_safe(
+            self.layer.mouse_drag_callbacks,
+            self._mouse_callback
+        )
+
     def _update_colors(self):
-        if self.manipulator.selected_axis_id is None:
+        if self.manipulator_model.selected_axis_id is None:
             self.vispy_visual_data.selected_axes = []
         else:
-            self.vispy_visual_data.selected_axes = [self.manipulator.selected_axis_id]
+            self.vispy_visual_data.selected_axes = [self.manipulator_model.selected_axis_id]
         self.vispy_visual.update_visuals_from_manipulator_visual_data()
 
     def _mouse_callback(self, layer, event):
         """Mouse call back for selecting and dragging a manipulator."""
         initial_layer_interactive = layer.interactive
-        click_position_data_3d, click_dir_data_3d = mouse_event_to_layer_data_displayed(layer,
-                                                                                        event)
+        click_position_data_3d, click_dir_data_3d = get_mouse_position_in_displayed_layer_data_coordinates(
+            layer, event
+        )
         drag_manager = self._drag_manager_from_click(click_position_data_3d, click_dir_data_3d)
-        if drag_manager is None:
+        if drag_manager is None:  # no translator/rotator was clicked
             return
-        layer.interactive = False  # disable layer interactivity
+
+        # setup...
+        layer.interactive = False
+        self.is_dragging = True
         self._update_colors()
+
         yield  # then start handling the mouse drag
         drag_manager.setup_drag(
             layer=layer,
             mouse_event=event,
-            translation=self.manipulator.origin,
-            rotation_matrix=self.manipulator.rotation_matrix
+            translation=self.manipulator_model.origin,
+            rotation_matrix=self.manipulator_model.rotation_matrix
         )
         while event.type == 'mouse_move':
             new_origin, new_rotation_matrix = drag_manager.update_drag(mouse_event=event)
-            with self.manipulator.events.blocked():
-                self.manipulator.origin = new_origin
-                self.manipulator.rotation_matrix = new_rotation_matrix
+            with self.manipulator_model.events.blocked():
+                self.manipulator_model.origin = new_origin
+                self.manipulator_model.rotation_matrix = new_rotation_matrix
                 self._on_transformation_changed()
             yield
 
         # reset manipulator visual and layer interactivity to original state
-        self.manipulator.selected_axis_id = None
+        self.manipulator_model.selected_axis_id = None
         self._update_colors()
         layer.interactive = initial_layer_interactive
+        self.is_dragging = False
 
     def _drag_manager_from_click(
             self, click_point: np.ndarray, view_direction: np.ndarray
@@ -114,9 +151,9 @@ def _drag_manager_from_click(
         """
         handle_data = self.vispy_visual_data.translator_handle_data + self.vispy_visual_data.rotator_handle_data
         untransformed_handle_points = einops.rearrange(handle_data.points, 'b xyz -> b xyz 1')
-        rotation, translation = self.manipulator.rotation_matrix, einops.rearrange(
-            self.manipulator.origin, 'xyz -> xyz 1')  # Rotation, Translation...
-        transformed_handle_points = (rotation @ untransformed_handle_points) + translation
+        rotation_matrix = self.manipulator_model.rotation_matrix
+        translation = einops.rearrange(self.manipulator_model.origin, 'xyz -> xyz 1')
+        transformed_handle_points = (rotation_matrix @ untransformed_handle_points) + translation
         selection = select_sphere_from_click(
             click_point=click_point,
             view_direction=view_direction,
@@ -126,19 +163,21 @@ def _drag_manager_from_click(
         if selection is None:
             return None
 
-        axis_id = handle_data.axis_identifiers[selection]
-        self.manipulator.selected_axis_id = axis_id
-        axis_vector = AxisModel.from_id(axis_id).vector
-        rotated_axis_vector = self.manipulator.rotation_matrix @ axis_vector
+        selected_axis_id = handle_data.axis_identifiers[selection]
+        self.manipulator_model.selected_axis_id = selected_axis_id
+        axis_vector = AxisModel.from_id(selected_axis_id).vector
+        rotated_axis_vector = self.manipulator_model.rotation_matrix @ axis_vector
 
         # is the clicked point  a translator or a rotator?
         point_is_translator = np.zeros(len(handle_data.points), dtype=bool)
         point_is_translator[:len(self.vispy_visual_data.translator_handle_data)] = True
         point_is_translator = point_is_translator[selection] == True  # np.array(True) is not True
         if point_is_translator:
+            self.manipulator_model.selected_object_type = 'translator'
             self.vispy_visual_data.translator_is_selected = True
             drag_manager = TranslatorDragManager(translation_vector=rotated_axis_vector)
         else:
+            self.manipulator_model.selected_object_type = 'rotator'
             self.vispy_visual_data.rotator_is_selected = True
             drag_manager = RotatorDragManager(rotation_vector=rotated_axis_vector)
         return drag_manager
@@ -149,8 +188,8 @@ def _on_transformation_changed(self) -> None:
         if self._layer is None:
             return
         # convert NumPy axis ordering to VisPy axis ordering
-        translation = self.manipulator.origin[::-1]
-        rotation_matrix = self.manipulator.rotation_matrix[::-1, ::-1].T
+        translation = self.manipulator_model.origin[::-1]
+        rotation_matrix = self.manipulator_model.rotation_matrix[::-1, ::-1].T
 
         # Embed rotation matrix in the top left corner of a 4x4 affine matrix
         affine_matrix = np.eye(4)
@@ -159,3 +198,12 @@ def _on_transformation_changed(self) -> None:
 
         # update transform on vispy manipulator
         self.vispy_visual.transform.matrix = affine_matrix
+
+    def _on_ndisplay_change(self, event=None):
+        if self._viewer.dims.ndisplay == 2:
+            self._disconnect_mouse_callback()
+        else:
+            self._connect_mouse_callback()
+
+    def _set_canvas_none(self):
+        self.vispy_visual._set_canvas(None)
diff --git a/src/napari_threedee/manipulator/rotator.py b/src/napari_threedee/_backend/manipulator/rotator.py
similarity index 95%
rename from src/napari_threedee/manipulator/rotator.py
rename to src/napari_threedee/_backend/manipulator/rotator.py
index cad8a40..31dbdce 100644
--- a/src/napari_threedee/manipulator/rotator.py
+++ b/src/napari_threedee/_backend/manipulator/rotator.py
@@ -30,4 +30,6 @@ def from_axis_set(cls, axes: AxisSet):
 
     @classmethod
     def from_string(cls, axes: str):
+        if axes == '':
+            return None
         return cls.from_axis_set(AxisSet.from_string(axes))
diff --git a/src/napari_threedee/manipulator/translator.py b/src/napari_threedee/_backend/manipulator/translator.py
similarity index 86%
rename from src/napari_threedee/manipulator/translator.py
rename to src/napari_threedee/_backend/manipulator/translator.py
index 0c66435..c25ea45 100644
--- a/src/napari_threedee/manipulator/translator.py
+++ b/src/napari_threedee/_backend/manipulator/translator.py
@@ -3,8 +3,8 @@
 import numpy as np
 from pydantic import BaseModel
 
-from napari_threedee.manipulator._interface import _Grabbable
-from napari_threedee.manipulator.axis_model import AxisSet, AxisModel
+from napari_threedee._backend.manipulator._interface import _Grabbable
+from napari_threedee._backend.manipulator.axis_model import AxisSet, AxisModel
 
 
 class Translator(BaseModel, _Grabbable):
@@ -41,6 +41,8 @@ def from_axis_set(cls, axes: AxisSet):
 
     @classmethod
     def from_string(cls, axes: str):
+        if axes == '':
+            return None
         return cls.from_axis_set(AxisSet.from_string(axes))
 
     def __str__(self) -> str:
diff --git a/src/napari_threedee/manipulator/utils.py b/src/napari_threedee/_backend/manipulator/utils.py
similarity index 100%
rename from src/napari_threedee/manipulator/utils.py
rename to src/napari_threedee/_backend/manipulator/utils.py
diff --git a/src/napari_threedee/manipulator/vispy_manipulator_visual.py b/src/napari_threedee/_backend/manipulator/vispy_manipulator_visual.py
similarity index 84%
rename from src/napari_threedee/manipulator/vispy_manipulator_visual.py
rename to src/napari_threedee/_backend/manipulator/vispy_manipulator_visual.py
index b3ee591..b0ae950 100644
--- a/src/napari_threedee/manipulator/vispy_manipulator_visual.py
+++ b/src/napari_threedee/_backend/manipulator/vispy_manipulator_visual.py
@@ -1,4 +1,4 @@
-from .model import ManipulatorModel
+from .manipulator_model import ManipulatorModel
 from .vispy_visual_data import ManipulatorVisualData
 
 import numpy as np
@@ -7,6 +7,8 @@
 
 
 class ManipulatorVisual(Compound):
+    _line_antialiasing_width: float = 2
+
     def __init__(self, parent, manipulator_visual_data: ManipulatorVisualData):
         super().__init__([Line(), Line(), Markers(), Markers(), Line(), Markers()], parent=parent)
         self.unfreeze()
@@ -15,21 +17,25 @@ def __init__(self, parent, manipulator_visual_data: ManipulatorVisualData):
 
         # set up the central axis visual
         self.origin_marker_visual.spherical = True
+        self.origin_marker_visual.antialias = self._line_antialiasing_width
         self.origin_marker_visual.set_data(
             pos=np.array([[0, 0, 0]]),
             face_color=[0.7, 0.7, 0.7, 1],
             size=10
         )
+        self.central_axis_visual.antialias = self._line_antialiasing_width
 
         # set up the rotator visual
         self.rotator_handle_visual.spherical = True
         self.rotator_handle_visual.scaling = True
-        self.rotator_handle_visual.antialias = 0
+        self.rotator_handle_visual.antialias = self._line_antialiasing_width
+        self.rotator_line_visual.antialias = self._line_antialiasing_width
 
         # set up the translator visual
         self.translator_handle_visual.spherical = True
         self.translator_handle_visual.scaling = True
-        self.translator_handle_visual.antialias = 0
+        self.translator_handle_visual.antialias = self._line_antialiasing_width
+        self.translator_line_visual.antialias = self._line_antialiasing_width
 
     @classmethod
     def from_manipulator(cls, manipulator: ManipulatorModel):
@@ -79,6 +85,8 @@ def _update_central_axis_visual(self):
         )
 
     def _update_translator_visuals(self):
+        if self.manipulator_visual_data.translator_line_data is None:
+            return
         self.translator_line_visual.set_data(
             pos=self.manipulator_visual_data.translator_line_data.vertices[:, ::-1],
             connect=self.manipulator_visual_data.translator_line_data.connections,
@@ -92,6 +100,8 @@ def _update_translator_visuals(self):
         )
 
     def _update_rotator_visuals(self):
+        if self.manipulator_visual_data.rotator_line_data is None:
+            return
         self.rotator_line_visual.set_data(
             pos=self.manipulator_visual_data.rotator_line_data.vertices[:, ::-1],
             connect=self.manipulator_visual_data.rotator_line_data.connections,
diff --git a/src/napari_threedee/manipulator/vispy_visual_data.py b/src/napari_threedee/_backend/manipulator/vispy_visual_data.py
similarity index 89%
rename from src/napari_threedee/manipulator/vispy_visual_data.py
rename to src/napari_threedee/_backend/manipulator/vispy_visual_data.py
index 5387b21..3c399b2 100644
--- a/src/napari_threedee/manipulator/vispy_visual_data.py
+++ b/src/napari_threedee/_backend/manipulator/vispy_visual_data.py
@@ -1,11 +1,11 @@
-from typing import List
+from typing import List, Optional
 
 import numpy as np
 from napari.utils.geometry import rotation_matrix_from_vectors_3d
 from pydantic import BaseModel
 
 from .central_axis import CentralAxis, CentralAxisSet
-from .model import ManipulatorModel
+from .manipulator_model import ManipulatorModel
 from .rotator import Rotator, RotatorSet
 from .translator import TranslatorSet, Translator
 from .utils import ModelWithSettableProperties
@@ -73,7 +73,7 @@ def from_rotator_set(cls, rotators: RotatorSet, n_segments: int = 64):
         return sum(cls.from_rotator(rotator, n_segments=n_segments) for rotator in rotators)
 
     def __add__(self, other):
-        if other == 0:
+        if other == 0 or other is None:
             return self
         vertices = np.concatenate([self.vertices, other.vertices], axis=0)
         reindexed_connections = other.connections + len(self.vertices)
@@ -142,7 +142,7 @@ def __radd__(self, other):
         return self.__add__(other)
 
     def __add__(self, other):
-        if other == 0:
+        if other == 0 or other is None:
             return self
         points = np.concatenate([self.points, other.points], axis=0)
         colors = np.concatenate([self.colors, other.colors], axis=0)
@@ -155,11 +155,11 @@ def __len__(self):
 
 class ManipulatorVisualData(ModelWithSettableProperties):
     """Data required to render a manipulator"""
-    central_axis_line_data: ManipulatorLineData
-    translator_line_data: ManipulatorLineData
-    translator_handle_data: ManipulatorHandleData
-    rotator_line_data: ManipulatorLineData
-    rotator_handle_data: ManipulatorHandleData
+    central_axis_line_data: Optional[ManipulatorLineData]
+    translator_line_data: Optional[ManipulatorLineData]
+    translator_handle_data: Optional[ManipulatorHandleData]
+    rotator_line_data: Optional[ManipulatorLineData]
+    rotator_handle_data: Optional[ManipulatorHandleData]
 
     selected_axes: List[int] = []
     translator_is_selected: bool = False
@@ -180,10 +180,20 @@ def rotator_is_selected(self, value: bool):
     @classmethod
     def from_manipulator(cls, manipulator: ManipulatorModel):
         central_axis_line_data = ManipulatorLineData.from_central_axis_set(manipulator.central_axes)
-        translator_line_data = ManipulatorLineData.from_translator_set(manipulator.translators)
-        rotator_line_data = ManipulatorLineData.from_rotator_set(manipulator.rotators)
-        translator_handle_data = ManipulatorHandleData.from_translator_set(manipulator.translators)
-        rotator_handle_data = ManipulatorHandleData.from_rotator_set(manipulator.rotators)
+        # translators
+        if manipulator.translators is None:
+            translator_line_data = None
+            translator_handle_data = None
+        else:
+            translator_line_data = ManipulatorLineData.from_translator_set(manipulator.translators)
+            translator_handle_data = ManipulatorHandleData.from_translator_set(manipulator.translators)
+        # rotators
+        if manipulator.rotators is None:
+            rotator_line_data = None
+            rotator_handle_data = None
+        else:
+            rotator_line_data = ManipulatorLineData.from_rotator_set(manipulator.rotators)
+            rotator_handle_data = ManipulatorHandleData.from_rotator_set(manipulator.rotators)
         return cls(
             central_axis_line_data=central_axis_line_data,
             translator_line_data=translator_line_data,
@@ -248,7 +258,8 @@ def rotator_handle_colors(self) -> np.ndarray:
     def _selected_central_axis_vertices(self) -> np.ndarray:
         if not self.translator_is_selected:
             return np.zeros(len(self.central_axis_line_data)).astype(bool)
-        return np.isin(self.central_axis_line_data.axis_identifiers, test_elements=self.selected_axes)
+        return np.isin(self.central_axis_line_data.axis_identifiers,
+                       test_elements=self.selected_axes)
 
     @property
     def _selected_translator_line_vertices(self) -> np.ndarray:
@@ -260,7 +271,8 @@ def _selected_translator_line_vertices(self) -> np.ndarray:
     def _selected_translator_handle_points(self) -> np.ndarray:
         if not self.translator_is_selected:
             return np.zeros(len(self.translator_handle_data)).astype(bool)
-        return np.isin(self.translator_handle_data.axis_identifiers, test_elements=self.selected_axes)
+        return np.isin(self.translator_handle_data.axis_identifiers,
+                       test_elements=self.selected_axes)
 
     @property
     def _selected_rotator_line_vertices(self) -> np.ndarray:
diff --git a/src/napari_threedee/_backend/threedee_model.py b/src/napari_threedee/_backend/threedee_model.py
new file mode 100644
index 0000000..af84759
--- /dev/null
+++ b/src/napari_threedee/_backend/threedee_model.py
@@ -0,0 +1,46 @@
+from abc import ABC, abstractmethod
+
+
+class ThreeDeeModel(ABC):
+    """Base class for manipulators and annotators.
+
+    By adhering to the interface defined by this class, widgets can be automatically generated for
+    manipulators and annotators.
+
+    To implement:
+        - the __init__() should take the viewer as the first argument and all
+        keyword arguments should have default values.
+        - implement the set_layers() method
+        - implement the _on_enable() callback
+        - implement the _on_disable() callback
+    """
+
+    @property
+    def enabled(self):
+        return self._enabled
+
+    @enabled.setter
+    def enabled(self, value: bool):
+        self._on_enable() if value is True else self._on_disable()
+        self._enabled = value
+
+    @abstractmethod
+    def set_layers(self, *args):
+        """This method should set layer attributes on the manipulator/annotator.
+        Arguments to this function should be typed as napari layers.
+        """
+        pass
+
+    @abstractmethod
+    def _on_enable(self):
+        """This method should 'activate' the manipulator/annotator,
+        setting state and connecting callbacks.
+        """
+        pass
+
+    @abstractmethod
+    def _on_disable(self):
+        """This method should 'deactivate' the manipulator/annotator,
+        updating state and disconnecting callbacks.
+        """
+        pass
diff --git a/src/napari_threedee/_backend/threedee_widget_model.py b/src/napari_threedee/_backend/threedee_widget_model.py
new file mode 100644
index 0000000..0dacc7a
--- /dev/null
+++ b/src/napari_threedee/_backend/threedee_widget_model.py
@@ -0,0 +1,47 @@
+from typing import Type
+
+import napari
+from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout
+from napari.utils.events import Event
+
+from napari_threedee._backend.threedee_model import ThreeDeeModel
+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,
+                 **kwargs):
+        super().__init__(flags, *args, **kwargs)
+        self.model: ThreeDeeModel = model_class(viewer)
+        self.viewer = viewer
+
+        self._layer_selection_widget = generate_populated_layer_selection_widget(
+            func=self.model.set_layers, viewer=viewer
+        )
+        self._layer_selection_widget()
+
+        # start in the disabled state
+        self.model.enabled = False
+        self.activate_button = QPushButton('activate')
+        self.activate_button.setCheckable(True)
+        self.activate_button.setChecked(False)
+
+        self.setLayout(QVBoxLayout())
+        self.layout().addWidget(self.layer_selection_widget)
+        self.layout().addWidget(self.activate_button)
+
+        self.activate_button.clicked.connect(self.on_activate_button_click)
+
+    @property
+    def layer_selection_widget(self) -> QWidget:
+        return self._layer_selection_widget.native
+
+    def on_activate_button_click(self, event: Event):
+        if self.activate_button.isChecked() is True:
+            self.model.enabled = True
+            self.activate_button.setText('deactivate')
+        else:
+            self.model.enabled = False
+            self.activate_button.setText('activate')
diff --git a/src/napari_threedee/_base_model.py b/src/napari_threedee/_base_model.py
deleted file mode 100644
index 49c357b..0000000
--- a/src/napari_threedee/_base_model.py
+++ /dev/null
@@ -1,89 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import Type
-
-import napari
-from napari.utils.events import Event
-from qtpy.QtWidgets import QWidget, QPushButton, QVBoxLayout
-
-from .utils.napari_utils import generate_populated_layer_selection_widget
-
-
-class ThreeDeeModel(ABC):
-    """Base class for manipulators and annotators.
-
-    To implement:
-        - the __init__() should take the viewer as the first argument and all
-        keyword arguments should have default values.
-        - implement the set_layers() method
-        - implement the _on_enable() callback
-        - implement the _on_disable() callback
-    """
-
-    @property
-    def enabled(self):
-        return self._enabled
-
-    @enabled.setter
-    def enabled(self, value: bool):
-        self._on_enable() if value is True else self._on_disable()
-        self._enabled = value
-
-    @abstractmethod
-    def set_layers(self, *args):
-        """This method should set layer attributes on the manipulator/annotator.
-        Arguments to this function should be typed as napari layers.
-        """
-        pass
-
-    @abstractmethod
-    def _on_enable(self):
-        """This method should 'activate' the manipulator/annotator,
-        setting state and connecting callbacks.
-        """
-        pass
-
-    @abstractmethod
-    def _on_disable(self):
-        """This method should 'deactivate' the manipulator/annotator,
-        updating state and disconnecting callbacks.
-        """
-        pass
-
-
-class QtThreeDeeWidgetBase(QWidget):
-    """Base class for GUI elements in napari-threedee."""
-
-    def __init__(self, model: Type[ThreeDeeModel], viewer: napari.Viewer, flags=None, *args,
-                 **kwargs):
-        super().__init__(flags, *args, **kwargs)
-        self.model = model(viewer)
-        self.viewer = viewer
-
-        self._layer_selection_widget = generate_populated_layer_selection_widget(
-            func=self.model.set_layers, viewer=viewer
-        )
-        self._layer_selection_widget()
-
-        # start in the disabled state
-        self.model.enabled = False
-        self.activate_button = QPushButton('activate')
-        self.activate_button.setCheckable(True)
-        self.activate_button.setChecked(False)
-
-        self.setLayout(QVBoxLayout())
-        self.layout().addWidget(self.layer_selection_widget)
-        self.layout().addWidget(self.activate_button)
-
-        self.activate_button.clicked.connect(self.on_activate_button_click)
-
-    @property
-    def layer_selection_widget(self) -> QWidget:
-        return self._layer_selection_widget.native
-
-    def on_activate_button_click(self, event: Event):
-        if self.activate_button.isChecked() is True:
-            self.model.enabled = True
-            self.activate_button.setText('deactivate')
-        else:
-            self.model.enabled = False
-            self.activate_button.setText('activate')
diff --git a/src/napari_threedee/_dock_widgets.py b/src/napari_threedee/_dock_widgets.py
deleted file mode 100644
index 3f0276b..0000000
--- a/src/napari_threedee/_dock_widgets.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from .manipulators.qt_manipulators import (
-    QtRenderPlaneManipulatorWidget,
-    QtPointManipulatorWidget,
-    QtLayerManipulatorWidget,
-)
-from .annotators.qt_annotators import QtPlanePointAnnotatorWidget
-from .annotators.qt_annotators import QtFilamentAnnotatorWidget
-from .lighting.qt_lighting_control import QtLightingControlWidget
\ No newline at end of file
diff --git a/src/napari_threedee/_manipulator_base.py b/src/napari_threedee/_manipulator_base.py
deleted file mode 100644
index 8e4fa7a..0000000
--- a/src/napari_threedee/_manipulator_base.py
+++ /dev/null
@@ -1,270 +0,0 @@
-from typing import Optional, Type, Union
-
-import napari
-import numpy as np
-from pydantic import BaseModel
-from vispy.visuals.transforms import MatrixTransform
-
-from napari_threedee.manipulator.manipulator import ManipulatorModel
-from napari_threedee.manipulator.drag_managers import RotatorDragManager, TranslatorDragManager
-from napari_threedee.manipulators._manipulator_visual import ManipulatorVisual
-from napari_threedee.utils.napari_utils import get_vispy_node, add_mouse_callback_safe, mouse_event_to_layer_data_displayed
-from napari_threedee.utils.selection_utils import select_sphere_from_click
-
-
-class Manipulator(BaseModel):
-    model: ManipulatorModel
-    viewer: napari.viewer.Viewer
-    visual: ManipulatorVisual = ManipulatorVisual(parent=None)
-    layer: Optional[napari.layers.Layer] = None
-
-
-
-        self._viewer = viewer
-        self._layer = layer
-        if self._layer is not None:
-            self._connect_vispy_visual(self._layer)
-
-        self.update_visual()
-
-        add_mouse_callback_safe(
-            self._layer.mouse_drag_callbacks,
-            self._mouse_callback,
-            index=0
-        )
-
-    @property
-    def rotation_matrix(self) -> np.ndarray:
-        return self._rotation_matrix
-
-    @rotation_matrix.setter
-    def rotation_matrix(self, rotation_matrix: np.ndarray) -> None:
-        self._rotation_matrix = rotation_matrix
-        self._on_transformation_changed()
-
-    @property
-    def center_point(self) -> np.ndarray:
-        return self._center_point
-
-    @center_point.setter
-    def center_point(self, center_point) -> None:
-        self._center_point = center_point
-        self._on_transformation_changed()
-
-    def _connect_vispy_visual(self, layer: Type[napari.layers.Layer]) -> None:
-        # get the callback_list node to pass as the parent of the manipulator
-        parent = get_vispy_node(self._viewer, layer)
-        self.visual.parent = parent
-
-        self.visual.transform = MatrixTransform()
-        # self.node.order = self._vispy_visual_order
-
-    def _update_central_axes_visual(self) -> None:
-        # set the axes
-        self.visual.central_axes_visual.set_data(
-            pos=self.central_axes.vertices[:, ::-1],
-            connect=self.central_axes.connections,
-            color=self.central_axes.rendered_vertex_colors,
-            width=10
-        )
-
-        # set the accenting points that cap the axes
-        self.visual.origin_marker_visual.set_data(
-            pos=np.array([[0, 0, 0]]),
-            face_color=[0.7, 0.7, 0.7, 1],
-            size=10
-        )
-
-    def _update_rotators_visual(self) -> None:
-        rotator_arc_colors, rotator_handle_colors = self.rotators.rendered_rotator_colors
-        self.visual.rotator_line_visual.set_data(
-            pos=self.rotators.vertices[:, ::-1],
-            connect=self.rotators.connections,
-            color=rotator_arc_colors,
-            width=self.rotators.line_width
-        )
-
-        # update the handle data
-        self.visual.rotator_handle_visual.set_data(
-            pos=self.rotators.handle_points[:, ::-1],
-            face_color=rotator_handle_colors,
-            size=self.rotators.handle_size,
-            edge_color=np.array([0, 0, 0, 0])
-        )
-
-    def _update_translators_visual(self) -> None:
-        translator_axis_colors, translator_handle_colors = self.translators.rendered_translator_colors
-
-        # set the axes
-        self.visual.translator_line_visual.set_data(
-            pos=self.translators.vertices[:, ::-1],
-            color=translator_axis_colors,
-            connect="segments",
-            width=self.translators.line_width,
-        )
-
-        # set the handles
-        self.visual.translator_handle_visual.set_data(
-            pos=self.translators.handle_points[:, ::-1],
-            face_color=translator_handle_colors, size=self.translators.handle_size, edge_color=np.array([0, 0, 0, 0])
-        )
-
-    def update_visual(self):
-        self._update_rotators_visual()
-        self._update_translators_visual()
-        self._update_central_axes_visual()
-
-    def _mouse_callback(self, layer, event):
-        """Mouse call back for selecting and dragging a manipulator."""
-        # get the initial state for layer.interactive so we can return
-        # at the end of the callback
-        initial_layer_interactive = layer.interactive
-
-        # get click position and direction in data coordinates
-        click_position_data_3d, click_dir_data_3d = mouse_event_to_layer_data_displayed(layer, event)
-
-        # determine which, if any rotator/translator was selected
-        drag_manager = self._process_mouse_event_click(click_position_data_3d, click_dir_data_3d)
-
-        if drag_manager is None:
-            # return early if no layer was selected
-            return
-
-        # set turn off interaction so drags don't move the camera
-        layer.interactive = False
-
-        yield
-
-        # start the drag
-        drag_manager.setup_drag(
-            layer=layer,
-            mouse_event=event,
-            translation=self.center_point,
-            rotation_matrix=self.rotation_matrix
-        )
-
-        while event.type == 'mouse_move':
-            # process the drag
-            updated_center_point, updated_rotation_matrix = drag_manager.update_drag(
-                mouse_event=event
-            )
-
-            # update the transformation
-            # use the private _center_point to avoid the _on_transform_changed()
-            # being called twice by the setters
-            self._center_point = updated_center_point
-            self.rotation_matrix = updated_rotation_matrix
-            yield
-
-        # unhighlight the manipulator components
-        self.rotators.highlighted_rotators = None
-        self.translators.highlighted_translators = None
-        self.central_axes.highlighted = None
-        self.update_visual()
-
-        # reset layer interaction to original state
-        layer.interactive = initial_layer_interactive
-
-    def _process_mouse_event_click(self, click_position: np.ndarray, view_direction: np.ndarray) -> Optional[RotatorDragManager]:
-        """Determine which rotator or translator was selected and return the appropriate drag manager.
-
-        Parameters
-        ----------
-        click_position : np.ndarray
-            The click position in displayed data coordinates.
-        view_direction : np.ndarray
-            The vector pointing in the direction of the camera in displayed data coordinates.
-
-        Returns
-        -------
-        drag_manager : Optional[RotatorDragManager]
-            The DragManager object for the translator or rotator selected.
-            Returns None if no rotator or translator was clicked.
-        """
-
-        # identify clicked rotator/translator
-        drag_manager = self._drag_manager_from_click(click_point=click_position, view_direction=view_direction)
-
-        if drag_manager is not None:
-            if isinstance(drag_manager, RotatorDragManager):
-                self.rotators.highlighted_rotators = [drag_manager.axis_index]
-                self.translators.highlighted_translators = []
-                self.central_axes.highlighted = PERPENDICULAR_AXES[drag_manager.axis_index]
-            else:
-                self.rotators.highlighted_rotators = []
-                self.translators.highlighted_translators = [drag_manager.axis_index]
-                self.central_axes.highlighted = [drag_manager.axis_index]
-        else:
-            self.rotators.highlighted_rotators = None
-            self.central_axes.highlighted = None
-        self.update_visual()
-
-        return drag_manager
-
-    def _check_if_manipulator_clicked(self, click_point: np.ndarray, view_direction: np.ndarray) -> Optional[Union[RotatorDragManager, TranslatorDragManager]]:
-        """Determine if a translator or rotator was clicked on.
-
-        Parameters
-        ----------
-        click_point : np.ndarray
-            The click point in data coordinates
-        view_direction : np.ndarray
-            The vector in the direction of the view (click).
-
-        Returns
-        -------
-        selected_translator : Optional[int]
-            If a translator was clicked, returns the index of the translator.
-            If no translator was clicked, returns None.
-        selected_rotator : Optional[int]
-            If a rotator was clicked, returns the index of the rotator.
-            If no rotator was clicked, returns None.
-        """
-        # concatentate and transform all handle points
-        untransformed_handle_points = np.concatenate(
-            [self.rotators.handle_points,
-             self.translators.handle_points]
-        )
-        current_handle_points = (untransformed_handle_points @ self.rotation_matrix.T) + self.center_point
-        selection = select_sphere_from_click(
-            click_point=click_point,
-            view_direction=view_direction,
-            sphere_centroids=current_handle_points,
-            sphere_diameter=self.rotators.handle_size
-        )
-
-        if selection is None:
-            return None
-
-        n_rotators = self.rotators.n_rotators
-        if selection < n_rotators:
-            # a rotator was selected
-            untransformed_normal_vector = self.rotators.basis_vectors[selection]
-            normal_vector = self.rotation_matrix.dot(untransformed_normal_vector)
-            return RotatorDragManager(rotation_vector=normal_vector, axis_index=selection)
-        else:
-            # a translator was selected
-            translator_index = selection - n_rotators
-            untransformed_normal_vector = self.translators.basis_vectors[translator_index]
-            normal_vector = self.rotation_matrix.dot(untransformed_normal_vector)
-            return TranslatorDragManager(translation_vector=normal_vector, axis_index=translator_index)
-
-    def _on_transformation_changed(self) -> None:
-        """Update the manipulator visual transformation based on the
-        manipulator state
-        """
-        if self._layer is None:
-            # do not do anything if the layer has not been set
-            return
-        # convert NumPy axis ordering to VisPy axis ordering
-        # by reversing the axes order and flipping the linear
-        # matrix
-        translation = self.center_point[::-1]
-        rotation_matrix = self.rotation_matrix[::-1, ::-1].T
-
-        # Embed in the top left corner of a 4x4 affine matrix
-        affine_matrix = np.eye(4)
-        affine_matrix[:3, :3] = rotation_matrix
-        affine_matrix[-1, :3] = translation
-
-        self.visual.transform.matrix = affine_matrix
diff --git a/src/napari_threedee/annotators/qt_annotators.py b/src/napari_threedee/annotators/_qt/__init__.py
similarity index 52%
rename from src/napari_threedee/annotators/qt_annotators.py
rename to src/napari_threedee/annotators/_qt/__init__.py
index dc172aa..d23642d 100644
--- a/src/napari_threedee/annotators/qt_annotators.py
+++ b/src/napari_threedee/annotators/_qt/__init__.py
@@ -1,2 +1,2 @@
 from .qt_plane_point_annotator import QtPlanePointAnnotatorWidget
-from .qt_spline_annotator import QtFilamentAnnotatorWidget
+from .qt_spline_annotator import QtSplineAnnotatorWidget
diff --git a/src/napari_threedee/annotators/_qt/qt_plane_point_annotator.py b/src/napari_threedee/annotators/_qt/qt_plane_point_annotator.py
new file mode 100644
index 0000000..2d24040
--- /dev/null
+++ b/src/napari_threedee/annotators/_qt/qt_plane_point_annotator.py
@@ -0,0 +1,10 @@
+import napari
+
+from napari_threedee._backend.threedee_widget_model import QtThreeDeeWidgetBase
+
+from napari_threedee.annotators.plane_point_annotator import PlanePointAnnotator
+
+
+class QtPlanePointAnnotatorWidget(QtThreeDeeWidgetBase):
+    def __init__(self, viewer: napari.Viewer):
+        super().__init__(model_class=PlanePointAnnotator, viewer=viewer)
diff --git a/src/napari_threedee/annotators/qt_spline_annotator.py b/src/napari_threedee/annotators/_qt/qt_spline_annotator.py
similarity index 91%
rename from src/napari_threedee/annotators/qt_spline_annotator.py
rename to src/napari_threedee/annotators/_qt/qt_spline_annotator.py
index 6ca1951..7977c7b 100644
--- a/src/napari_threedee/annotators/qt_spline_annotator.py
+++ b/src/napari_threedee/annotators/_qt/qt_spline_annotator.py
@@ -2,14 +2,14 @@
 from napari.utils.events import Event
 from qtpy.QtWidgets import QPushButton, QGroupBox, QVBoxLayout, QCheckBox, QSpinBox, QLabel, QHBoxLayout
 
-from .._base_model import QtThreeDeeWidgetBase
+from napari_threedee._backend.threedee_widget_model import QtThreeDeeWidgetBase
 
-from .spline_annotator import SplineAnnotator
+from napari_threedee.annotators.spline_annotator import SplineAnnotator
 
 
-class QtFilamentAnnotatorWidget(QtThreeDeeWidgetBase):
+class QtSplineAnnotatorWidget(QtThreeDeeWidgetBase):
     def __init__(self, viewer: napari.Viewer):
-        super().__init__(model=SplineAnnotator, viewer=viewer)
+        super().__init__(model_class=SplineAnnotator, viewer=viewer)
 
         self.auto_fit_checkbox = QCheckBox("automatically fit spline")
         self.auto_fit_checkbox.clicked.connect(self._on_auto_fit)
diff --git a/src/napari_threedee/annotators/plane_point_annotator.py b/src/napari_threedee/annotators/plane_point_annotator.py
index 3063460..ce64522 100644
--- a/src/napari_threedee/annotators/plane_point_annotator.py
+++ b/src/napari_threedee/annotators/plane_point_annotator.py
@@ -3,9 +3,9 @@
 import napari
 from napari.layers import Image, Points
 
-from .._base_model import ThreeDeeModel
+from napari_threedee._backend.threedee_model import ThreeDeeModel
 from ..mouse_callbacks import add_point_on_plane
-from ..utils.napari_utils import add_mouse_callback_safe, remove_mouse_callback_safe
+from napari_threedee.utils.napari_utils import add_mouse_callback_safe, remove_mouse_callback_safe
 
 
 class PlanePointAnnotator(ThreeDeeModel):
diff --git a/src/napari_threedee/annotators/qt_plane_point_annotator.py b/src/napari_threedee/annotators/qt_plane_point_annotator.py
deleted file mode 100644
index 120a4e1..0000000
--- a/src/napari_threedee/annotators/qt_plane_point_annotator.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import napari
-
-from .._base_model import QtThreeDeeWidgetBase
-
-from .plane_point_annotator import PlanePointAnnotator
-
-
-class QtPlanePointAnnotatorWidget(QtThreeDeeWidgetBase):
-    def __init__(self, viewer: napari.Viewer):
-        super().__init__(model=PlanePointAnnotator, viewer=viewer)
diff --git a/src/napari_threedee/annotators/spline_annotator.py b/src/napari_threedee/annotators/spline_annotator.py
index 08f8a63..c74481d 100644
--- a/src/napari_threedee/annotators/spline_annotator.py
+++ b/src/napari_threedee/annotators/spline_annotator.py
@@ -9,9 +9,9 @@
 from pydantic import validator, PrivateAttr
 from scipy.interpolate import splprep, splev
 
-from .._base_model import ThreeDeeModel
+from napari_threedee._backend.threedee_model import ThreeDeeModel
 from ..mouse_callbacks import add_point_on_plane
-from ..utils.napari_utils import add_mouse_callback_safe, remove_mouse_callback_safe
+from napari_threedee.utils.napari_utils import add_mouse_callback_safe, remove_mouse_callback_safe
 
 from typing import Tuple, Union, Optional
 
diff --git a/src/napari_threedee/dock_widgets.py b/src/napari_threedee/dock_widgets.py
new file mode 100644
index 0000000..b0cb4e7
--- /dev/null
+++ b/src/napari_threedee/dock_widgets.py
@@ -0,0 +1,14 @@
+from .manipulators._qt import (
+    QtLayerManipulatorWidget,
+    QtPointManipulatorWidget,
+    QtRenderPlaneManipulatorWidget
+)
+
+from .annotators._qt import (
+    QtPlanePointAnnotatorWidget,
+    QtSplineAnnotatorWidget,
+)
+
+from .lighting._qt import (
+    QtLightingControlWidget
+)
diff --git a/src/napari_threedee/lighting/_qt/__init__.py b/src/napari_threedee/lighting/_qt/__init__.py
new file mode 100644
index 0000000..8dcb0f1
--- /dev/null
+++ b/src/napari_threedee/lighting/_qt/__init__.py
@@ -0,0 +1,1 @@
+from .qt_lighting_control import QtLightingControlWidget
\ No newline at end of file
diff --git a/src/napari_threedee/lighting/qt_lighting_control.py b/src/napari_threedee/lighting/_qt/qt_lighting_control.py
similarity index 95%
rename from src/napari_threedee/lighting/qt_lighting_control.py
rename to src/napari_threedee/lighting/_qt/qt_lighting_control.py
index 42ad2ed..d35710c 100644
--- a/src/napari_threedee/lighting/qt_lighting_control.py
+++ b/src/napari_threedee/lighting/_qt/qt_lighting_control.py
@@ -2,7 +2,7 @@
 from magicgui import magicgui
 from qtpy.QtWidgets import QPushButton, QWidget, QVBoxLayout
 
-from .lighting_control import LightingControl
+from napari_threedee.lighting.lighting_control import LightingControl
 
 
 class QtLightingControlWidget(QWidget):
diff --git a/src/napari_threedee/lighting/lighting_control.py b/src/napari_threedee/lighting/lighting_control.py
index acdb26f..867e89c 100644
--- a/src/napari_threedee/lighting/lighting_control.py
+++ b/src/napari_threedee/lighting/lighting_control.py
@@ -3,8 +3,8 @@
 import numpy as np
 import napari
 
-from .._base_model import ThreeDeeModel
-from ..utils.napari_utils import add_mouse_callback_safe, get_napari_visual, remove_mouse_callback_safe
+from napari_threedee._backend.threedee_model import ThreeDeeModel
+from napari_threedee.utils.napari_utils import get_napari_visual
 
 
 class LightingControl(ThreeDeeModel):
diff --git a/src/napari_threedee/manipulators/_qt/__init__.py b/src/napari_threedee/manipulators/_qt/__init__.py
new file mode 100644
index 0000000..91c71c6
--- /dev/null
+++ b/src/napari_threedee/manipulators/_qt/__init__.py
@@ -0,0 +1,3 @@
+from .qt_layer_manipulator import QtLayerManipulatorWidget
+from .qt_point_manipulator import QtPointManipulatorWidget
+from .qt_render_plane_manipulator import QtRenderPlaneManipulatorWidget
diff --git a/src/napari_threedee/manipulators/_qt/qt_layer_manipulator.py b/src/napari_threedee/manipulators/_qt/qt_layer_manipulator.py
new file mode 100644
index 0000000..6895f2b
--- /dev/null
+++ b/src/napari_threedee/manipulators/_qt/qt_layer_manipulator.py
@@ -0,0 +1,9 @@
+import napari
+
+from ..layer_manipulator import LayerManipulator
+from ..._backend.threedee_widget_model import QtThreeDeeWidgetBase
+
+
+class QtLayerManipulatorWidget(QtThreeDeeWidgetBase):
+    def __init__(self, viewer: napari.Viewer, *args, **kwargs):
+        super().__init__(model_class=LayerManipulator, viewer=viewer, *args, **kwargs)
diff --git a/src/napari_threedee/manipulators/_qt/qt_point_manipulator.py b/src/napari_threedee/manipulators/_qt/qt_point_manipulator.py
new file mode 100644
index 0000000..e56ad45
--- /dev/null
+++ b/src/napari_threedee/manipulators/_qt/qt_point_manipulator.py
@@ -0,0 +1,9 @@
+import napari
+
+from ..point_manipulator import PointManipulator
+from ..._backend.threedee_widget_model import QtThreeDeeWidgetBase
+
+
+class QtPointManipulatorWidget(QtThreeDeeWidgetBase):
+    def __init__(self, viewer: napari.Viewer, *args, **kwargs):
+        super().__init__(model_class=PointManipulator, viewer=viewer, *args, **kwargs)
diff --git a/src/napari_threedee/manipulators/_qt/qt_render_plane_manipulator.py b/src/napari_threedee/manipulators/_qt/qt_render_plane_manipulator.py
new file mode 100644
index 0000000..d521b08
--- /dev/null
+++ b/src/napari_threedee/manipulators/_qt/qt_render_plane_manipulator.py
@@ -0,0 +1,9 @@
+import napari
+
+from ..._backend.threedee_widget_model import QtThreeDeeWidgetBase
+from ..render_plane_manipulator import RenderPlaneManipulator
+
+
+class QtRenderPlaneManipulatorWidget(QtThreeDeeWidgetBase):
+    def __init__(self, viewer: napari.Viewer, *args, **kwargs):
+        super().__init__(model_class=RenderPlaneManipulator, viewer=viewer, *args, **kwargs)
\ No newline at end of file
diff --git a/src/napari_threedee/manipulators/base_manipulator.py b/src/napari_threedee/manipulators/base_manipulator.py
index f28c55e..ec5bf39 100644
--- a/src/napari_threedee/manipulators/base_manipulator.py
+++ b/src/napari_threedee/manipulators/base_manipulator.py
@@ -1,764 +1,236 @@
 from abc import ABC, abstractmethod
-from typing import Optional, Tuple, Type
+from typing import Optional, Type
 
 import napari
 import numpy as np
-from napari.utils.geometry import project_points_onto_plane, rotation_matrix_from_vectors_3d
-from napari.utils.translations import trans
 from napari.viewer import Viewer
-from vispy.scene import Mesh
-from vispy.visuals.transforms import MatrixTransform
 
-from .manipulator_utils import make_translator_meshes, color_lines, make_rotator_meshes
-from .._base_model import ThreeDeeModel
-from ..utils.napari_utils import get_vispy_node, add_mouse_callback_safe, remove_mouse_callback_safe
-from ..utils.selection_utils import select_mesh_from_click
+from napari_threedee._backend.threedee_model import ThreeDeeModel
+from .._backend.manipulator.axis_model import AxisModel
+from .._backend.manipulator.napari_manipulator_backend import \
+    NapariManipulatorBackend
+from napari_threedee.utils.napari_utils import add_mouse_callback_safe, \
+    remove_mouse_callback_safe
 
 
 class BaseManipulator(ThreeDeeModel, ABC):
-    """Base class for manipulators.
+    """Base class for manipulator implementations.
 
     To implement:
         - the __init__() should take the viewer as the first argument, the layer
             as the second argument followed by any keyword arguments.
             Keyword arguments should have default values.
-        - implement a self._set_initial_translation_vectors() method
-        - implement a self._set_initial_rotator_normals() method
+        - The __init__() should call the super.__init__().
+        - implement a self._initialize_transform() method.
         - implement the _pre_drag() callback.
         - implement the _while_dragging_translator() callback.
         - implement the _while_dragging_rotator() callback.
         - implement the _post_drag() callback.
-        - Call the super.__init__().
 
     Parameters
     ----------
     viewer : Viewer
         The napari viewer containing the visuals.
     layer : Optional[Layer]
-        The callback_list to attach the manipulator to.
-    order : int
-        The order to place the manipulator visuals in the vispy scene graph.
-    translator_length : float
-        The length of the translator arms in data units.
-    translator_width : float
-        The width of the translator arms in data units.
-    rotator_radius : float
-        The radius of the rotators in data units.
-    rotator_width : float
-        The width of the rotators in data units.
-
-    Attributes
-    ----------
-    translation : np.ndarray
-        (3, 1) array containing the coordinates to the translation of the manipulator.
-    rot_mat : np.ndarray
-        (3, 3) array containing the rotation matrix applied to the manipulator.
-    translator_length : float
-        The length of the translator arms in data units.
-    translator_width : float
-        The width of the translator arms in data units.
-    rotator_radius : float
-        The radius of the rotators in data units.
-    rotator_width : float
-        The width of the rotators in data units.
-    translator_normals : np.ndarray
-        (N x 3) array containing the normal vector for each of the N translators.
-    rotator_normals : np.ndarray
-        (N x 3) array containing the normal vector for each of the N rotators.
-
-    Notes
-    -----
-    _N_SEGMENTS_ROTATOR : float
-        The number of segments to discretize the rotator into. More segments
-        makes the rotator look more smooth, but will reduce rendering performance.
-    _N_TUBE_POINTS : float
-        The number of points to use to represent the circular crossection of the
-        manipulator objects. More points makes the manipulator appear more smooth, but
-        will reduce the rendering performance.
+        The callback list of to attach the manipulator to.
     """
-    _N_SEGMENTS_ROTATOR = 50
-    _N_TUBE_POINTS = 15
 
     def __init__(
-            self,
-            viewer,
-            layer=None,
-            enabled: bool = True,
-            order: int = 0,
-            translator_length: float = 50,
-            translator_width: float = 1,
-            rotator_radius: float = 5,
-            rotator_width: float = 1,
+        self,
+        viewer,
+        layer=None,
+        rotator_axes: Optional[str] = None,
+        translator_axes: Optional[str] = None,
+        enabled: bool = True,
     ):
         super().__init__()
         self._viewer = viewer
-
-        # this will be overriden by the input layer below
-        self._layer = None
-        self._enabled = False
-
-        self._translator_length = translator_length
-        self._translator_width = translator_width
-        self._rotator_radius = rotator_radius
-        self._rotator_width = rotator_width
-
-        # this is used to store the vector to the initial click
-        # on a rotator for calculating the rotation
-        self._initial_click_vector = None
-
-        # this is used to store the initial rotation matrix before
-        # starting a rotation
-        self._initial_rot_mat = None
-
-        # CMYRGB for 6 axes data in x, y, z, ... ordering
-        self._default_color = [
-            [1, 1, 0, 1],
-            [0, 1, 1, 1],
-            [1, 0, 1, 1],
-            [1, 0, 0, 1],
-            [0, 1, 0, 1],
-            [0, 0, 1, 1],
-        ]
-
-        # the order for the manipulator visual in the vispy scenegraph
-        self._vispy_visual_order = order
-
-        # connect events
-        self._viewer.camera.events.zoom.connect(self._on_zoom_change)
-        self._viewer.dims.events.ndisplay.connect(self._on_ndisplay_change)
-        # add the layer
+        self._enabled = enabled
+        self._backend = NapariManipulatorBackend(
+            rotator_axes=rotator_axes,
+            translator_axes=translator_axes,
+            viewer=self._viewer,
+            layer=layer,
+        )
+        self.visible = False
         self.layer = layer
+        if self.enabled:
+            self._on_enable()
 
-        self.enabled = enabled
+    @property
+    def origin(self) -> np.ndarray:
+        """(3, ) array containing the origin of the manipulator."""
+        return self._backend.manipulator_model.origin
 
-    @abstractmethod
-    def _initialize_transform(self):
-        """Should set the properties required to calculate the rotation matrix and translation"""
-        raise NotImplementedError
+    @origin.setter
+    def origin(self, value: np.ndarray):
+        self._backend.manipulator_model.origin = value
 
     @property
-    def _initial_translation_vectors(self):
-        """An (Nx3) numpy array containing the translation vector for each of the
-        N translators to be created.
-
-        Translation vectors are defined in displayed data coordinates.
-        """
-        return self._initial_translation_vectors_
+    def rotation_matrix(self) -> np.ndarray:
+        """(3, 3) array containing the rotation matrix of the manipulator."""
+        return self._backend.manipulator_model.rotation_matrix
 
-    @_initial_translation_vectors.setter
-    def _initial_translation_vectors(self, value: np.ndarray):
-        """An (Nx3) numpy array containing the translation vector for each of the
-        N translators to be created.
+    @rotation_matrix.setter
+    def rotation_matrix(self, value: np.ndarray):
+        self._backend.manipulator_model.rotation_matrix = value
 
-        Translation vectors are defined in displayed data coordinates.
-        """
-        value = np.asarray(value)
-        self._initial_translation_vectors_ = value
+    @property
+    def z_vector(self) -> np.ndarray:
+        return self.rotation_matrix[:, 0]
 
-    def _set_initial_translation_vectors(self):
-        """Method to be overridden by subclasses for defining translation vectors.
-        """
-        self._initial_translation_vectors = np.empty((0, 3))
-
-    def _init_translators(self):
-        translator_vertices, translator_indices, translator_colors, triangle_indices = make_translator_meshes(
-            centroids=np.asarray([0, 0, 0]),
-            normals=self._initial_translation_vectors,
-            colors=self._default_color[:len(self._initial_translation_vectors)],
-            translator_length=self.translator_length,
-            tube_radius=self._translator_width,
-            tube_points=self._N_TUBE_POINTS,
-        )
+    @property
+    def y_vector(self) -> np.ndarray:
+        return self.rotation_matrix[:, 1]
 
-        self.translator_vertices = translator_vertices
-        self.translator_indices = translator_indices
-        self.translator_colors = translator_colors
-        self.translator_triangle_indices = triangle_indices
+    @property
+    def x_vector(self) -> np.ndarray:
+        return self.rotation_matrix[:, 2]
 
-        self._translator_normals = self._initial_translation_vectors.copy()
+    @property
+    def selected_translator(self) -> Optional[AxisModel]:
+        if self._backend.manipulator_model.selected_object_type != 'translator':
+            return None
+        axis_id = self._backend.manipulator_model.selected_axis_id
+        return AxisModel.from_id(axis_id)
 
     @property
-    def _initial_rotator_normals(self):
-        """An (Nx3) numpy array containing the normal direction for each of the
-        N rotators to be created.
+    def selected_rotator(self) -> Optional[AxisModel]:
+        if self._backend.manipulator_model.selected_object_type != 'rotator':
+            return None
+        axis_id = self._backend.manipulator_model.selected_axis_id
+        return AxisModel.from_id(axis_id)
+
+    @abstractmethod
+    def _initialize_transform(self):
+        """Update the origin and the rotation matrix of the manipulator visual.
 
-        Normal directions are defined in displayed data coordinates.
+        This should correctly initialize self.origin and self.rotation_matrix for the object
+        you want to manipulate. This method will be called when the layer is updated.
         """
-        return self._initial_rotator_normals_
+        ...
 
-    @_initial_rotator_normals.setter
-    def _initial_rotator_normals(self, value: np.ndarray):
-        """An (Nx3) numpy array containing the normal direction for each of the
-        N rotators to be created.
+    def _pre_drag(self):
+        """A callback called at the beginning of the drag event.
 
-        Normal directions are defined in displayed data coordinates.
+        This is typically used to save information that will be used during the
+        translator/rotator drag callbacks (e.g. initial positions/orientations).
         """
-        value = np.asarray(value)
-        self._initial_rotator_normals_ = value
+        pass
+
+    def _while_dragging_translator(self):
+        """A callback called during translator drag events.
 
-    def _set_initial_rotator_normals(self):
-        """Method to be overridden by subclasses for defining rotator normals.
+        Implementations should update the object being manipulated based on the state of the
+        manipulator.
         """
-        self._initial_rotator_normals = np.empty((0, 3))
+        pass
 
-    def _init_rotators(self):
-        if len(self._initial_rotator_normals) == 0:
-            return None
-        rotator_vertices, rotator_indices, rotator_colors, triangle_indices = make_rotator_meshes(
-            centroids=np.repeat([0, 0, 0], 3, axis=0),
-            normals=self._initial_rotator_normals,
-            colors=self._default_color[:len(self._initial_rotator_normals)],
-            rotator_radius=self.rotator_radius,
-            tube_radius=1,
-            tube_points=self._N_TUBE_POINTS,
-            n_segments=self._N_SEGMENTS_ROTATOR
-        )
+    def _while_dragging_rotator(self):
+        """A callback called during translator drag events.
 
-        self.rotator_vertices = rotator_vertices
-        self.rotator_indices = rotator_indices
-        self.rotator_colors = rotator_colors
-        self.rotator_triangle_indices = triangle_indices
+        Implementations should update the object being manipulated based on the state of the
+        manipulator.
+        """
+        pass
 
-        self._rotator_normals = self._initial_rotator_normals.copy()
+    def _post_drag(self):
+        """A callback called at the end of the drag event.
 
-    @property
-    def layer(self):
-        return self._layer
+        Implementations should use this to clean up any variables set during the click event.
+        """
+        pass
 
-    @layer.setter
-    def layer(self, layer: Optional[Type[napari.layers.Layer]]):
-        if layer is not None:
-            if layer == self._layer:
-                return
-            if self.layer is not None:
-                # remove the current visual
-                self.node.parent = None
-                self._disconnect_events(self.layer)
-            self._layer = layer
-            self._connect_vispy_visual(layer)
-            self._initialize_layer()
-
-            if self.enabled:
-                self._on_enable()
-            else:
-                self._on_disable()
-            self._connect_events(self.layer)
-        else:
-            self._layer = layer
+    def set_layers(self, layer: Type[napari.layers.Layer]):
+        """Add the correct layer type for the manipulator in a subclass.
 
-    def _initialize_layer(self):
-        self._initialize_transform()
-        self._set_initial_translation_vectors()
-        self._init_translators()
-        self._set_initial_rotator_normals()
-        self._init_rotators()
-        self._on_data_change()
-        self._on_zoom_change()
-        self._on_matrix_change()
-
-    def _connect_vispy_visual(self, layer: Type[napari.layers.Layer]):
-        # get the callback_list node to pass as the parent of the manipulator
-        parent = get_vispy_node(self._viewer, layer)
-        self.node = Mesh(mode='triangles', shading='smooth', parent=parent)
-
-        self.node.transform = MatrixTransform()
-        self.node.order = self._vispy_visual_order
-        self.node.canvas._backend.destroyed.connect(self._set_canvas_none)
-
-    def _connect_events(self, layer: napari.layers.Layer):
+        Implementing this method with a correct type annotation will allow
+        autogeneration of a Qt widget for a manipulator.
+        """
+        self.layer = layer
+
+    def _connect_events(self):
         """This method should be implemented on subclasses that
         require events to be connected to the layer when self.layer
         is set (other than the main mouse callback"""
         pass
 
-    def _disconnect_events(self, layer: napari.layers.Layer):
+    def _disconnect_events(self):
         """This method must be implemented on subclasses that
-        implement _connect_events(). This methood is to disconnect
+        implement _connect_events(). This method is to disconnect
         the events that were connected in _connect_events()"""
         pass
 
+    @property
+    def layer(self):
+        return self._backend.layer
 
-    def set_layers(self, layer: Type[napari.layers.Layer]):
-        """Override this in a subclass with the correct layer type for the manipulator"""
-        self.layer = layer
+    @layer.setter
+    def layer(self, layer: Optional[Type[napari.layers.Layer]]):
+        # if layer is None or layer == self.layer:
+        #     return
+        if layer is None:
+            return
+        self._disconnect_events()
+        self._backend.layer = layer
+        self._initialize_transform()
+        if self.enabled:
+            self._on_enable()
+        else:
+            self._on_disable()
+        self._connect_events()
+
+    @property
+    def visible(self) -> bool:
+        return self._backend.vispy_visual.visible
+
+    @visible.setter
+    def visible(self, value: bool):
+        self._backend.vispy_visual.visible = value
+
+    @property
+    def enabled(self) -> bool:
+        return self._enabled
+
+    @enabled.setter
+    def enabled(self, value: bool):
+        self._enabled = value
+        self._on_enable() if self._enabled is True else self._on_disable()
 
     def _on_enable(self):
         if self.layer is not None:
-            self.node.visible = True
+            self.visible = True
             add_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
-                self._mouse_callback,
-                index=0
+                callback_list=self.layer.mouse_drag_callbacks,
+                callback=self._mouse_callback,
+                index=1
             )
 
     def _on_disable(self):
         if self.layer is not None:
-            self.node.visible = False
+            self.visible = False
             remove_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
+                self.layer.mouse_drag_callbacks,
                 self._mouse_callback
             )
 
     def _on_ndisplay_change(self, event=None):
         if self._viewer.dims.ndisplay == 2:
             self.enabled = False
-            self._disconnect_events(self.layer)
         else:
             self.enabled = True
-            self._connect_events(self.layer)
-
-    @property
-    def translation(self) -> np.ndarray:
-        """Vector from the layer origin to the manipulator origin"""
-        return self._translation
-
-    @translation.setter
-    def translation(self, translation: np.ndarray):
-        self._translation = np.asarray(translation)
-        self._on_matrix_change()
-
-    @property
-    def rot_mat(self) -> np.ndarray:
-        return self._rot_mat
-
-    @rot_mat.setter
-    def rot_mat(self, rotation_matrix: np.ndarray):
-        self._rot_mat = rotation_matrix
-        self._on_matrix_change()
-
-    @property
-    def translator_length(self) -> float:
-        return self._translator_length
-
-    @translator_length.setter
-    def translator_length(self, line_length: float):
-        self._translator_length = line_length
-        self._on_data_change()
-
-    @property
-    def translator_width(self) -> float:
-        return self._translator_width
-
-    @translator_width.setter
-    def translator_width(self, width: float):
-        self._translator_width = width
-        self._update_translator_mesh()
-
-    @property
-    def rotator_radius(self) -> float:
-        return self._rotator_radius
-
-    @rotator_radius.setter
-    def rotator_radius(self, rotator_radius):
-        self._rotator_radius = rotator_radius
-        self._on_data_change()
-
-    @property
-    def rotator_width(self) -> float:
-        return self._rotator_width
-
-    @rotator_width.setter
-    def rotator_width(self, width: float):
-        self._rotator_width = width
-        self.update_rotator_mesh()
-
-    @property
-    def translator_normals(self) -> np.ndarray:
-        return (self._initial_translation_vectors @ self.rot_mat.T)
-
-    @property
-    def rotator_normals(self) -> np.ndarray:
-        return (self._initial_rotator_normals @ self.rot_mat.T)
-
-    @property
-    def _displayed_translator_vertices(self):
-        if self.translator_vertices is not None:
-            return (self.translator_vertices @ self.rot_mat.T) + self.translation
-        else:
-            return None
-
-    @property
-    def _displayed_rotator_vertices(self):
-        if self.rotator_vertices is not None:
-            return (self.rotator_vertices @ self.rot_mat.T) + self.translation
-        else:
-            return None
 
     def _mouse_callback(self, layer, event):
-        """Mouse call back for selecting and dragging a manipulator."""
-        # get the initial state for layer.interactive so we can return
-        # at the end of the callback
-        initial_layer_interactive = layer.interactive
-
-        # get click position and direction in data coordinates
-        click_position_world = event.position
-        click_position_data_3d = np.asarray(
-            self._layer._world_to_displayed_data(
-                click_position_world,
-                event.dims_displayed
-            )
-        )
-        click_dir_data_3d = np.asarray(
-            self._layer._world_to_displayed_data_ray(
-                event.view_direction,
-                event.dims_displayed
-            )
-        )
-
-        # identify clicked rotator/translator
-        selected_translator, selected_rotator = self._check_if_manipulator_clicked(
-            plane_point=click_position_data_3d,
-            plane_normal=click_dir_data_3d,
-        )
-        if selected_translator is not None:
-            selected_translator_normal = self.translator_normals[selected_translator]
-        else:
-            selected_translator_normal = None
-        if (selected_rotator is not None) or (selected_translator is not None):
-            layer.interactive = False
-
-        initial_position_world = event.position
+        """Update the manipulated object via subclass implementations of drag/rotate behaviour."""
         yield
-
-        if selected_translator is not None or selected_rotator is not None:
-
-            self._setup_translator_drag(
-                click_point=click_position_data_3d, selected_translator=selected_translator
-            )
-            # set up for the mouse drag
-            self._setup_rotator_drag(
-                click_point=click_position_data_3d, selected_rotator=selected_rotator
-            )
-
-            # call the _pre_drag callback
-            self._pre_drag(
-                click_point=click_position_data_3d,
-                selected_translator=selected_translator,
-                selected_rotator=selected_rotator
-            )
-
-            while event.type == 'mouse_move':
-                # click position
-                coordinates = np.asarray(layer.world_to_data(event.position))[event.dims_displayed]
-
-                # rotator_drag_vector = None
-                # translator_drag_vector = None
-                self._process_translator_drag(
-                    event,
-                    selected_translator,
-                    initial_position_world,
-                    selected_translator_normal
-                )
-                self._process_rotator_drag(
-                    event,
-                    selected_rotator,
-                    coordinates,
-                    initial_position_world
-                )
-
-                yield
-
-        # Call a function to clean up after the mouse event
-        layer.interactive = initial_layer_interactive
-        self._initial_click_vector = None
-        self._initial_rot_mat = None
-        self._layer._drag_start = None
+        if self._viewer.dims.ndisplay != 3 or self._backend.is_dragging is False:
+            return  # early exit if manipulator is not being manipulated
+        self._pre_drag()
+        while event.type == 'mouse_move':
+            selected_object_type = self._backend.manipulator_model.selected_object_type
+            if selected_object_type == 'translator':
+                self._while_dragging_translator()
+            elif selected_object_type == 'rotator':
+                self._while_dragging_rotator()
+            yield
         self._post_drag()
-
-    def _process_translator_drag(
-            self,
-            event,
-            selected_translator: int,
-            initial_position_world: np.ndarray,
-            selected_translator_normal: np.ndarray
-    ):
-        if selected_translator is None:
-            # no processing necessary if a translator was not selected
-            return
-        # get drag vector projected onto the translator axis
-        projected_distance = self.layer.projected_distance_from_mouse_drag(
-            start_position=initial_position_world,
-            end_position=event.position,
-            view_direction=event.view_direction,
-            vector=selected_translator_normal,
-            dims_displayed=event.dims_displayed
-        )
-        translator_drag_vector = projected_distance * selected_translator_normal
-        self.translation = self._initial_translation + translator_drag_vector
-        self._while_dragging_translator(selected_translator=selected_translator,
-                                        translation_vector=translator_drag_vector)
-
-    def _process_rotator_drag(
-            self,
-            event,
-            selected_rotator: int,
-            coordinates,
-            initial_position_world
-    ):
-        if selected_rotator is None:
-            # no processing necessary if a rotator was not selected
-            return
-        # calculate the rotation matrix for the rotator drag
-        rotator_drag_vector = coordinates - initial_position_world
-        plane_normal = self.rotator_normals[selected_rotator]
-        projected_click_point, _ = project_points_onto_plane(
-            points=coordinates,
-            plane_point=self.translation,
-            plane_normal=plane_normal,
-        )
-        click_vector = np.squeeze(projected_click_point) - self.translation
-        rotation_matrix = rotation_matrix_from_vectors_3d(
-            self._initial_click_vector, click_vector
-        )
-
-        # update the rotation matrix and call the _while_rotator_drag callback
-        self.rot_mat = np.dot(rotation_matrix, self._initial_rot_mat)
-        self._while_dragging_rotator(
-            selected_rotator=selected_rotator,
-            rotation_matrix=rotation_matrix
-        )
-
-    def _check_if_manipulator_clicked(
-            self,
-            plane_point: np.ndarray,
-            plane_normal: np.ndarray
-    ) -> Tuple[Optional[int], Optional[int]]:
-        """Determine if a translator or rotator was clicked on.
-
-        Parameters
-        ----------
-        plane_point : np.ndarray
-            The click point in data coordinates
-        plane_normal : np.ndarray
-            The vector in the direction of the view (click).
-
-        Returns
-        -------
-        selected_translator : Optional[int]
-            If a translator was clicked, returns the index of the translator.
-            If no translator was clicked, returns None.
-        selected_rotator : Optional[int]
-            If a rotator was clicked, returns the index of the rotator.
-            If no rotator was clicked, returns None.
-        """
-        # project the in view points onto the plane
-        if len(self.translator_normals) > 0:
-            translator_triangles = self._displayed_translator_vertices[self.translator_indices]
-            selected_translator = select_mesh_from_click(
-                click_point=plane_point,
-                view_direction=plane_normal,
-                triangles=translator_triangles,
-                triangle_indices=self.translator_triangle_indices
-            )
-        else:
-            selected_translator = None
-
-        if len(self.rotator_normals) > 0:
-            rotator_triangles = self._displayed_rotator_vertices[self.rotator_indices]
-            selected_rotator = select_mesh_from_click(
-                click_point=plane_point,
-                view_direction=plane_normal,
-                triangles=rotator_triangles,
-                triangle_indices=self.rotator_triangle_indices
-            )
-        else:
-            selected_rotator = None
-
-        return selected_translator, selected_rotator
-
-    def _setup_translator_drag(self, click_point: np.ndarray, selected_translator: Optional[int]):
-        if selected_translator is not None:
-            self._initial_translation = self.translation.copy()
-
-    def _setup_rotator_drag(self, click_point: np.ndarray, selected_rotator: Optional[int]):
-        if selected_rotator is not None:
-            normal = self.rotator_normals[selected_rotator]
-
-            # project the click point on to the plane of the rotat
-            initial_click_point, _ = project_points_onto_plane(
-                points=click_point,
-                plane_point=self.translation,
-                plane_normal=normal,
-            )
-
-            self._initial_click_vector = np.squeeze(initial_click_point) - self.translation
-            self._initial_rot_mat = self.rot_mat.copy()
-
-    def _pre_drag(
-            self,
-            click_point: np.ndarray,
-            selected_translator: Optional[int],
-            selected_rotator: Optional[int]
-    ):
-        """This is called at the beginning of the drag event and is
-        typically used to save information that will be used during the
-        drag callbacks (e.g., initial positions).
-
-        Parameters
-        ----------
-        click_point : np.ndarray
-            The click point in data coordinates (displayed dims only).
-        selected_translator : Optional[int]
-            The index of the selected translator. The index corresponds
-            to self.translator_normals. This is None when
-            no translator has been selected.
-        selected_rotator : Optional[int]
-            The index of the selected rotator. The index corresponds
-            to self.rotator_normals. This is None when
-            no rotator has been selected.
-        """
-        pass
-
-    def _while_dragging_translator(self, selected_translator: int, translation_vector: np.ndarray):
-        """This callback is called during translator drags events.
-
-        Parameters
-        ----------
-        selected_translator : int
-            The index of the selected translator. This index corresponds to
-            the self.translator_normals.
-        translation_vector : np.ndarray
-            The vector describing the current dragposition from the initial position
-            projected onto the selected translator.
-        """
-        pass
-
-    def _while_dragging_rotator(self, selected_rotator: int, rotation_matrix: np.ndarray):
-        """This callback is called during rotator drag events.
-
-        Parameters
-        ----------
-        selected_rotator : int
-            The index of the selected rotator. This index corresponds to
-            the self.rotator_normals.
-        rotation_matrix : np.ndarray
-            The (3 x 3) rotation matrix for the rotation from the initial point
-            to the current point in the drag.
-        """
-        pass
-
-    def _post_drag(self):
-        """This callback is called at the end of the drag event and should
-        be used to clean up any variables set during the click event.
-        """
-        pass
-
-    def _set_canvas_none(self):
-        self.node._set_canvas(None)
-
-    def _on_visible_change(self):
-        """Change visibiliy of axes."""
-        self.node.visible = True
-        self._on_zoom_change()
-
-    def _update_translator_mesh(self):
-        """Update the mesh for the translator"""
-        translator_vertices, translator_indices, translator_colors, triangle_indices = make_translator_meshes(
-            centroids=self.translation,
-            normals=self.translator_normals,
-            colors=self._default_color[:len(self.translator_normals)],
-            translator_length=self.translator_length,
-            tube_radius=self._translator_width,
-            tube_points=self._N_TUBE_POINTS,
-        )
-        self.translator_vertices = translator_vertices
-        self.translator_indices = translator_indices
-        self.translator_colors = translator_colors
-        self.triangle_indices = triangle_indices
-        self._on_data_change()
-
-    def _update_rotator_mesh(self):
-        """Update rotator mesh"""
-        rotator_vertices, rotator_indices, rotator_colors, triangle_indices = make_rotator_meshes(
-            centroids=np.repeat([0, 0, 0], 3, axis=0),
-            normals=self._rotator_normals,
-            colors=self._default_color[:len(self._initial_rotator_normals)],
-            rotator_radius=self.rotator_radius,
-            tube_radius=self.rotator_width,
-            tube_points=self._N_TUBE_POINTS,
-            n_segments=self._N_SEGMENTS_ROTATOR
-        )
-
-        self.rotator_vertices = rotator_vertices
-        self.rotator_indices = rotator_indices
-        self.rotator_colors = rotator_colors
-        self.rotator_triangle_indices = triangle_indices
-
-        self._on_matrix_change()
-
-    def _on_data_change(self):
-        """Change style of axes."""
-        if self.layer is None:
-            # do not do anything if the layer has not been set
-            return
-
-        # Actual number of displayed dims
-        ndisplay = self._viewer.dims.ndisplay
-
-        # Determine data based the number of displayed dimensions
-        if ndisplay == 2:
-            # make the arrow lines
-            data = self._line_data2D
-            color = color_lines(self._default_color)
-        elif ndisplay == 3:
-            # make the arrow lines, reverse axes for vispy display
-            translator_vertices = self.translator_vertices[:, ::-1]
-            translator_indices = self.translator_indices.copy()
-            translator_colors = self.translator_colors
-        else:
-            raise ValueError(
-                trans._(
-                    'Invalid ndisplay value',
-                    deferred=True,
-                )
-            )
-
-        if len(self._initial_rotator_normals) > 0:
-            translator_indices += len(self.rotator_vertices)
-            vertices = np.concatenate([self.rotator_vertices[:, ::-1], translator_vertices])
-            faces = np.concatenate([self.rotator_indices, translator_indices])
-            colors = np.concatenate([self.rotator_colors, translator_colors])
-        else:
-            vertices = translator_vertices
-            faces = translator_indices
-            colors = translator_colors
-
-        self.node.set_data(
-            vertices=vertices,
-            faces=faces,
-            vertex_colors=colors
-        )
-
-    def _on_zoom_change(self):
-        """Update axes length based on zoom scale."""
-        # if not self._viewer.axes.enabled:
-        #     return
-        return  # turn off temporarily
-        scale = 1 / self._viewer.camera.zoom
-
-        # If scale has not changed, do not redraw
-        if abs(np.log10(self._scale) - np.log10(scale)) < 1e-4:
-            return
-        self._scale = scale
-        scale_canvas2world = self._scale
-        target_canvas_pixels = self._target_length
-        scale = target_canvas_pixels * scale_canvas2world
-        # Update axes scale
-        self.node.transform.scale = [scale, scale, scale, 1]
-
-    def _on_matrix_change(self):
-        if self.layer is None:
-            # do not do anything if the layer has not been set
-            return
-        # convert NumPy axis ordering to VisPy axis ordering
-        # by reversing the axes order and flipping the linear
-        # matrix
-        translate = self.translation[::-1]
-        rot_matrix = self.rot_mat[::-1, ::-1].T
-
-        # Embed in the top left corner of a 4x4 affine matrix
-        affine_matrix = np.eye(4)
-        affine_matrix[: rot_matrix.shape[0], : rot_matrix.shape[1]] = rot_matrix
-        affine_matrix[-1, : len(translate)] = translate
-
-        self.node.transform.matrix = affine_matrix
diff --git a/src/napari_threedee/manipulators/layer_manipulator.py b/src/napari_threedee/manipulators/layer_manipulator.py
index b767b8b..1068b1c 100644
--- a/src/napari_threedee/manipulators/layer_manipulator.py
+++ b/src/napari_threedee/manipulators/layer_manipulator.py
@@ -1,65 +1,28 @@
-from typing import Optional, Union
-
 import napari
 import numpy as np
+
 from napari_threedee.manipulators.base_manipulator import BaseManipulator
 
 
 class LayerManipulator(BaseManipulator):
-    """LayerManipulator is a manipulator for translating a layer.
-
-    Parameters
-    ----------
-
-    """
+    """A manipulator for translating a layer."""
 
-    def __init__(self, viewer, layer=None, translator_length=50, order=0):
-        super().__init__(
-            viewer=viewer,
-            layer=layer,
-            translator_length=translator_length,
-            order=order
-        )
+    def __init__(self, viewer, layer=None):
+        super().__init__(viewer, layer, rotator_axes=None, translator_axes='xyz')
 
     def set_layers(self, layer: napari.layers.Layer):
         super().set_layers(layer)
 
     def _initialize_transform(self):
-        self._translation = np.array([0, 0, 0])
-        self._rot_mat = np.eye(3)
+        self.origin = self.layer.translate[self.layer._dims_displayed]
 
-    def _set_initial_translation_vectors(self):
-        self._initial_translation_vectors_ = np.asarray(
-            [
-                [1, 0, 0],
-                [0, 1, 0],
-                [0, 0, 1]
-            ]
-        )
+    def _pre_drag(self):
+        self.translate_start = self.layer.translate[self.layer._dims_displayed].copy()
 
-    def _setup_translator_drag(self, click_point: np.ndarray, selected_translator: Optional[int]):
-        if selected_translator is not None:
-            self._initial_translation = np.array(self.layer.translate)
+    def _while_dragging_translator(self):
+        new_translate = self.translate_start + self.origin
+        self.layer.translate = new_translate
+        # origin is relative to the layer transform so needs
+        # to be reset after updating the transform
+        self.origin = np.asarray((0, 0, 0))
 
-    def _process_translator_drag(
-            self,
-            event,
-            selected_translator: int,
-            initial_position_world: np.ndarray,
-            selected_translator_normal: np.ndarray
-    ):
-        if selected_translator is None:
-            # no processing necessary if a translator was not selected
-            return
-        # get drag vector projected onto the translator axis
-        projected_distance = self.layer.projected_distance_from_mouse_drag(
-            start_position=initial_position_world,
-            end_position=event.position,
-            view_direction=event.view_direction,
-            vector=selected_translator_normal,
-            dims_displayed=event.dims_displayed
-        )
-        translator_drag_vector = projected_distance * selected_translator_normal
-        self.layer.translate = self._initial_translation + translator_drag_vector
-        self._while_dragging_translator(selected_translator=selected_translator,
-                                        translation_vector=translator_drag_vector)
diff --git a/src/napari_threedee/manipulators/point_manipulator.py b/src/napari_threedee/manipulators/point_manipulator.py
index a6a1ad2..6870c30 100644
--- a/src/napari_threedee/manipulators/point_manipulator.py
+++ b/src/napari_threedee/manipulators/point_manipulator.py
@@ -1,201 +1,132 @@
-from typing import Optional
-
 import napari
 from napari.layers.points._points_mouse_bindings import select as napari_selection_callback
-import numpy as np
+from napari.layers.points._points_constants import Mode
 
 from .base_manipulator import BaseManipulator
-from ..utils.napari_utils import remove_mouse_callback_safe, add_mouse_callback_safe
+from napari_threedee.utils.napari_utils import remove_mouse_callback_safe, add_mouse_callback_safe
 
 
 class PointManipulator(BaseManipulator):
 
-    def __init__(self, viewer, layer=None, order=0, translator_length=50, rotator_radius=5):
-        self._visible = False
+    def __init__(self, viewer, layer=None):
         super().__init__(
             viewer,
-            layer,
-            order=order,
-            translator_length=translator_length,
-            rotator_radius=rotator_radius,
-            enabled=False
+            layer=layer,
+            translator_axes='xyz',
+            rotator_axes='xyz'
         )
 
-    @property
-    def visible(self) -> bool:
-        return self._visible
-
-    @visible.setter
-    def visible(self, visible: bool):
-        # if visible == self._visible:
-        #     return
-        if self.node is not None:
-            self.node.visible = visible
-        self._visible = visible
-
     def set_layers(self, layer: napari.layers.Points):
         super().set_layers(layer)
 
     def _initialize_transform(self):
-        self._translation = np.array([0, 0, 0])
-        self._rot_mat = np.eye(3)
-
-        if self.layer is not None:
-            self._on_selection_change()
-
+        pass
 
-    def _set_initial_translation_vectors(self):
-        self._initial_translation_vectors_ = np.asarray(
-            [[1, 0, 0],
-             [0, 1, 0],
-             [0, 0, 1]]
+    def _connect_events(self):
+        if self.layer is None:
+            return
+        self.layer.events.highlight.connect(self._on_selection_change)
+        remove_mouse_callback_safe(
+            self.layer.mouse_drag_callbacks,
+            napari_selection_callback
         )
+        if len(self.layer.selected_data) == 1:
+            add_mouse_callback_safe(
+                self.layer.mouse_drag_callbacks,
+                self.napari_selection_callback_passthrough
+            )
 
-    def _set_initial_rotator_normals(self):
-        self._initial_rotator_normals_ = np.array(
-            [
-                [1, 0, 0],
-                [0, 0, 1],
-                [0, 1, 0]
-            ]
+    def _disconnect_events(self):
+        if self.layer is None:
+            return
+        self.layer.events.highlight.disconnect(self._on_selection_change)
+        remove_mouse_callback_safe(
+            self.layer.mouse_drag_callbacks,
+            self.napari_selection_callback_passthrough
         )
-
-    def _connect_events(self, layer):
-        layer.events.highlight.connect(self._on_selection_change)
-
-        if self._layer is not None:
-            remove_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
-                napari_selection_callback
+        if self.layer.mode == Mode.SELECT:
+            add_mouse_callback_safe(
+                self.layer.mouse_drag_callbacks, napari_selection_callback
             )
-            if len(self._layer.selected_data) == 1:
-                add_mouse_callback_safe(
-                    self._layer.mouse_drag_callbacks,
-                    self.napari_selection_callback_passthrough
-                )
-
-    def _disconnect_events(self, layer):
-        layer.events.highlight.disconnect(self._on_selection_change)
-        if self._layer is not None:
-            remove_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
-                self.napari_selection_callback_passthrough
-            )
-            if self._layer.mode == 'select':
-                add_mouse_callback_safe(
-                    self._layer.mouse_drag_callbacks, napari_selection_callback
-                )
 
     @property
     def active_point_index(self):
-        return list(self._layer.selected_data)[0]
+        return list(self.layer.selected_data)[0]
 
     @property
     def active_point_position(self):
-        return self._layer.data[self.active_point_index]
+        return self.layer.data[self.active_point_index]
 
     def _on_selection_change(self, event=None):
-        if self._layer._is_selecting is True or self.enabled is False:
+        # early exit cases
+        if self.layer is None:
+            return
+        elif self.enabled is False:
+            return
+        elif self.layer._is_selecting is True:
             return
 
-        selected_points = list(self._layer.selected_data)
+        selected_points = list(self.layer.selected_data)
         if len(selected_points) == 1:
+            # replace napari selection callback with n3d passthrough
             remove_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
+                self.layer.mouse_drag_callbacks,
                 napari_selection_callback
             )
             add_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
+                self.layer.mouse_drag_callbacks,
                 self.napari_selection_callback_passthrough
             )
             self.visible = True
-            self.translation = self.active_point_position
 
+            # update manipulator position
+            self.origin = self.active_point_position
         else:
+            # reinstate original callbacck
             self.visible = False
-            if self._layer.mode == 'select':
+            if self.layer.mode == Mode.SELECT:
                 remove_mouse_callback_safe(
-                    self._layer.mouse_drag_callbacks,
+                    self.layer.mouse_drag_callbacks,
                     self.napari_selection_callback_passthrough
                 )
                 add_mouse_callback_safe(
-                    self._layer.mouse_drag_callbacks, napari_selection_callback
+                    self.layer.mouse_drag_callbacks, napari_selection_callback
                 )
 
-    def _pre_drag(
-            self,
-            click_point: np.ndarray,
-            selected_translator: Optional[int],
-            selected_rotator: Optional[int]
-    ):
-        pass
-
-    def _while_dragging_translator(self, selected_translator: int, translation_vector: np.ndarray):
-        self._layer._move([self.active_point_index], self.translation)
-        self._drag_start = None
+    def _pre_drag(self):
+        self.layer.events.highlight.disconnect(self._on_selection_change)
 
-    def _while_dragging_rotator(self, selected_rotator: int, rotation_matrix: np.ndarray):
-        # todo: store rotmat somewhere
-        pass
+    def _while_dragging_translator(self):
+        selected_point_index = list(self.layer.selected_data)[0]
+        self.layer.data[selected_point_index] = self.origin
+        # refresh rendering manually after modifying array data inplace
+        self.layer.refresh()
 
     def _post_drag(self):
-        pass
+        self.layer.events.highlight.connect(self._on_selection_change)
 
-    def napari_selection_callback_passthrough(self, layer, event):
-        # get click position and direction in data coordinates
-        click_position_world = event.position
 
-        click_position_data_3d = np.asarray(
-            self._layer._world_to_displayed_data(
-                click_position_world,
-                event.dims_displayed
-            )
-        )
-        click_dir_data_3d = np.asarray(
-            self._layer._world_to_displayed_data_ray(
-                event.view_direction,
-                event.dims_displayed
-            )
-        )
-
-        # identify clicked rotator/translator
-        selected_translator, selected_rotator = self._check_if_manipulator_clicked(
-            plane_point=click_position_data_3d,
-            plane_normal=click_dir_data_3d,
-        )
-        manipulator_clicked = (selected_translator is not None) or (selected_rotator is not None)
+    # def _while_dragging_rotator(self, selected_rotator: int, rotation_matrix: np.ndarray):
+    #     # todo: store rotmat somewhere
+    #     pass
 
-        if manipulator_clicked:  # early exit
+    def napari_selection_callback_passthrough(self, layer, event):
+        if self._backend.is_dragging:  # early exit if manipulator clicked
             return
-
-        # reestablish normal behaviour
-        # layer.selected_data changing emits an event which reattaches the
-        # original napari_selection_callback
+        # if manipulator not clicked, do normal point selection
         value = layer.get_value(
             position=event.position,
             view_direction=event.view_direction,
             dims_displayed=event.dims_displayed,
             world=True,
         )
+        # updating selected_data triggers an event which reattaches the
+        # original napari selection callback.
         if value is not None:
             layer.selected_data = {value}
         else:
             layer.selected_data = set()
 
     def _on_enable(self):
-        if self.layer is not None:
-            add_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
-                self._mouse_callback,
-                index=0
-            )
-            self._enabled = True
-            self._on_selection_change()
-
-    def _on_disable(self):
-        if self.layer is not None:
-            self.visible = False
-            remove_mouse_callback_safe(
-                self._layer.mouse_drag_callbacks,
-                self._mouse_callback
-            )
+        super()._on_enable()
+        self._on_selection_change()
diff --git a/src/napari_threedee/manipulators/qt_manipulators.py b/src/napari_threedee/manipulators/qt_manipulators.py
deleted file mode 100644
index 29c6f70..0000000
--- a/src/napari_threedee/manipulators/qt_manipulators.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import napari
-
-from .layer_manipulator import LayerManipulator
-from .render_plane_manipulator import RenderPlaneManipulator
-from .point_manipulator import PointManipulator
-from .._base_model import QtThreeDeeWidgetBase
-
-
-class QtRenderPlaneManipulatorWidget(QtThreeDeeWidgetBase):
-    def __init__(self,viewer: napari.Viewer, *args, **kwargs):
-        super().__init__(model=RenderPlaneManipulator, viewer=viewer, *args, **kwargs)
-
-class QtPointManipulatorWidget(QtThreeDeeWidgetBase):
-    def __init__(self,viewer: napari.Viewer, *args, **kwargs):
-        super().__init__(model=PointManipulator, viewer=viewer, *args, **kwargs)
-
-class QtLayerManipulatorWidget(QtThreeDeeWidgetBase):
-    def __init__(self,viewer: napari.Viewer, *args, **kwargs):
-        super().__init__(model=LayerManipulator, viewer=viewer, *args, **kwargs)
diff --git a/src/napari_threedee/manipulators/render_plane_manipulator.py b/src/napari_threedee/manipulators/render_plane_manipulator.py
index 2356676..1b0c631 100644
--- a/src/napari_threedee/manipulators/render_plane_manipulator.py
+++ b/src/napari_threedee/manipulators/render_plane_manipulator.py
@@ -1,6 +1,3 @@
-from functools import cached_property
-from typing import Optional
-
 import napari.layers
 from napari.utils.geometry import rotation_matrix_from_vectors_3d
 import numpy as np
@@ -8,69 +5,21 @@
 
 
 class RenderPlaneManipulator(BaseManipulator):
-    """A manipulator for moving an image layer rendering plane.
-    """
+    """A manipulator for moving and orienting an image layer rendering plane."""
 
-    def __init__(self, viewer, layer=None, order=0, translator_length=10, rotator_radius=5):
-        super().__init__(
-            viewer,
-            layer,
-            order=order,
-            translator_length=translator_length,
-            rotator_radius=rotator_radius
-        )
+    def __init__(self, viewer, layer=None):
+        super().__init__(viewer, layer, rotator_axes='yz', translator_axes='z')
 
-    def set_layers(self, layer: napari.layers.Image):
-        super().set_layers(layer)
+    def set_layers(self, layers: napari.layers.Image):
+        super().set_layers(layers)
 
     def _initialize_transform(self):
-        if self.layer is not None:
-            self._translation = np.array(self.layer.experimental_slicing_plane.position)
-            plane_normal = self.layer.experimental_slicing_plane.normal
-            if not np.allclose(plane_normal, [1, 0, 0]):
-                self.rot_mat = rotation_matrix_from_vectors_3d([1, 0, 0], plane_normal)
-            else:
-                self._rot_mat = np.eye(3)
-        else:
-            self._translation = np.array([0, 0, 0])
-            self._rot_mat = np.eye(3)
-
-    def _set_initial_translation_vectors(self):
-        self._initial_translation_vectors_ = np.asarray(
-            [[1, 0, 0]]
-        )
-
-    def _set_initial_rotator_normals(self):
-        # normals = np.eye(3)
-        # random_vec3 = np.array([0, 1, 0]) / np.linalg.norm([0, 1, 0])
-        # normals[0] = np.array([self.layer.experimental_slicing_plane.normal])
-        # normals[1] = np.cross(normals[0], random_vec3)
-        # normals[2] = np.cross(normals[0], normals[1])
-        normals = np.array(
-            [
-                [1, 0, 0],
-                [0, 0, 1],
-                [0, 1, 0]
-            ]
-        )
-        self._initial_rotator_normals_ = normals
-
-    def _pre_drag(
-            self,
-            click_point: np.ndarray,
-            selected_translator: Optional[int],
-            selected_rotator: Optional[int]
-    ):
-        self._initial_plane_pos = np.asarray(self._layer.experimental_slicing_plane.position)
-        self._initial_rot_mat = self.rot_mat.copy()
-
-    def _while_dragging_translator(self, selected_translator: int, translation_vector: np.ndarray):
-        new_translation = self._initial_plane_pos + translation_vector
-        self._layer.experimental_slicing_plane.position = np.squeeze(new_translation)
-        self.centroid = np.asarray(self._layer.experimental_slicing_plane.position)
+        self.origin = np.array(self.layer.plane.position)
+        plane_normal = self.layer.plane.normal
+        self.rotation_matrix = rotation_matrix_from_vectors_3d([1, 0, 0], plane_normal)
 
-    def _while_dragging_rotator(self, selected_rotator: int, rotation_matrix: np.ndarray):
-        self._layer.experimental_slicing_plane.normal = self.rotator_normals[0]
+    def _while_dragging_translator(self):
+        self.layer.plane.position = self.origin
 
-    def _post_drag(self):
-        self._initial_plane_pos = None
+    def _while_dragging_rotator(self):
+        self.layer.plane.normal = self.z_vector
diff --git a/src/napari_threedee/napari.yaml b/src/napari_threedee/napari.yaml
index fcf157c..3ba8fa2 100644
--- a/src/napari_threedee/napari.yaml
+++ b/src/napari_threedee/napari.yaml
@@ -4,32 +4,43 @@ contributions:
   commands:
   - id: napari-threedee.QtRenderPlaneManipulatorWidget
     title: Create render plane manipulator
-    python_name: napari_threedee._dock_widgets:QtRenderPlaneManipulatorWidget
+    python_name: napari_threedee.dock_widgets:QtRenderPlaneManipulatorWidget
+
   - id: napari-threedee.QtPointManipulatorWidget
     title: Create point manipulator
-    python_name: napari_threedee._dock_widgets:QtPointManipulatorWidget
+    python_name: napari_threedee.dock_widgets:QtPointManipulatorWidget
+
   - id: napari-threedee.QtLayerManipulatorWidget
     title: Create layer manipulator
-    python_name: napari_threedee._dock_widgets:QtLayerManipulatorWidget
+    python_name: napari_threedee.dock_widgets:QtLayerManipulatorWidget
+
   - id: napari-threedee.QtPlanePointAnnotatorWidget
     title: Create plane point annotator
-    python_name: napari_threedee._dock_widgets:QtPlanePointAnnotatorWidget
-  - id: napari-threedee.QtFilamentAnnotatorWidget
+    python_name: napari_threedee.dock_widgets:QtPlanePointAnnotatorWidget
+
+  - id: napari-threedee.QtSplineAnnotatorWidget
     title: Create filament annotator
-    python_name: napari_threedee._dock_widgets:QtFilamentAnnotatorWidget
+    python_name: napari_threedee.dock_widgets:QtSplineAnnotatorWidget
+
   - id: napari-threedee.QtLightingControlWidget
     title: Create mesh lighting control
-    python_name: napari_threedee._dock_widgets:QtLightingControlWidget
+    python_name: napari_threedee.dock_widgets:QtLightingControlWidget
+
   widgets:
   - command: napari-threedee.QtRenderPlaneManipulatorWidget
     display_name: render plane manipulator
+
   - command: napari-threedee.QtPointManipulatorWidget
     display_name: point manipulator
+
   - command: napari-threedee.QtLayerManipulatorWidget
     display_name: layer manipulator
+
   - command: napari-threedee.QtPlanePointAnnotatorWidget
     display_name: plane point annotator
-  - command: napari-threedee.QtFilamentAnnotatorWidget
-    display_name: filament annotator
+
+  - command: napari-threedee.QtSplineAnnotatorWidget
+    display_name: spline annotator
+
   - command: napari-threedee.QtLightingControlWidget
     display_name: mesh lighting controls
diff --git a/src/napari_threedee/utils/napari_utils.py b/src/napari_threedee/utils/napari_utils.py
index 5622296..28c65f0 100644
--- a/src/napari_threedee/utils/napari_utils.py
+++ b/src/napari_threedee/utils/napari_utils.py
@@ -69,7 +69,7 @@ def get_layers_of_type(*args, viewer: napari.Viewer, layer_type):
 
 def generate_populated_layer_selection_widget(func, viewer) -> FunctionGui:
     parameters = inspect.signature(func).parameters
-    mgui_param_args = {
+    magicgui_parameter_arguments = {
         parameter_name: {
             'choices': partial(get_layers_of_type, viewer=viewer, layer_type=parameter.annotation)
         }
@@ -77,10 +77,42 @@ def generate_populated_layer_selection_widget(func, viewer) -> FunctionGui:
         in parameters.items()
         if parameter.annotation in NAPARI_LAYER_TYPES
     }
-    return magicgui.magicgui(func, **mgui_param_args, auto_call=True)
+    return magicgui.magicgui(func, **magicgui_parameter_arguments, auto_call=True)
 
 
-def mouse_event_to_layer_data_displayed(layer, event) -> Tuple[np.ndarray, np.ndarray]:
+def get_mouse_position_in_displayed_dimensions(event) -> np.ndarray:
+    """Get the position under the mouse in scene (displayed world) coordinates.
+
+    Parameters
+    ----------
+    layer : napari.layers.Layer
+        The layer to convert the coordinates to.
+    event
+        The mouse event.
+    Returns
+    -------
+    click_position_data_3d : np.ndarray
+        The click position in displayed data coordinates.
+    click_dir_data_3d : np.ndarray
+        The click direction in displayed data coordiantes
+    """
+    click_position_world = event.position
+    return np.asarray(click_position_world)[list(event.dims_displayed)]
+
+
+def get_view_direction_in_displayed_dimensions(event) -> np.ndarray:
+    """Get the view direction under the mouse in scene (displayed world) coordinates.
+
+    Parameters
+    ----------
+    event: Event
+        napari mouse event.
+    """
+    view_direction_world = event.view_direction
+    return np.asarray(view_direction_world)[list(event.dims_displayed)]
+
+
+def get_mouse_position_in_displayed_layer_data_coordinates(layer, event) -> Tuple[np.ndarray, np.ndarray]:
     """Get the mouse click position and direction in layer data displayed coordinates.
 
     Parameters


Test patch:
diff --git a/src/napari_threedee/manipulators/_manipulator_visual.py b/src/napari_threedee/_backend/manipulator/_tests/__init__.py
similarity index 100%
rename from src/napari_threedee/manipulators/_manipulator_visual.py
rename to src/napari_threedee/_backend/manipulator/_tests/__init__.py
diff --git a/src/napari_threedee/manipulator/_tests/test_axis_model.py b/src/napari_threedee/_backend/manipulator/_tests/test_axis_model.py
similarity index 87%
rename from src/napari_threedee/manipulator/_tests/test_axis_model.py
rename to src/napari_threedee/_backend/manipulator/_tests/test_axis_model.py
index ca49367..ebdab53 100644
--- a/src/napari_threedee/manipulator/_tests/test_axis_model.py
+++ b/src/napari_threedee/_backend/manipulator/_tests/test_axis_model.py
@@ -1,6 +1,6 @@
 import numpy as np
 
-from napari_threedee.manipulator.axis_model import AxisModel, AxisSet
+from napari_threedee._backend.manipulator.axis_model import AxisModel, AxisSet
 
 
 def test_axis_model_instantiation_from_string():
diff --git a/src/napari_threedee/manipulator/_tests/test_central_axis.py b/src/napari_threedee/_backend/manipulator/_tests/test_central_axis.py
similarity index 77%
rename from src/napari_threedee/manipulator/_tests/test_central_axis.py
rename to src/napari_threedee/_backend/manipulator/_tests/test_central_axis.py
index 9584ab9..a14aa7c 100644
--- a/src/napari_threedee/manipulator/_tests/test_central_axis.py
+++ b/src/napari_threedee/_backend/manipulator/_tests/test_central_axis.py
@@ -1,4 +1,4 @@
-from napari_threedee.manipulator.central_axis import CentralAxis, CentralAxisSet
+from napari_threedee._backend.manipulator.central_axis import CentralAxis, CentralAxisSet
 
 
 def test_central_axis_model_instantiation():
diff --git a/src/napari_threedee/manipulator/_tests/test_drag_manager.py b/src/napari_threedee/_backend/manipulator/_tests/test_drag_manager.py
similarity index 83%
rename from src/napari_threedee/manipulator/_tests/test_drag_manager.py
rename to src/napari_threedee/_backend/manipulator/_tests/test_drag_manager.py
index f263d07..d0ebaaa 100644
--- a/src/napari_threedee/manipulator/_tests/test_drag_manager.py
+++ b/src/napari_threedee/_backend/manipulator/_tests/test_drag_manager.py
@@ -5,13 +5,14 @@
 from napari.layers import Image
 import pytest
 
-from napari_threedee.manipulator.drag_managers import RotatorDragManager, TranslatorDragManager
+from napari_threedee._backend.manipulator.drag_managers import RotatorDragManager, \
+    TranslatorDragManager
 
 
 @dataclass
 class DummyMouseEvent:
     position: np.ndarray
-    view_direction:np.ndarray
+    view_direction: np.ndarray
     dims_displayed: List[int]
     modifiers: List[str]
 
@@ -19,15 +20,14 @@ class DummyMouseEvent:
 @pytest.mark.parametrize(
     "view_direction,drag_position,expected_angle",
     [
-        (np.array([1, 0, 0]), np.array([10, -10, 10]), np.pi/2),
-        (np.array([-1, 0, 0]), np.array([10, -10, 10]), np.pi/2),
-        (np.array([1, 0, 0]), np.array([10, 10, -10]), -np.pi/2),
-        (np.array([-1, 0, 0]), np.array([10, 10, -10]), -np.pi/2),
+        (np.array([1, 0, 0]), np.array([10, -10, 10]), np.pi / 2),
+        (np.array([-1, 0, 0]), np.array([10, -10, 10]), np.pi / 2),
+        (np.array([1, 0, 0]), np.array([10, 10, -10]), -np.pi / 2),
+        (np.array([-1, 0, 0]), np.array([10, 10, -10]), -np.pi / 2),
 
     ]
 )
 def test_rotator_drag_manager(view_direction, drag_position, expected_angle):
-
     image_layer = Image(np.zeros((20, 20, 20)))
 
     # axis around which rotation will be applied
@@ -55,11 +55,11 @@ def test_rotator_drag_manager(view_direction, drag_position, expected_angle):
 
     # make a click that corresponds to a +90 deg rotation
     drag_mouse_event = DummyMouseEvent(
-            position=drag_position,
-            view_direction=view_direction,
-            dims_displayed=[0, 1, 2],
-            modifiers=[]
-        )
+        position=drag_position,
+        view_direction=view_direction,
+        dims_displayed=[0, 1, 2],
+        modifiers=[]
+    )
     updated_translation, updated_rotation_matrix = drag_manager.update_drag(drag_mouse_event)
 
     # should be no translation
@@ -88,7 +88,6 @@ def test_rotator_drag_manager(view_direction, drag_position, expected_angle):
     ]
 )
 def test_translator_drag_manager(view_direction, drag_position, expected_translation_distance):
-
     image_layer = Image(np.zeros((20, 20, 20)))
 
     # axis around which rotation will be applied
@@ -116,11 +115,11 @@ def test_translator_drag_manager(view_direction, drag_position, expected_transla
 
     # make a click that corresponds to a +90 deg rotation
     drag_mouse_event = DummyMouseEvent(
-            position=drag_position,
-            view_direction=view_direction,
-            dims_displayed=[0, 1, 2],
-            modifiers=[]
-        )
+        position=drag_position,
+        view_direction=view_direction,
+        dims_displayed=[0, 1, 2],
+        modifiers=[]
+    )
     updated_translation, updated_rotation_matrix = drag_manager.update_drag(drag_mouse_event)
 
     # should be no translation
diff --git a/src/napari_threedee/manipulator/_tests/test_manipulator.py b/src/napari_threedee/_backend/manipulator/_tests/test_manipulator.py
similarity index 67%
rename from src/napari_threedee/manipulator/_tests/test_manipulator.py
rename to src/napari_threedee/_backend/manipulator/_tests/test_manipulator.py
index 124e427..3555714 100644
--- a/src/napari_threedee/manipulator/_tests/test_manipulator.py
+++ b/src/napari_threedee/_backend/manipulator/_tests/test_manipulator.py
@@ -1,6 +1,6 @@
 import numpy as np
 import pytest
-from napari_threedee.manipulator.model import ManipulatorModel
+from napari_threedee._backend.manipulator.manipulator_model import ManipulatorModel
 
 
 def test_instantiation():
@@ -21,6 +21,7 @@ def test_instantiation_with_empty_translators():
     manipulator = ManipulatorModel.from_strings(translators=None, rotators='xyz')
     assert isinstance(manipulator, ManipulatorModel)
 
+
 @pytest.mark.xfail
 def test_rotation_matrix_type():
     manipulator = ManipulatorModel(central_axes='xyz', rotators='xyz', translators='xyz')
@@ -34,4 +35,14 @@ def test_instantiation_from_strings():
     assert len(central_axes) == 2
     assert 'x' in central_axes
     assert 'y' in central_axes
-    assert 'z' not in central_axes
\ No newline at end of file
+    assert 'z' not in central_axes
+
+
+def test_selected_object_type_update():
+    """Selected object type should be set to None if selected axis id is unset."""
+    manipulator = ManipulatorModel.from_strings(translators='x', rotators='z')
+    manipulator.selected_axis_id = 1
+    manipulator.selected_object_type = 'rotator'
+    assert manipulator.selected_object_type == 'rotator'
+    manipulator.selected_axis_id = None
+    assert manipulator.selected_object_type is None
diff --git a/src/napari_threedee/manipulator/_tests/test_manipulator_visual_data.py b/src/napari_threedee/_backend/manipulator/_tests/test_manipulator_visual_data.py
similarity index 90%
rename from src/napari_threedee/manipulator/_tests/test_manipulator_visual_data.py
rename to src/napari_threedee/_backend/manipulator/_tests/test_manipulator_visual_data.py
index 4325a4a..a2ba036 100644
--- a/src/napari_threedee/manipulator/_tests/test_manipulator_visual_data.py
+++ b/src/napari_threedee/_backend/manipulator/_tests/test_manipulator_visual_data.py
@@ -1,11 +1,11 @@
 import numpy as np
 
-from napari_threedee.manipulator.axis_model import AxisModel
-from napari_threedee.manipulator.central_axis import CentralAxis, CentralAxisSet
-from napari_threedee.manipulator.model import ManipulatorModel
-from napari_threedee.manipulator.translator import Translator, TranslatorSet
-from napari_threedee.manipulator.rotator import Rotator, RotatorSet
-from napari_threedee.manipulator.vispy_visual_data import ManipulatorLineData, \
+from napari_threedee._backend.manipulator.axis_model import AxisModel
+from napari_threedee._backend.manipulator.central_axis import CentralAxis, CentralAxisSet
+from napari_threedee._backend.manipulator.manipulator_model import ManipulatorModel
+from napari_threedee._backend.manipulator.translator import Translator, TranslatorSet
+from napari_threedee._backend.manipulator.rotator import Rotator, RotatorSet
+from napari_threedee._backend.manipulator.vispy_visual_data import ManipulatorLineData, \
     ManipulatorHandleData, ManipulatorVisualData
 
 
diff --git a/src/napari_threedee/manipulator/_tests/test_rotator.py b/src/napari_threedee/_backend/manipulator/_tests/test_rotator.py
similarity index 68%
rename from src/napari_threedee/manipulator/_tests/test_rotator.py
rename to src/napari_threedee/_backend/manipulator/_tests/test_rotator.py
index 6187e24..1df6222 100644
--- a/src/napari_threedee/manipulator/_tests/test_rotator.py
+++ b/src/napari_threedee/_backend/manipulator/_tests/test_rotator.py
@@ -1,5 +1,4 @@
-from napari_threedee.manipulator.rotator import RotatorSet, Rotator
-from napari_threedee.manipulator.axis_model import AxisSet
+from napari_threedee._backend.manipulator.rotator import RotatorSet, Rotator
 
 
 def test_rotator_instantiation():
diff --git a/src/napari_threedee/manipulator/_tests/test_translator.py b/src/napari_threedee/_backend/manipulator/_tests/test_translator.py
similarity index 78%
rename from src/napari_threedee/manipulator/_tests/test_translator.py
rename to src/napari_threedee/_backend/manipulator/_tests/test_translator.py
index 8429a3c..fc3c67b 100644
--- a/src/napari_threedee/manipulator/_tests/test_translator.py
+++ b/src/napari_threedee/_backend/manipulator/_tests/test_translator.py
@@ -1,4 +1,4 @@
-from napari_threedee.manipulator.translator import TranslatorSet, Translator
+from napari_threedee._backend.manipulator.translator import TranslatorSet, Translator
 
 
 def test_translator_instantiation():
diff --git a/src/napari_threedee/_tests/test_dock_widget.py b/src/napari_threedee/_tests/test_dock_widget.py
index bf83d6d..4133adc 100644
--- a/src/napari_threedee/_tests/test_dock_widget.py
+++ b/src/napari_threedee/_tests/test_dock_widget.py
@@ -1,21 +1,21 @@
 import napari_threedee
 import pytest
 
-# this is your plugin name declared in your napari.plugins entry point
 MY_PLUGIN_NAME = "napari-threedee"
-# the name of your widget(s)
 MY_WIDGET_NAMES = [
     "render plane manipulator",
     "point manipulator",
     "layer manipulator",
     "plane point annotator",
-    "filament annotator",
+    "spline annotator",
     "mesh lighting controls",
 ]
 
 
 @pytest.mark.parametrize("widget_name", MY_WIDGET_NAMES)
-def test_something_with_viewer(widget_name, make_napari_viewer, napari_plugin_manager):
+def test_something_with_viewer(
+    widget_name, make_napari_viewer, napari_plugin_manager
+):
     napari_plugin_manager.register(napari_threedee, name=MY_PLUGIN_NAME)
     viewer = make_napari_viewer()
     num_dw = len(viewer.window._dock_widgets)