diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 92458ef5..85d2495a 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -11,33 +11,71 @@ As the object will be used as a context manager create the hardware connection i from __future__ import annotations from collections.abc import Sequence, Mapping +from typing import Literal import labthings_fastapi as lt +class RedefinedBaseMovementError(RuntimeError): + """The subclass of BaseStage has overridden ``move_relative`` or ``move_absolute``. + + Overriding ``move_relative`` or ``move_absolute`` can be problematic as these use the + external position not the hardware position. It is recommended to override + ``_hardware_move_relative`` and ``_hardware_move_absolute`` instead. + + The BaseStage will raise this on ``__init__``, it is the last thing ``__init__`` + does. As such, this exception can be captured by ``try`` if a stage needs to + override these for a specific reason. + """ + + class BaseStage(lt.Thing): """A base stage class for OpenFlexure translation stages. This can't be used directly but should reduce boilerplate code when - implementing new stages. A minimal working stage must implement - ``move_relative`` and ``move_absolute`` actions, which update the - ``position`` property on completion, and provide ``set_zero_position``. + implementing new stages. + + Note that the coordinate system used for the microscope may need to have different + axis direction as those used by the underlying stage controller. + + A minimal working stage must implement ``_hardware_move_relative`` + and ``_hardware_move_absolute`` actions, which update the ``_hardware_position`` + attribute on completion, and also should implement ``set_zero_position``. """ _axis_names = ("x", "y", "z") + def __init__(self): + """Initialise the stage. + + :raises RedefinedBaseMovementError: if ``move_relative`` and/or + ``move_absolute`` are overridden. It is recommended to override + ``_hardware_move_relative`` and/or ``_hardware_move_absolute`` instead so + that all code in the child class uses the hardware reference frame. + """ + self._hardware_position = dict.fromkeys(self._axis_names, 0) + + # This must be the last thing the function does in case it is caught in a try. + if ( + self.__class__.move_relative.func is not BaseStage.move_relative.func + or self.__class__.move_absolute.func is not BaseStage.move_absolute.func + ): + raise RedefinedBaseMovementError( + "move_relative and/or move_absolute has been overridden. This may " + "cause issues as the base methods implement converting from program " + "coordinates to hardware coordinates. Consider overriding " + "_hardware_move_relative and/or _hardware_move_absolute instead." + ) + @lt.thing_property def axis_names(self) -> Sequence[str]: """The names of the stage's axes, in order.""" return self._axis_names - position = lt.ThingProperty( - Mapping[str, int], - dict.fromkeys(_axis_names, 0), - readonly=True, - observable=True, - ) - """Current position of the stage.""" + @lt.thing_property + def position(self) -> Mapping[str, int]: + """Current position of the stage.""" + return self._apply_axis_direction(self._hardware_position) moving = lt.ThingProperty( bool, @@ -47,11 +85,56 @@ class BaseStage(lt.Thing): ) """Whether the stage is in motion.""" + axis_inverted = lt.ThingSetting( + initial_value={"x": False, "y": False, "z": False}, + model=Mapping[str, bool], + readonly=True, + ) + """Used to convert coordinates between the program frame and the hardware frame.""" + + def _apply_axis_direction( + self, position: list[int] | tuple[int] | Mapping[str, int] + ) -> list[int] | Mapping[str, int]: + if isinstance(position, (list, tuple)): + return [ + -int(pos) if inverted else int(pos) + for pos, inverted in zip(position, self.axis_inverted.values()) + ] + if isinstance(position, Mapping): + try: + return { + ax: -int(position[ax]) + if self.axis_inverted[ax] + else int(position[ax]) + for ax in position + } + except KeyError as e: + raise KeyError( + f"One or more axis in {position.keys()} is not defined." + ) from e + raise TypeError( + "Position must be a sequence of positions or a mapping from axis to position." + ) + @property def thing_state(self): """Summary metadata describing the current state of the stage.""" return {"position": self.position} + @lt.thing_action + def invert_axis_direction(self, axis: Literal["x", "y", "z"]): + """Invert the direction setting of the given axis. + + :param axis: The axis name (x, y or z) to invert. + """ + # Not mutating in place so that setting is saved on change. + direction = self.axis_inverted + try: + direction[axis] = not direction[axis] + except KeyError as e: + raise KeyError(f"The axis {axis} is not defined.") from e + self.axis_inverted = direction + @lt.thing_action def move_relative( self, @@ -60,8 +143,24 @@ class BaseStage(lt.Thing): **kwargs: Mapping[str, int], ): """Make a relative move. Keyword arguments should be axis names.""" + self._hardware_move_relative( + cancel=cancel, + block_cancellation=block_cancellation, + **self._apply_axis_direction(kwargs), + ) + + def _hardware_move_relative( + self, + cancel: lt.deps.CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): + """Make a relative move in the coordinate system used by the physical hardware. + + Make sure to use and update ``self._hardware_position`` not ``self.position``. + """ raise NotImplementedError( - "StageThings must define their own move_relative method" + "StageThings must define their own _hardware_move_relative method" ) @lt.thing_action @@ -72,6 +171,22 @@ class BaseStage(lt.Thing): **kwargs: Mapping[str, int], ): """Make an absolute move. Keyword arguments should be axis names.""" + self._hardware_move_absolute( + cancel=cancel, + block_cancellation=block_cancellation, + **self._apply_axis_direction(kwargs), + ) + + def _hardware_move_absolute( + self, + cancel: lt.deps.CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): + """Make a absolute move in the coordinate system used by the physical hardware. + + Make sure to use and update ``self._hardware_position`` not ``self.position``. + """ raise NotImplementedError( "StageThings must define their own move_absolute method" ) diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 87109e4f..8c01c186 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -27,16 +27,23 @@ class DummyStage(BaseStage): """ super().__init__(**kwargs) self.step_time = step_time + self.instantaneous_position = self._hardware_position def __enter__(self): """Register the stage position when the Thing context manager is opened.""" - self.instantaneous_position = self.position + self.instantaneous_position = self._hardware_position def __exit__(self, _exc_type, _exc_value, _traceback): """Nothing to do when the Thing context manager is closed.""" - @lt.thing_action - def move_relative( + axis_inverted = lt.ThingSetting( + initial_value={"x": True, "y": False, "z": False}, + model=Mapping[str, bool], + readonly=True, + ) + """Used to convert coordinates between the program frame and the hardware frame.""" + + def _hardware_move_relative( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, @@ -57,8 +64,8 @@ class DummyStage(BaseStage): cancel.sleep(dt) fraction_complete = (time.time() - start_time) / (dt * max_displacement) self.instantaneous_position = { - k: self.position[k] + int(fraction_complete * v) - for k, v in zip(self.axis_names, displacement) + ax: self._hardware_position[ax] + int(fraction_complete * disp) + for ax, disp in zip(self.axis_names, displacement) } fraction_complete = 1.0 except lt.exceptions.InvocationCancelledError as e: @@ -68,14 +75,13 @@ class DummyStage(BaseStage): raise e finally: self.moving = False - self.position = { - k: self.position[k] + int(fraction_complete * v) - for k, v in zip(self.axis_names, displacement) + self._hardware_position = { + ax: self._hardware_position[ax] + int(fraction_complete * disp) + for ax, disp in zip(self.axis_names, displacement) } - self.instantaneous_position = self.position + self.instantaneous_position = self._hardware_position - @lt.thing_action - def move_absolute( + def _hardware_move_absolute( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, @@ -83,11 +89,11 @@ class DummyStage(BaseStage): ): """Make an absolute move. Keyword arguments should be axis names.""" displacement = { - axis: int(pos) - self.position[axis] + axis: int(pos) - self._hardware_position[axis] for axis, pos in kwargs.items() if axis in self.axis_names } - self.move_relative( + self._hardware_move_relative( cancel, block_cancellation=block_cancellation, **displacement ) @@ -99,5 +105,5 @@ class DummyStage(BaseStage): It is intended for use after manually or automatically recentring the stage. """ - self.position = dict.fromkeys(self.axis_names, 0) - self.instantaneous_position = self.position + self._hardware_position = dict.fromkeys(self.axis_names, 0) + self.instantaneous_position = self._hardware_position diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index e78fa75c..4f3d661d 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import threading import time +from copy import copy from typing import Iterator, Literal from contextlib import contextmanager from collections.abc import Mapping @@ -36,8 +37,9 @@ class SangaboardThing(BaseStage): Sangaboard class """ - self.sangaboard_kwargs = kwargs + self.sangaboard_kwargs = copy(kwargs) self.sangaboard_kwargs["port"] = port + super().__init__(**kwargs) def __enter__(self): """Connect to the sangaboard when the Thing context manager is opened.""" @@ -65,19 +67,25 @@ class SangaboardThing(BaseStage): with self._sangaboard_lock: yield self._sangaboard + axis_inverted = lt.ThingSetting( + initial_value={"x": True, "y": False, "z": True}, + model=Mapping[str, bool], + readonly=True, + ) + """Used to convert coordinates between the program frame and the hardware frame.""" + def update_position(self) -> None: """Read position from the stage and set the corresponding property.""" with self.sangaboard() as sb: - self.position = dict(zip(self.axis_names, sb.position)) + self._hardware_position = dict(zip(self.axis_names, sb.position)) - @lt.thing_action - def move_relative( + def _hardware_move_relative( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ) -> None: - """Make a relative move. Keyword arguments should be axis names.""" + """Make a relative move in the coordinate system used by the sangaboard.""" displacement = [kwargs.get(axis, 0) for axis in self.axis_names] with self.sangaboard() as sb: self.moving = True @@ -98,22 +106,21 @@ class SangaboardThing(BaseStage): self.moving = False self.update_position() - @lt.thing_action - def move_absolute( + def _hardware_move_absolute( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ) -> None: - """Make an absolute move. Keyword arguments should be axis names.""" + """Make a absolute move in the coordinate system used by the sangaboard.""" with self.sangaboard(): self.update_position() displacement = { - axis: int(pos) - self.position[axis] + axis: int(pos) - self._hardware_position[axis] for axis, pos in kwargs.items() if axis in self.axis_names } - self.move_relative( + self._hardware_move_relative( cancel, block_cancellation=block_cancellation, **displacement ) diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py index f63b44e3..571a7159 100644 --- a/tests/test_dummy_server.py +++ b/tests/test_dummy_server.py @@ -1,7 +1,7 @@ """Test the server without creating a full HTTP server and socket connection. Rather than spinning up a full uvicorn webserver for each test these tests use -the FastAPI ``TestClient`` or to directly communicate with the underlying +the FastAPI ``TestClient`` or directly communicate with the underlying LabThings-FastAPI code. This increases speed of testing significantly. For tests that require a full running server see the ``integration-tests`` diff --git a/tests/test_stage.py b/tests/test_stage.py new file mode 100644 index 00000000..be4e662f --- /dev/null +++ b/tests/test_stage.py @@ -0,0 +1,335 @@ +"""Test the stage without creating a full HTTP server and socket connection.""" + +from collections.abc import Mapping +import tempfile +import itertools + +from fastapi.testclient import TestClient +import pytest +from httpx import HTTPStatusError +from hypothesis import given, settings, HealthCheck, strategies as st + +import labthings_fastapi as lt +from labthings_fastapi.exceptions import NotConnectedToServerError + +from openflexure_microscope_server.things.stage import ( + BaseStage, + RedefinedBaseMovementError, +) +from openflexure_microscope_server.things.stage.dummy import DummyStage + +# Keep the size and number of moves fairly small or the tests can take forever +point3d = st.tuples( + st.integers(min_value=-100, max_value=100), + st.integers(min_value=-100, max_value=100), + st.integers(min_value=-100, max_value=100), +) + +path3d = st.lists(point3d, min_size=5, max_size=10) + + +@pytest.fixture +def dummy_stage(): + """Return a dummy stage with a very low step time.""" + return DummyStage(step_time=0.000001) + + +@pytest.fixture +def thing_server(dummy_stage): + """Yield a server with a very basic configuration.""" + with tempfile.TemporaryDirectory() as tmpdir: + server = lt.ThingServer(settings_folder=tmpdir) + server.add_thing(dummy_stage, "/stage/") + yield server + + +@pytest.fixture +def stage_client(thing_server): + """Yield a labthings ThingClient for the stage.""" + with TestClient(thing_server.app) as test_client: + yield lt.ThingClient.from_url("/stage/", client=test_client) + + +def test_override_base_movement(): + """Child classes of stage should implement functions in the hardware reference frame. + + ``move_absolute`` and ``move_relative`` are in the program reference frame and + convert to the hardware frame. It is recommended to override + ``_hardware_move_relative`` and ``_hardware_move_absolute`` instead. + + Check that the expected error is raised if the methods are overridden. + """ + + class BadStage1(BaseStage): + @lt.thing_action + def move_relative( + self, + cancel: lt.deps.CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): + pass + + with pytest.raises(RedefinedBaseMovementError): + BadStage1() + + class BadStage2(BaseStage): + @lt.thing_action + def move_absolute( + self, + cancel: lt.deps.CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): + pass + + with pytest.raises(RedefinedBaseMovementError): + BadStage2() + + +def _set_axis_direction(dummy_stage, direction): + """Set the axis direction directly even if not connected to server.""" + try: + dummy_stage.axis_inverted = direction + except NotConnectedToServerError: + pass + + +def test_apply_axis_direction_all_pos(dummy_stage): + """Test the apply axis direction function behaves as expected when axis +v3.""" + # Directly create a stage not through a ThingServer to access private methods + _set_axis_direction(dummy_stage, {"x": False, "y": False, "z": False}) + + # A list of positions to try + positions = [ + [1, 2, 3], # list + {"x": 1, "y": 2, "z": 3}, # mapping + {"x": 1, "z": 2, "y": 3}, # mapping out of order + {"x": 3}, # Mapping with only 1 value + {"x": 1, "z": 2}, # Mapping with only 2 values + ] + for pos in positions: + assert dummy_stage._apply_axis_direction(pos) == pos + + # Check tuple separately as it gets converted to list + assert dummy_stage._apply_axis_direction((1, 2, 0)) == [1, 2, 0] + + +def test_apply_axis_direction_mixed(dummy_stage): + """Test the apply axis direction function behaves as expected when axis dirs are mixed.""" + # Make x and z negative + _set_axis_direction(dummy_stage, {"x": True, "y": False, "z": True}) + + # A list of (input position, output position) to try + position_pairs = [ + ([1, 2, 3], [-1, 2, -3]), # list + ([1, "2", "3"], [-1, 2, -3]), # list with strings + ((1, 2, 0), [-1, 2, 0]), # tuple (gets converted to list) + ({"x": 1, "y": 2, "z": 3}, {"x": -1, "y": 2, "z": -3}), # mapping + ({"x": 1, "z": 2, "y": 3}, {"x": -1, "z": -2, "y": 3}), # mapping out of order + ({"x": 1, "y": "2", "z": "3"}, {"x": -1, "y": 2, "z": -3}), # mapping w strings + ({"x": 3}, {"x": -3}), # Mapping with only 1 value + ({"x": 1, "z": 2}, {"x": -1, "z": -2}), # Mapping with only 2 values + ] + for pos, expected_pos in position_pairs: + assert dummy_stage._apply_axis_direction(pos) == expected_pos + + +def test_apply_axis_errors(dummy_stage): + """Test the apply axis direction returns appropriate errors.""" + with pytest.raises(TypeError): + dummy_stage._apply_axis_direction(None) + with pytest.raises(TypeError): + dummy_stage._apply_axis_direction("Onwards!") + with pytest.raises(KeyError): + dummy_stage._apply_axis_direction({"x": -1, "y": 2, "z": 4, "up": -3}) + with pytest.raises(KeyError): + dummy_stage._apply_axis_direction({"x": -1, "y": 2, "up": -3}) + + +def test_default_values(stage_client, dummy_stage): + """Check the default values for the dummy stage.""" + # axes are x, y, z (note that going through the thing, client the tuple is + # converted to a list. + assert stage_client.axis_names == ["x", "y", "z"] + # position starts at 0, 0, 0 + assert stage_client.position == {"x": 0, "y": 0, "z": 0} + # axis direction starts is -1, 1, 1 for the dummy stage + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} + # And check the thing state + assert dummy_stage.thing_state == {"position": {"x": 0, "y": 0, "z": 0}} + + +def test_direction_inversion(stage_client, dummy_stage): + """Check axes invert as expected when called.""" + # Can't set an arbitrary value via a client as read only: + with pytest.raises(HTTPStatusError) as excinfo: + stage_client.axis_inverted = {"x": 2, "y": 1, "z": 1} + # Read only should set a 405 error code + assert excinfo.value.response.status_code == 405 + # And not modify th initial value + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} + stage_client.invert_axis_direction(axis="x") + assert stage_client.axis_inverted == {"x": False, "y": False, "z": False} + stage_client.invert_axis_direction(axis="x") + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} + stage_client.invert_axis_direction(axis="y") + assert stage_client.axis_inverted == {"x": True, "y": True, "z": False} + stage_client.invert_axis_direction(axis="y") + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} + stage_client.invert_axis_direction(axis="z") + assert stage_client.axis_inverted == {"x": True, "y": False, "z": True} + stage_client.invert_axis_direction(axis="z") + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} + + # Should error if axis doesn't exist, this is a KeyError in the server + with pytest.raises(KeyError): + dummy_stage.invert_axis_direction(axis="theta") + # But a 422 over HTTP + with pytest.raises(HTTPStatusError) as excinfo: + stage_client.invert_axis_direction(axis="theta") + assert excinfo.value.response.status_code == 422 + + +def _test_move_relative(stage_client, dummy_stage, axis_inverted, path): + """Test moving relative, ensuring position and hardware position behave as expected. + + :param axis_inverted: Is used to set the inversion. + :param path: The 3d path to move over, generated by hypothesis. + """ + _set_axis_direction(dummy_stage, axis_inverted) + # Explicitly do axes calculation here to check logic in main code. + x_dir = -1 if axis_inverted["x"] else 1 + y_dir = -1 if axis_inverted["y"] else 1 + z_dir = -1 if axis_inverted["z"] else 1 + + position = list(stage_client.position.values()) + for movement in path: + stage_client.move_relative(x=movement[0], y=movement[1], z=movement[2]) + position = [pos + move for pos, move in zip(position, movement)] + stage_pos = stage_client.get_xyz_position() + hw_pos = dummy_stage._hardware_position + assert position[0] == stage_pos[0] == hw_pos["x"] * x_dir + assert position[1] == stage_pos[1] == hw_pos["y"] * y_dir + assert position[2] == stage_pos[2] == hw_pos["z"] * z_dir + + +@given(path=path3d) +@settings( + max_examples=3, + suppress_health_check=[HealthCheck.function_scoped_fixture], + deadline=10000, +) +def test_move_relative(stage_client, dummy_stage, path): + """Loop over different inversion options and check that the stage moves as expected. + + 3 paths are tried for each case of axis inversion. This checks both that the + reported position changes as is input in path, and that the hardware position is + inverted when appropriate. + """ + # Note that the fixture is not reset. This is fine, because the stage_should work + # no matter the starting position. + + axis_names = stage_client.axis_names + # Create every combination of True/False for x,y,z. + inversion_combinations = [ + dict(zip(axis_names, inverted)) + for inverted in itertools.product([True, False], repeat=len(axis_names)) + ] + print(path) + for axis_inverted in inversion_combinations: + _test_move_relative( + stage_client=stage_client, + dummy_stage=dummy_stage, + axis_inverted=axis_inverted, + path=path, + ) + + +def _test_move_absolute(stage_client, dummy_stage, axis_inverted, path): + """Test moving relative, ensuring position and hardware position behave as expected. + + :param axis_inverted: Is used to set the inversion. + :param path: The 3d path to move over, generated by hypothesis. + """ + _set_axis_direction(dummy_stage, axis_inverted) + # Explicitly do axes calculation here to check logic in main code. + x_dir = -1 if axis_inverted["x"] else 1 + y_dir = -1 if axis_inverted["y"] else 1 + z_dir = -1 if axis_inverted["z"] else 1 + + position = list(stage_client.position.values()) + for move_to in path: + stage_client.move_absolute(x=move_to[0], y=move_to[1], z=move_to[2]) + position = move_to + stage_pos = stage_client.get_xyz_position() + hw_pos = dummy_stage._hardware_position + assert position[0] == stage_pos[0] == hw_pos["x"] * x_dir + assert position[1] == stage_pos[1] == hw_pos["y"] * y_dir + assert position[2] == stage_pos[2] == hw_pos["z"] * z_dir + + +@given(path=path3d) +@settings( + max_examples=3, + suppress_health_check=[HealthCheck.function_scoped_fixture], + deadline=10000, +) +def test_move_absolute(stage_client, dummy_stage, path): + """Loop over different inversion options and check that the stage moves as expected. + + 3 paths are tried for each case of axis inversion. This checks both that the + reported position changes as is input in path, and that the hardware position is + inverted when appropriate. + """ + # Note that the fixture is not reset. This is fine, because the stage_should work + # no matter the starting position. + + axis_names = stage_client.axis_names + # Create every combination of True/False for x,y,z. + inversion_combinations = [ + dict(zip(axis_names, inverted)) + for inverted in itertools.product([True, False], repeat=len(axis_names)) + ] + print(path) + for axis_inverted in inversion_combinations: + _test_move_absolute( + stage_client=stage_client, + dummy_stage=dummy_stage, + axis_inverted=axis_inverted, + path=path, + ) + + +def test_thing_description_equivalence(dummy_stage, mocker): + """Stop extra actions getting added to child classes without explicit approval. + + To add an extra action to a stage this test needs to be updated, highlighting it at + review. This tests should explain why the action isn't on the general base class. + """ + mock_sangaboard = mocker.Mock() + mocker.patch.dict("sys.modules", {"sangaboard": mock_sangaboard}) + from openflexure_microscope_server.things.stage.sangaboard import SangaboardThing + + # Flash LED isn't a standard stage action, most stages do not control illumination. + extra_sanga_actions = ["flash_led"] + + base_td = BaseStage().thing_description() + base_actions = set(base_td.actions.keys()) + base_properties = set(base_td.properties.keys()) + + dummy_td = dummy_stage.thing_description() + dummy_actions = set(dummy_td.actions.keys()) + dummy_properties = set(dummy_td.properties.keys()) + + sanga_td = SangaboardThing().thing_description() + # Remove known extra actions + sanga_actions = list(sanga_td.actions.keys()) + for action in extra_sanga_actions: + index = sanga_actions.index(action) + sanga_actions.pop(index) + sanga_actions = set(sanga_actions) + sanga_properties = set(sanga_td.properties.keys()) + + assert sanga_actions == dummy_actions == base_actions + assert sanga_properties == dummy_properties == base_properties diff --git a/webapp/src/App.vue b/webapp/src/App.vue index 3f76338e..b82c520e 100644 --- a/webapp/src/App.vue +++ b/webapp/src/App.vue @@ -272,15 +272,19 @@ export default { var x_rel = 0; var y_rel = 0; var z_rel = 0; + // 37 corresponds to the left key if (37 in this.arrowKeysDown) { - x_rel = x_rel + 1; - } - if (39 in this.arrowKeysDown) { x_rel = x_rel - 1; } + // 39 corresponds to the right key + if (39 in this.arrowKeysDown) { + x_rel = x_rel + 1; + } + // 38 corresponds to the up key if (38 in this.arrowKeysDown) { y_rel = y_rel + 1; } + // 40 corresponds to the down key if (40 in this.arrowKeysDown) { y_rel = y_rel - 1; } diff --git a/webapp/src/components/tabContentComponents/navigateComponents/paneNavigate.vue b/webapp/src/components/tabContentComponents/navigateComponents/paneNavigate.vue index d52215bb..e427f99a 100644 --- a/webapp/src/components/tabContentComponents/navigateComponents/paneNavigate.vue +++ b/webapp/src/components/tabContentComponents/navigateComponents/paneNavigate.vue @@ -5,7 +5,7 @@
+
+ Your z motor is currently {{ this.z_inverted }} inverted.
+
+
+ We expect that moving in +z:
+