Skip to content

Commit f81d053

Browse files
Merge pull request #50 from nickswalker/fix-axis-convention
Add ergonomic way to fix axis convention
2 parents 6f8a152 + ba8ae66 commit f81d053

10 files changed

Lines changed: 1620 additions & 9 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ with pyspacemouse.open() as device:
112112
print(state.t)
113113
```
114114

115+
### Axis Conventions
116+
117+
For new code, opt into an axis convention by passing an enum value on device open:
118+
119+
```python
120+
import pyspacemouse
121+
from pyspacemouse import AxisConvention
122+
123+
with pyspacemouse.open(axis_convention=AxisConvention.HID_Z_UP) as device:
124+
state = device.read()
125+
```
126+
127+
<p align="center">
128+
<img src="./docs/assets/hid_z_up_axes.svg" alt="HID Z-up axis convention" width="220">
129+
<img src="./docs/assets/hid_axes.svg" alt="Raw HID axis convention" width="220">
130+
<img src="./docs/assets/legacy_axis.svg" alt="Legacy PySpaceMouse axis convention" width="220">
131+
</p>
132+
133+
See [Axis Conventions](./docs/mouseApi/index.md#axis-conventions) for the full
134+
mapping table. Custom
135+
`device_spec` mappings are used exactly as provided and cannot be combined with
136+
`axis_convention`.
137+
115138
### Callbacks
116139

117140
```python

docs/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ with pyspacemouse.open() as device:
112112
print(state.t)
113113
```
114114

115+
### Axis Conventions
116+
117+
For new code, opt into an axis convention by passing an enum value on device open:
118+
119+
```python
120+
import pyspacemouse
121+
from pyspacemouse import AxisConvention
122+
123+
with pyspacemouse.open(axis_convention=AxisConvention.HID_Z_UP) as device:
124+
state = device.read()
125+
```
126+
127+
<p align="center">
128+
<img src="./assets/hid_axes.svg" alt="Raw HID axis convention" width="220">
129+
<img src="./assets/hid_z_up_axes.svg" alt="HID Z-up axis convention" width="220">
130+
<img src="./assets/legacy_axis.svg" alt="Legacy PySpaceMouse axis convention" width="220">
131+
</p>
132+
133+
See [Axis Conventions](./mouseApi/index.md#axis-conventions) for the full
134+
mapping table. Custom
135+
`device_spec` mappings are used exactly as provided and cannot be combined with
136+
`axis_convention`.
137+
115138
### Callbacks
116139

117140
```python

docs/assets/hid_axes.svg

Lines changed: 462 additions & 0 deletions
Loading

docs/assets/hid_z_up_axes.svg

Lines changed: 456 additions & 0 deletions
Loading

docs/assets/legacy_axis.svg

Lines changed: 448 additions & 0 deletions
Loading

docs/mouseApi/index.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ There are also attributes:
4242
dev.connected True if the device is connected, False otherwise
4343
dev.state Convenience property which returns the same value as read()
4444

45-
## State objects
45+
## State Objects
4646

4747
State objects returned from `read()` have 7 attributes: [t,x,y,z,roll,pitch,yaw,button].
4848

@@ -51,6 +51,40 @@ State objects returned from `read()` have 7 attributes: [t,x,y,z,roll,pitch,yaw,
5151
* roll, pitch, yaw: rotations in the range [-1.0, 1.0].
5252
* buttons: list of button states (0 or 1), in order specified in the device specifier
5353

54+
## Axis Conventions
55+
56+
Built-in device specs can be opened with an explicit axis convention:
57+
58+
```python
59+
import pyspacemouse
60+
from pyspacemouse import AxisConvention
61+
62+
with pyspacemouse.open(axis_convention=AxisConvention.HID_Z_UP) as device:
63+
state = device.read()
64+
```
65+
66+
<p align="center">
67+
<img src="../assets/hid_axes.svg" alt="Raw HID axis convention" width="220">
68+
<img src="../assets/hid_z_up_axes.svg" alt="HID Z-up axis convention" width="220">
69+
<img src="../assets/legacy_axis.svg" alt="Legacy PySpaceMouse axis convention" width="220">
70+
</p>
71+
72+
The same `axis_convention` argument is available on `open_by_path()` and
73+
`open_with_config()`.
74+
75+
| Convention | Translation axes | Rotation axes | Notes |
76+
|------------|------------------|------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
77+
| `AxisConvention.HID` | `+x` right, `+y` toward the user, `+z` down | right-handed | USB HID convention; see [HID Usage Tables, 4.2 Axis Usages](https://usb.org/document-library/hid-usage-tables-17). |
78+
| `AxisConvention.HID_Z_UP` | `+x` right, `+y` away from the user, `+z` up | right-handed | HID frame, rotated -180 degrees about +X so Z points up |
79+
| `AxisConvention.LEGACY` | `x=+HID_x`, `y=-HID_y`, `z=-HID_z` | `roll=-HID_Ry`, `pitch=-HID_Rx`, `yaw=+HID_Rz` | Deprecated. Default for backward compatibility. |
80+
81+
For new code, select a convention that matches your need. Use `AxisConvention.LEGACY` only for existing code that
82+
depends on the old PySpaceMouse signs or labels.
83+
84+
Custom `device_spec` mappings are used exactly as provided. If you pass a
85+
custom `device_spec`, do not also pass `axis_convention`; adjust the mapping
86+
with `create_device_info()` or `modify_device_info()` instead.
87+
5488
## Custom Device Configuration
5589

5690
You can customize axis mappings without modifying the TOML configuration by using the helper functions:

pyspacemouse/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
)
5353

5454
# Config helpers (for custom device configurations)
55-
from .config_helpers import create_device_info, modify_device_info
55+
from .config_helpers import apply_axis_convention, create_device_info, modify_device_info
5656

5757
# Device class
5858
from .device import SpaceMouseDevice
@@ -62,6 +62,7 @@
6262
from .types import (
6363
AXIS_NAMES,
6464
Axis,
65+
AxisConvention,
6566
AxisSpec,
6667
ButtonSpec,
6768
ButtonState,
@@ -82,6 +83,7 @@
8283
# Types
8384
"Axis",
8485
"AXIS_NAMES",
86+
"AxisConvention",
8587
"AxisSpec",
8688
"ButtonSpec",
8789
"ButtonState",
@@ -108,6 +110,7 @@
108110
"get_device_specs",
109111
"load_device_specs",
110112
# Config helpers
113+
"apply_axis_convention",
111114
"create_device_info",
112115
"modify_device_info",
113116
]

pyspacemouse/api.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@
1616

1717
from __future__ import annotations
1818

19+
import warnings
1920
from pathlib import Path
2021
from typing import Callable, List, Optional, Sequence, Tuple
2122

2223
from easyhid import Enumeration
2324

2425
from .callbacks import ButtonCallback, Config, DofCallback
26+
from .config_helpers import apply_axis_convention
2527
from .device import SpaceMouseDevice
2628
from .loader import get_device_specs
27-
from .types import DeviceInfo, SpaceMouseState
29+
from .types import AxisConvention, DeviceInfo, SpaceMouseState
2830

2931

3032
def get_connected_devices() -> List[str]:
@@ -100,11 +102,33 @@ def _create_and_open_device(
100102
button_callback: Optional[Callable[[SpaceMouseState, List[int]], None]] = None,
101103
button_callbacks: Optional[Sequence[ButtonCallback]] = None,
102104
nonblocking: bool = True,
105+
axis_convention: Optional[AxisConvention] = None,
106+
is_custom_spec: bool = False,
103107
) -> SpaceMouseDevice:
104108
"""Create, configure and open a SpaceMouseDevice.
105109
106110
This is a shared helper to avoid duplication between open() and open_by_path().
107111
"""
112+
if is_custom_spec and axis_convention is not None:
113+
raise ValueError(
114+
"axis_convention and device_spec are mutually exclusive. "
115+
"Manually change the axis mapping in the spec if you need to."
116+
)
117+
if not is_custom_spec:
118+
axis_convention = (
119+
AxisConvention.LEGACY if axis_convention is None else AxisConvention(axis_convention)
120+
)
121+
if axis_convention == AxisConvention.LEGACY:
122+
warnings.warn(
123+
"AxisConvention.LEGACY is deprecated for built-in device specs "
124+
"and will be removed in a future release. Pass "
125+
"axis_convention=AxisConvention.HID_Z_UP for the recommended "
126+
"right-handed Z-up frame, or AxisConvention.HID for raw HID axes.",
127+
DeprecationWarning,
128+
stacklevel=3,
129+
)
130+
spec = apply_axis_convention(spec, axis_convention)
131+
108132
mouse = SpaceMouseDevice(info=spec, device=hid_device)
109133
mouse.configure(
110134
callback=callback,
@@ -127,6 +151,7 @@ def open_by_path(
127151
button_callbacks: Optional[Sequence[ButtonCallback]] = None,
128152
nonblocking: bool = True,
129153
device_spec: Optional[DeviceInfo] = None,
154+
axis_convention: Optional[AxisConvention] = None,
130155
) -> SpaceMouseDevice:
131156
"""Open a SpaceMouse device by its filesystem path.
132157
@@ -143,7 +168,13 @@ def open_by_path(
143168
nonblocking: If True, use non-blocking reads (required for callbacks)
144169
device_spec: Optional custom DeviceInfo. If provided, uses this
145170
instead of looking up by VID/PID. Useful for custom
146-
axis mappings or unsupported devices.
171+
axis mappings or unsupported devices. Custom specs are
172+
used exactly as provided.
173+
axis_convention: Coordinate convention for axis values. If None,
174+
uses the deprecated legacy convention for backward
175+
compatibility. Use AxisConvention.HID_Z_UP for a
176+
right-handed Z-up frame. Mutually exclusive with
177+
device_spec.
147178
148179
Returns:
149180
SpaceMouseDevice instance (use as context manager for auto-cleanup)
@@ -175,7 +206,8 @@ def open_by_path(
175206
raise FileNotFoundError(f"No HID device found at path '{path}'.")
176207

177208
# Use provided spec or find matching device specification
178-
if device_spec is not None:
209+
is_custom_spec = device_spec is not None
210+
if is_custom_spec:
179211
spec = device_spec
180212
else:
181213
all_specs = get_device_specs()
@@ -207,6 +239,8 @@ def open_by_path(
207239
button_callback=button_callback,
208240
button_callbacks=button_callbacks,
209241
nonblocking=nonblocking,
242+
axis_convention=axis_convention,
243+
is_custom_spec=is_custom_spec,
210244
)
211245

212246

@@ -220,6 +254,7 @@ def open(
220254
device: Optional[str] = None,
221255
device_index: int = 0,
222256
device_spec: Optional[DeviceInfo] = None,
257+
axis_convention: Optional[AxisConvention] = None,
223258
) -> SpaceMouseDevice:
224259
"""Open a SpaceMouse device by name or auto-detection.
225260
@@ -240,7 +275,14 @@ def open(
240275
device_spec: Optional custom DeviceInfo. If provided, uses this
241276
instead of looking up from TOML. Useful for custom
242277
axis mappings. The device/device_index are still used
243-
to find the HID device.
278+
to find the HID device. Custom specs are used exactly
279+
as provided.
280+
axis_convention: Coordinate convention for axis values. If None,
281+
uses the deprecated legacy convention for backward
282+
compatibility. Use AxisConvention.HID_Z_UP for a
283+
geometrically consistent right-handed Z-up frame, or
284+
AxisConvention.HID for raw HID values (Z down).
285+
Mutually exclusive with device_spec.
244286
245287
Returns:
246288
SpaceMouseDevice instance (use as context manager for auto-cleanup)
@@ -261,8 +303,9 @@ def open(
261303
if device not in device_specs:
262304
raise ValueError(f"Unknown device: '{device}'. Available: {list(device_specs.keys())}")
263305

264-
# Use provided spec or get from TOML
265-
spec = device_spec if device_spec is not None else device_specs[device]
306+
# Use provided spec exactly as-is, or get from TOML and apply convention.
307+
is_custom_spec = device_spec is not None
308+
spec = device_spec if is_custom_spec else device_specs[device]
266309

267310
# Find matching HID devices
268311
hid = Enumeration()
@@ -291,6 +334,8 @@ def open(
291334
button_callback=button_callback,
292335
button_callbacks=button_callbacks,
293336
nonblocking=nonblocking,
337+
axis_convention=axis_convention,
338+
is_custom_spec=is_custom_spec,
294339
)
295340

296341

@@ -299,6 +344,7 @@ def open_with_config(
299344
nonblocking: bool = True,
300345
device: Optional[str] = None,
301346
device_index: int = 0,
347+
axis_convention: Optional[AxisConvention] = None,
302348
) -> SpaceMouseDevice:
303349
"""Open a SpaceMouse device using a Config object.
304350
@@ -307,6 +353,7 @@ def open_with_config(
307353
nonblocking: If True, use non-blocking reads
308354
device: Device name to open
309355
device_index: Which instance to open if multiple connected
356+
axis_convention: Coordinate convention for axis values (see open()).
310357
311358
Returns:
312359
SpaceMouseDevice instance (use as context manager for auto-cleanup)
@@ -320,4 +367,5 @@ def open_with_config(
320367
nonblocking=nonblocking,
321368
device=device,
322369
device_index=device_index,
370+
axis_convention=axis_convention,
323371
)

0 commit comments

Comments
 (0)