Compare commits

...

14 Commits

Author SHA1 Message Date
Laurent Trinques ac40d110fb Merge pull request #1028 from ispyisail/fix/spacemouse-macos-shared-open
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m51s
Fix 3D mouse not detected on macOS while 3DxWare is running
2026-09-25 11:13:33 +02:00
ispyisail 72cf86ece0 Open the 3D mouse non-exclusively on macOS
hidapi's macOS backend opens every device with
kIOHIDOptionsTypeSeizeDevice unless told otherwise (hid_init() calls
hid_darwin_set_open_exclusive(1) for backward compatibility). When
3DxWare is running it already holds the SpacePilot/SpaceMouse, so the
seize fails, hid_open_path() returns NULL and HidBackend::scan() keeps
retrying every 3 s without ever finding the device.

Reported in discussion #599: a SpacePilot Pro (046d:c629) is listed by
hid_enumerate() and works in 3DxWare, but has no effect in QET's macOS
build. Enumeration never opens a device, so it did not exercise this.

Guarded on HID_API_VERSION >= 0.12, where the setter first appeared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:27:37 +12:00
Laurent Trinques 8aefdcc481 Merge pull request #1027 from ispyisail/fix/bugtracker-95-collection-project-name
Fix bugtracker #95: Collections panel shows "Projet sans titre" for untitled projects
2026-09-25 09:05:25 +02:00
ispyisail 3a45625d69 Fix bugtracker #95: collections pane shows "Untitled project" for a titleless project
The root of a project's embedded collection showed "Projet sans titre"
whenever the project had no title, while the project panel shows the
file name. Fall back to the file name the same way, and only use
"Projet sans titre" for a project that has neither.

The name was also computed once, so changing the project title or saving
it under a new name left the pane stale until the collections were
reloaded. Update it on projectTitleChanged and projectFilePathChanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:24:17 +12:00
Laurent Trinques 988357057f macOS add SPACEMOUSE_BACKEND=hid 2026-09-25 05:15:06 +02:00
Laurent Trinques e9676ef87e Merge pull request #1026 from ispyisail/feature/spacemouse-windows
Build the Windows package with 3D mouse support
2026-09-25 04:30:40 +02:00
Laurent Trinques ca507dc0f3 Merge pull request #1025 from ispyisail/feature/spacemouse-hid
Add a 3D mouse backend that reads the device over USB (hidapi)
2026-09-25 04:12:35 +02:00
Laurent Trinques 1932150ea6 Merge pull request #1023 from ispyisail/feature/spacemouse-settings
Add 3D mouse speed, direction and twist-to-zoom settings
2026-09-25 03:53:15 +02:00
Laurent Trinques 23241fba69 Merge pull request #1022 from ispyisail/fix/spacemouse-element-editor
Fix 3D mouse doing nothing in the element editor
2026-09-25 03:52:23 +02:00
ispyisail f2f1051914 Build the Windows package with 3D mouse support (hidapi)
Installs MSYS2's hidapi and turns on QET_ENABLE_SPACEMOUSE with the
hidapi backend, so the Windows package reads a 3Dconnexion SpaceMouse
directly over USB, with no 3Dconnexion driver (discussion #599).

The existing transitive DLL scan copies libhidapi-0.dll into the package,
and both installers take everything in bin/. Two checks make a silent
loss fail the build instead: CMake only warns when hidapi is missing, so
the exe must link against it, and the DLL must be deployed, or
QElectroTech.exe would not start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:30:48 +12:00
ispyisail 313aafc95b Add misc/spacemouse-capture.py to record a 3D mouse's raw USB reports
For device owners on Linux: guided movements, with every raw report and
the device's report descriptor saved to one JSON file. Dropped into
tests/qttest/fixtures/spacemouse/, a recording is checked by
tst_spacemousehid against what the user was asked to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:00:59 +12:00
ispyisail aaeaff55cc Add a hidapi 3D mouse backend that needs no 3Dconnexion driver
The 3D mouse only worked on Linux, through spacenavd. This adds a second
backend that reads the device directly over USB through hidapi, with no
3Dconnexion driver or SDK: the route to Windows and macOS (discussion
#599), and usable on Linux without spacenavd.

SpaceMouseHid decodes the raw reports from the device's own report
descriptor -- where each axis and button sits, its range, absolute or
relative -- so no per-model table is needed, with the classic report
1/2/3 layout as a fallback when the descriptor cannot be read and the
0x1c button list newer devices send. Absolute axes are rescaled to
+-500 exactly as spacenavd does, so both backends give QET the same
values. HidBackend polls from the main thread (fast while moving, slow
when still), emits one sample per poll, and looks for a device every 3 s
so plugging one in or back in needs no restart.

QET_SPACEMOUSE_BACKEND (auto, spnav, hid) picks the backend; auto keeps
libspnav on Linux when it is found and uses hidapi otherwise. hidapi is
found through pkg-config as hidapi-hidraw (Linux) or hidapi (MSYS2,
Homebrew).

A sample arriving in the same millisecond as the previous one now counts
for no time instead of a full period, so a burst of queued samples no
longer moves the view further than the time it covers.

Tested without a device: tst_spacemousehid (descriptor parsing, broken
and hostile descriptors, every report form, recordings from real devices
once they are added to fixtures/spacemouse), and end to end on Linux
through a virtual USB device created with /dev/uhid: the same moves give
byte-identical screenshots through the hidapi and libspnav backends, an
absolute axis is rescaled as spacenavd does, buttons trigger their
bound action, and unplugging and replugging while QET runs (including
with a dialog open that a device button opened) reconnects cleanly.
Not tested on Windows, macOS or real hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:55:30 +12:00
ispyisail 17ebbffbca Add 3D mouse speed, direction, dead zone and twist-to-zoom settings
The 3D mouse's pan and zoom speeds were fixed guesses, and each sample
was applied as it came, so the speed on screen depended on how often
the driver sends samples -- different for every platform and device.

Motion now goes through SpaceMouseMotion::map(), which scales each
sample by the time since the previous one, and applies the user's
settings from a new "Mouvement" section of Configuration > Souris 3D:
pan and zoom speed, a dead zone, inverting each axis, and zooming by
push/pull (as before) or by twisting the cap. The defaults keep the
previous behaviour. Zoom is now exponential in the deflection, so the
factor stays positive however hard the cap is pulled (1 + z/1000 went
negative past z = -1000) and an equal push and pull cancel out. Sub-
pixel pan is carried over between samples instead of being rounded
away. The backend now reports all six axes.

tst_spacemousemotion covers the mapping without a device and is built
whether or not QET_ENABLE_SPACEMOUSE is on. The new behaviour was also
checked end to end with tools/spnav-shim (qelectrotech-docker): twist
with a dead zone of 10 ignores push/pull and small drift, and a twist of
60 gives the same frame as a push of 50 with the defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:56:04 +12:00
ispyisail 04e2279a7f Make the 3D mouse pan and zoom the element editor too
The 3D mouse only acted on the diagram editor: SpaceMouseListener
ignored every other window, so the element editor did not move at
all (reported by scorpio810 in PR #635 with a SpacePilot Pro).

applyMotion() now also handles a QETElementEditor, driving its
ElementView through the same scrollbar pan and a new
ElementView::zoom(factor), which keeps the wheel zoom's clamping.
The element editor's scene rect only covers what is on screen, so it
is grown before each pan sample, as its middle-button pan does;
without that the scrollbars have no range and the pan does nothing.

Verified under Xvfb with an LD_PRELOAD stand-in for libspnav feeding
recorded motion samples: element editor zooms 1.63x for ten z=50
samples and pans; diagram editor screenshots are byte-identical to
master's for the same input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:32:10 +12:00
29 changed files with 2235 additions and 149 deletions
+10
View File
@@ -57,6 +57,7 @@ jobs:
mingw-w64-ucrt-x86_64-nsis
mingw-w64-ucrt-x86_64-angleproject
mingw-w64-ucrt-x86_64-qt6-declarative
mingw-w64-ucrt-x86_64-hidapi
- name: Cache ccache
uses: actions/cache@v5
@@ -120,6 +121,8 @@ jobs:
-DCMAKE_POLICY_DEFAULT_CMP0077=NEW \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DQET_EXPORT_PROJECT_DB=ON \
-DQET_ENABLE_SPACEMOUSE=ON \
-DQET_SPACEMOUSE_BACKEND=hid \
-DCMAKE_C_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
-DSQLite3_INCLUDE_DIR=/ucrt64/include \
@@ -146,6 +149,11 @@ jobs:
SIZE=$(stat -c%s "$EXE")
echo "Exe found: $EXE ($SIZE bytes)"
[ "$SIZE" -gt 100000 ] || { echo "ERROR: exe too small"; exit 1; }
# 3D mouse support (discussion #599): CMake only warns when hidapi
# is missing, which would ship a package without it -- fail here.
ldd "$EXE" | grep -qi 'libhidapi' \
|| { echo "ERROR: exe not linked against hidapi -- 3D mouse support missing"; exit 1; }
echo "3D mouse support: linked against $(ldd "$EXE" | grep -io 'libhidapi[^ ]*' | head -1)"
- name: Deploy — copy exe + windeployqt + DLLs
shell: msys2 {0}
@@ -191,6 +199,8 @@ jobs:
echo "=== $DLL_COUNT DLLs present after scan ==="
ls -lh "$BIN/QElectroTech.exe" || { echo "ERROR: exe missing from bin/"; exit 1; }
[ "$DLL_COUNT" -gt 5 ] || { echo "ERROR: too few DLLs"; exit 1; }
# Without it QElectroTech.exe would not start at all.
[ -f "$BIN/libhidapi-0.dll" ] || { echo "ERROR: libhidapi-0.dll not deployed"; exit 1; }
cd "$GITHUB_WORKSPACE"
cp /ucrt64/bin/libgcc_s_seh-1.dll "$BIN/"
+3 -2
View File
@@ -173,8 +173,9 @@ endif()
if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED)
list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::SPNAV)
endif()
# A future Windows/macOS (3DxWare) backend would list.APPEND its own SDK
# libraries here, behind its own *_ENABLED guard, alongside this one.
if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::HIDAPI)
endif()
if(APPLE)
# CFBundleIdentifier must not be empty. CMake's default Info.plist
+4 -4
View File
@@ -48,7 +48,7 @@ option(BUILD_WITH_KF "Build with KDE Frameworks" ON)
# strict behaviour, and only developers who opt in trade that for the speed.
option(QET_ENABLE_PCH "Use precompiled headers (developer build speed; may mask missing #includes)" OFF)
# Phase 1 (Linux, libspnav) of discussion #599: 3Dconnexion SpaceMouse/
# SpacePilot pan/zoom support. Off by default -- see cmake/find_spacemouse.cmake
# for what happens when it is on but libspnav isn't found.
option(QET_ENABLE_SPACEMOUSE "Build with 3Dconnexion/libspnav 3D mouse support for pan/zoom (Linux, requires libspnav-dev and spacenavd)" OFF)
# Discussion #599: 3Dconnexion SpaceMouse/SpacePilot pan/zoom support. Off by
# default -- see cmake/find_spacemouse.cmake for the backends and what happens
# when it is on but no library is found.
option(QET_ENABLE_SPACEMOUSE "Build with 3D mouse (3Dconnexion SpaceMouse) support for pan/zoom; needs libspnav or hidapi, see QET_SPACEMOUSE_BACKEND" OFF)
+47 -27
View File
@@ -20,44 +20,64 @@ message(" - find_spacemouse")
# so none of this runs, and the default build is entirely unaffected: no new
# dependency, no new source files, no new symbols.
#
# When it is on, the platform decides which backend (if any) is available --
# see sources/spacemouse/spacemousebackend.h for what a backend is. Only one
# exists today: SpnavBackend, Linux, via libspnav. A Windows/macOS backend
# would need 3Dconnexion's proprietary 3DxWare SDK and is not implemented
# (see discussion #599's own phase split); until it is, turning this option
# on there downgrades cleanly with a warning rather than failing configure.
# When it is on, QET_SPACEMOUSE_BACKEND picks how the device is read -- see
# sources/spacemouse/spacemousebackend.h for what a backend is:
# spnav Linux, through the spacenavd daemon (libspnav)
# hid any platform, directly over USB (hidapi), no 3Dconnexion driver
# auto spnav on Linux when libspnav is found, hid otherwise
# A backend whose library is not found downgrades the option to off with a
# warning, rather than failing configure for an opt-in feature.
set(QET_SPACEMOUSE_BACKEND "auto" CACHE STRING "3D mouse backend: auto, spnav or hid")
set_property(CACHE QET_SPACEMOUSE_BACKEND PROPERTY STRINGS auto spnav hid)
set(QET_SPACEMOUSE_ENABLED FALSE)
set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED FALSE)
set(QET_SPACEMOUSE_BACKEND_HID_ENABLED FALSE)
if(QET_ENABLE_SPACEMOUSE)
if(UNIX AND NOT APPLE)
# libspnav is looked for via its pkg-config file (present on every
# distro package of it this was checked against: Debian/Ubuntu's
# libspnav-dev ships /usr/share/pkgconfig/spnav.pc). Not found does
# not hard-fail the whole configure -- it downgrades the option back
# to off with a clear message, so a developer who doesn't have the
# library installed still gets a normal build instead of a configure
# error for an opt-in feature they didn't ask to block on.
find_package(PkgConfig)
if(PkgConfig_FOUND)
pkg_check_modules(SPNAV IMPORTED_TARGET spnav)
endif()
find_package(PkgConfig)
set(_qet_spacemouse_try_spnav FALSE)
set(_qet_spacemouse_try_hid FALSE)
if(QET_SPACEMOUSE_BACKEND STREQUAL "spnav")
set(_qet_spacemouse_try_spnav TRUE)
elseif(QET_SPACEMOUSE_BACKEND STREQUAL "hid")
set(_qet_spacemouse_try_hid TRUE)
else()
if(UNIX AND NOT APPLE)
set(_qet_spacemouse_try_spnav TRUE)
endif()
set(_qet_spacemouse_try_hid TRUE)
endif()
# libspnav: Debian/Ubuntu's libspnav-dev ships spnav.pc.
if(_qet_spacemouse_try_spnav AND PkgConfig_FOUND)
pkg_check_modules(SPNAV IMPORTED_TARGET spnav)
if(SPNAV_FOUND)
set(QET_SPACEMOUSE_ENABLED TRUE)
set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED TRUE)
add_definitions(-DQET_SPACEMOUSE_SUPPORT)
add_definitions(-DQET_SPACEMOUSE_BACKEND_SPNAV)
message("QET_ENABLE_SPACEMOUSE ON (backend: libspnav ${SPNAV_VERSION})")
else()
message(WARNING "QET_ENABLE_SPACEMOUSE is ON but libspnav was not found via pkg-config "
"(install libspnav-dev, or the equivalent for your distribution) -- "
"building WITHOUT 3D mouse support.")
endif()
endif()
# hidapi: hidapi-hidraw.pc on Linux (libhidapi-dev), hidapi.pc from
# MSYS2 (mingw-w64-ucrt-x86_64-hidapi) and Homebrew (hidapi).
if(NOT QET_SPACEMOUSE_ENABLED AND _qet_spacemouse_try_hid AND PkgConfig_FOUND)
pkg_search_module(HIDAPI IMPORTED_TARGET hidapi-hidraw hidapi)
if(HIDAPI_FOUND)
set(QET_SPACEMOUSE_ENABLED TRUE)
set(QET_SPACEMOUSE_BACKEND_HID_ENABLED TRUE)
add_definitions(-DQET_SPACEMOUSE_BACKEND_HID)
message("QET_ENABLE_SPACEMOUSE ON (backend: hidapi ${HIDAPI_VERSION})")
endif()
endif()
if(QET_SPACEMOUSE_ENABLED)
add_definitions(-DQET_SPACEMOUSE_SUPPORT)
else()
message(WARNING "QET_ENABLE_SPACEMOUSE is ON but no 3D mouse backend is implemented yet "
"for this platform (only Linux/libspnav exists today -- see "
"https://github.com/qelectrotech/qelectrotech-source-mirror/discussions/599) "
"-- building WITHOUT 3D mouse support.")
message(WARNING "QET_ENABLE_SPACEMOUSE is ON but no library was found for the "
"'${QET_SPACEMOUSE_BACKEND}' backend (libspnav-dev for spnav, "
"hidapi for hid, via pkg-config) -- building WITHOUT 3D mouse support.")
endif()
endif()
+11
View File
@@ -882,6 +882,8 @@ if(QET_SPACEMOUSE_ENABLED)
${QET_DIR}/sources/spacemouse/spacemousebuttonmap.h
${QET_DIR}/sources/spacemouse/spacemouselistener.cpp
${QET_DIR}/sources/spacemouse/spacemouselistener.h
${QET_DIR}/sources/spacemouse/spacemousemotion.cpp
${QET_DIR}/sources/spacemouse/spacemousemotion.h
${QET_DIR}/sources/ui/configpage/spacemouseconfigpage.cpp
${QET_DIR}/sources/ui/configpage/spacemouseconfigpage.h
)
@@ -894,6 +896,15 @@ if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED)
)
endif()
if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
list(APPEND QET_SRC_FILES
${QET_DIR}/sources/spacemouse/hidbackend.cpp
${QET_DIR}/sources/spacemouse/hidbackend.h
${QET_DIR}/sources/spacemouse/spacemousehid.cpp
${QET_DIR}/sources/spacemouse/spacemousehid.h
)
endif()
set(TS_FILES
${QET_DIR}/lang/qet_ar.ts
${QET_DIR}/lang/qet_ca.ts
+3 -1
View File
@@ -103,7 +103,9 @@ cmake -S . -B "$BUILD_DIR" -G Ninja \
-DBUILD_WITH_KF=$BUILD_WITH_KF \
-DBUILD_KF=OFF \
-DQET_EXPORT_PROJECT_DB=ON \
-DPACKAGE_TESTS=OFF
-DPACKAGE_TESTS=OFF \
-DQET_ENABLE_SPACEMOUSE=ON \
-DQET_SPACEMOUSE_BACKEND=hid
if [ $? -ne 0 ]; then
echo "ERROR: cmake configure failed."
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
# Copyright 2006-2026 The QElectroTech Team
# This file is part of QElectroTech.
#
# QElectroTech is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# QElectroTech is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
"""Record raw USB reports from a 3Dconnexion 3D mouse, for QElectroTech.
QElectroTech's Windows/macOS 3D mouse support reads the device directly
over USB, so it has to decode each device model's raw reports itself.
This records what your device sends while you make a few guided
movements, and saves it to one file you can attach to the discussion.
Nothing is sent anywhere.
sudo python3 spacemouse-capture.py # finds the device itself
sudo python3 spacemouse-capture.py --list # just show what it finds
sudo is needed because /dev/hidraw* is usually readable by root only.
spacenavd can keep running. If the recording comes out empty, stop it
(`sudo systemctl stop spacenavd`) and try again.
Only the standard library is used, so it runs on any Linux with Python 3.
"""
import argparse
import datetime
import glob
import json
import os
import platform
import select
import sys
import time
VENDORS = {0x046D: 'Logitech (older 3Dconnexion)', 0x256F: '3Dconnexion'}
STEPS = [
('rest', 'Do not touch the device.', 3),
('right', 'Push the cap to the RIGHT and hold it, then let go.', 4),
('left', 'Push the cap to the LEFT and hold it, then let go.', 4),
('away', 'Push the cap AWAY from you and hold it, then let go.', 4),
('toward', 'Pull the cap TOWARDS you and hold it, then let go.', 4),
('down', 'Press the cap DOWN and hold it, then let go.', 4),
('up', 'Lift the cap UP and hold it, then let go.', 4),
('twist_cw', 'TWIST the cap CLOCKWISE (seen from above) and hold, then let go.', 4),
('twist_ccw', 'TWIST the cap ANTICLOCKWISE and hold, then let go.', 4),
('tilt_away', 'TILT the cap AWAY from you and hold, then let go.', 4),
('tilt_right', 'TILT the cap to the RIGHT and hold, then let go.', 4),
('buttons', 'Press each button once, slowly, one at a time, in any order.', 15),
]
def find_devices():
"""Return [{hidraw, name, vendor, product, sysfs}] for 3Dconnexion devices."""
found = []
for sysdir in sorted(glob.glob('/sys/class/hidraw/hidraw*')):
try:
with open(os.path.join(sysdir, 'device', 'uevent')) as f:
uevent = dict(line.strip().split('=', 1) for line in f if '=' in line)
except OSError:
continue
# HID_ID=0003:0000256F:0000C635 (bus:vendor:product)
try:
_bus, vendor, product = (int(x, 16) for x in uevent.get('HID_ID', '').split(':'))
except ValueError:
continue
if vendor not in VENDORS:
continue
found.append({
'hidraw': '/dev/' + os.path.basename(sysdir),
'name': uevent.get('HID_NAME', '?'),
'vendor': '%04x' % vendor,
'product': '%04x' % product,
'sysfs': sysdir,
})
return found
def read_descriptor(sysdir):
try:
with open(os.path.join(sysdir, 'device', 'report_descriptor'), 'rb') as f:
return f.read().hex()
except OSError as e:
return 'unreadable: %s' % e
def record(fd, seconds):
"""Read every report arriving within `seconds`; return [[ms, hex], ...]."""
reports = []
start = time.monotonic()
while True:
left = seconds - (time.monotonic() - start)
if left <= 0:
return reports
ready, _, _ = select.select([fd], [], [], left)
if not ready:
continue
try:
data = os.read(fd, 64)
except BlockingIOError:
continue
if not data: # only a test FIFO with no writer does this
time.sleep(0.01)
continue
reports.append([round((time.monotonic() - start) * 1000, 1), data.hex()])
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--list', action='store_true', help='only list matching devices')
ap.add_argument('--device', help='hidraw path, if more than one device is found')
ap.add_argument('--descriptor', help=argparse.SUPPRESS) # testing without a device
ap.add_argument('--yes', action='store_true', help=argparse.SUPPRESS) # no Enter prompts
ap.add_argument('-o', '--output', help='output file (default: spacemouse-capture-<product>.json)')
args = ap.parse_args()
devices = find_devices()
if args.list:
for d in devices:
print('%(hidraw)s %(vendor)s:%(product)s %(name)s' % d)
if not devices:
print('No 3Dconnexion device found under /sys/class/hidraw.')
return 0 if devices else 1
if args.device:
dev = next((d for d in devices if d['hidraw'] == args.device),
{'hidraw': args.device, 'name': '?', 'vendor': '?', 'product': '?', 'sysfs': None})
elif len(devices) == 1:
dev = devices[0]
elif not devices:
sys.exit('No 3Dconnexion device found. Is it plugged in? (try --list)')
else:
sys.exit('Several devices found, pick one with --device:\n' +
'\n'.join(' %(hidraw)s %(name)s' % d for d in devices))
if args.descriptor:
with open(args.descriptor, 'rb') as f:
descriptor = f.read().hex()
elif dev['sysfs']:
descriptor = read_descriptor(dev['sysfs'])
else:
descriptor = 'unknown'
try:
fd = os.open(dev['hidraw'], os.O_RDONLY | os.O_NONBLOCK)
except PermissionError:
sys.exit('Permission denied on %s -- run with sudo.' % dev['hidraw'])
print('Recording from %s (%s, %s:%s).' % (dev['hidraw'], dev['name'], dev['vendor'], dev['product']))
print('For each step, press Enter, do the movement, and wait for the next prompt.\n')
result = {
'tool': 'spacemouse-capture.py 1',
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
'system': platform.platform(),
'device': {k: dev[k] for k in ('name', 'vendor', 'product')},
'report_descriptor': descriptor,
'steps': [],
}
try:
for i, (key, text, seconds) in enumerate(STEPS, 1):
print('[%d/%d] %s' % (i, len(STEPS), text))
if not args.yes:
input(' Press Enter to start (%d s)... ' % seconds)
reports = record(fd, seconds)
print(' %d reports recorded.\n' % len(reports))
result['steps'].append({'step': key, 'instruction': text, 'reports': reports})
except KeyboardInterrupt:
print('\nStopped early -- saving what was recorded so far.')
finally:
os.close(fd)
out = args.output or 'spacemouse-capture-%s.json' % dev['product']
with open(out, 'w') as f:
json.dump(result, f, indent=1)
total = sum(len(s['reports']) for s in result['steps'])
print('Saved %s (%d reports in total).' % (out, total))
if total == 0:
print('Nothing was recorded. Try stopping spacenavd first: sudo systemctl stop spacenavd')
else:
print('Please attach this file to discussion #599. Thank you!')
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -503,6 +503,10 @@ void ElementsCollectionModel::addProject(QETProject *project, bool set_data)
connect(project->embeddedElementCollection(),
&XmlElementCollection::directoryRemoved,
this, &ElementsCollectionModel::itemRemovedFromCollection);
connect(project, &QETProject::projectTitleChanged,
this, &ElementsCollectionModel::projectNameChanged);
connect(project, &QETProject::projectFilePathChanged,
this, &ElementsCollectionModel::projectNameChanged);
}
/**
@@ -534,6 +538,10 @@ void ElementsCollectionModel::removeProject(QETProject *project)
&XmlElementCollection::directoryRemoved,
this,
&ElementsCollectionModel::itemRemovedFromCollection);
disconnect(project, &QETProject::projectTitleChanged,
this, &ElementsCollectionModel::projectNameChanged);
disconnect(project, &QETProject::projectFilePathChanged,
this, &ElementsCollectionModel::projectNameChanged);
}
}
@@ -774,3 +782,15 @@ void ElementsCollectionModel::updateItem(const QString& path)
eci->setUpData();
}
}
/**
@brief ElementsCollectionModel::projectNameChanged
Update the displayed name of the collection of project,
when its title or its file path changed.
@param project
*/
void ElementsCollectionModel::projectNameChanged(QETProject *project)
{
if (XmlProjectElementCollectionItem *xpeci = m_project_hash.value(project))
xpeci->updateProjectName();
}
@@ -73,6 +73,7 @@ class ElementsCollectionModel : public QStandardItemModel
void elementIntegratedToCollection (const QString& path);
void itemRemovedFromCollection (const QString& path);
void updateItem (const QString& path);
void projectNameChanged (QETProject *project);
private:
QList <QETProject *> m_project_list;
@@ -21,6 +21,8 @@
#include "../qetproject.h"
#include "xmlelementcollection.h"
#include <QFileInfo>
/**
@brief XmlProjectElementCollectionItem::XmlProjectElementCollectionItem
Constructor
@@ -58,10 +60,7 @@ QString XmlProjectElementCollectionItem::localName()
return text();
if (isCollectionRoot()) {
if (m_project->title().isEmpty())
setText(QObject::tr("Projet sans titre"));
else
setText(m_project->title());
updateProjectName();
}
else {
ElementsLocation location (embeddedPath(), m_project);
@@ -71,6 +70,25 @@ QString XmlProjectElementCollectionItem::localName()
return text();
}
/**
@brief XmlProjectElementCollectionItem::updateProjectName
Set the displayed name of the collection root from the project :
its title, or its file name when it has no title, like the project panel.
Does nothing if this item is not the root of the collection.
*/
void XmlProjectElementCollectionItem::updateProjectName()
{
if (!isCollectionRoot() || !m_project)
return;
if (!m_project->title().isEmpty())
setText(m_project->title());
else if (!m_project->filePath().isEmpty())
setText(QFileInfo(m_project->filePath()).completeBaseName());
else
setText(QObject::tr("Projet sans titre"));
}
/**
@brief XmlProjectElementCollectionItem::name
@return The collection name of this item
@@ -45,6 +45,7 @@ class XmlProjectElementCollectionItem : public ElementCollectionItem
bool isCollectionRoot() const override;
void addChildAtPath(const QString &collection_name) override;
QETProject * project() const;
void updateProjectName();
void setProject (QETProject *project,
bool set_data = true,
+12
View File
@@ -117,6 +117,18 @@ void ElementView::scaleClamped(qreal factor)
scale(factor, factor);
}
/**
@brief ElementView::zoom
Zoom by an arbitrary factor, for a continuous input such as a 3D mouse.
Same clamping as the wheel zoom.
@param zoom_factor : > 1 zooms in, < 1 zooms out
*/
void ElementView::zoom(qreal zoom_factor)
{
adjustSceneRect();
scaleClamped(zoom_factor);
}
/**
Agrandit le schema (+33% = inverse des -25 % de zoomMoins())
*/
+1
View File
@@ -41,6 +41,7 @@ class ElementView : public QGraphicsView {
ElementScene *scene() const;
void setScene(ElementScene *);
QRectF viewedSceneRect() const;
void zoom(qreal zoom_factor);
protected:
void mousePressEvent(QMouseEvent *) override;
+6
View File
@@ -2231,6 +2231,12 @@ void QETApp::configureQET()
// affiche le dialogue puis evite de le lier a un quelconque widget parent
cd.exec();
cd.setParent(nullptr, cd.windowFlags());
#ifdef QET_SPACEMOUSE_SUPPORT
if (m_space_mouse_listener) {
m_space_mouse_listener->reloadSettings();
}
#endif
}
/**
+205
View File
@@ -0,0 +1,205 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "hidbackend.h"
#include <QTimer>
#include <hidapi.h>
#if defined(Q_OS_MACOS) && HID_API_VERSION >= HID_API_MAKE_VERSION(0, 12, 0)
# include <hidapi_darwin.h>
#endif
namespace {
constexpr int SCAN_INTERVAL_MS = 3000;
constexpr int FAST_POLL_MS = 8; // devices report at up to ~125 Hz
constexpr int SLOW_POLL_MS = 50;
//Fast polls in a row with nothing to read before slowing down.
constexpr int IDLE_POLLS_BEFORE_SLOW = 125;
constexpr int MAX_REPORT_SIZE = 64;
constexpr int MAX_DESCRIPTOR_SIZE = 4096;
}
/**
@brief HidBackend::HidBackend
@param parent
*/
HidBackend::HidBackend(QObject *parent) :
SpaceMouseBackend(parent)
{
if (hid_init() != 0) {
return;
}
m_hid_initialised = true;
#if defined(Q_OS_MACOS) && HID_API_VERSION >= HID_API_MAKE_VERSION(0, 12, 0)
//hidapi opens devices exclusively on macOS by default, which fails
//while 3DxWare has the device open (discussion #599).
hid_darwin_set_open_exclusive(0);
#endif
m_read_timer = new QTimer(this);
connect(m_read_timer, &QTimer::timeout, this, &HidBackend::readReports);
m_scan_timer = new QTimer(this);
m_scan_timer->setInterval(SCAN_INTERVAL_MS);
connect(m_scan_timer, &QTimer::timeout, this, &HidBackend::scan);
scan();
}
/**
@brief HidBackend::~HidBackend
*/
HidBackend::~HidBackend()
{
close();
if (m_hid_initialised) {
hid_exit();
}
}
/**
@brief HidBackend::scan
Open the first 3D mouse found, or keep looking every few seconds.
*/
void HidBackend::scan()
{
if (m_device) {
return;
}
hid_device_info *list = hid_enumerate(0, 0);
for (hid_device_info *info = list; info && !m_device; info = info->next)
{
if (!SpaceMouseHid::isSpaceMouse(info->vendor_id, info->product_id,
info->usage_page, info->usage)) {
continue;
}
m_device = hid_open_path(info->path);
}
hid_free_enumeration(list);
if (!m_device) {
m_scan_timer->start();
return;
}
m_scan_timer->stop();
hid_set_nonblocking(m_device, 1);
SpaceMouseHid::Layout layout;
#if HID_API_VERSION >= HID_API_MAKE_VERSION(0, 14, 0)
unsigned char descriptor[MAX_DESCRIPTOR_SIZE];
const int size = hid_get_report_descriptor(m_device, descriptor, sizeof descriptor);
if (size > 0) {
layout = SpaceMouseHid::parseDescriptor(
QByteArray(reinterpret_cast<const char *>(descriptor), size));
}
#endif
if (!layout.hasAxes()) {
layout = SpaceMouseHid::fallbackLayout();
}
m_decoder = SpaceMouseHid::Decoder(layout);
m_idle_polls = 0;
m_read_timer->start(FAST_POLL_MS);
}
/**
@brief HidBackend::readReports
Drain every report waiting, emit what they mean, and adjust the polling
rate. A read error means the device went away (unplugged, or the
wireless receiver lost it): go back to looking for one.
*/
void HidBackend::readReports()
{
//A button can trigger an action that opens a dialog, whose event
//loop fires this timer again before the signal returns. That is
//fine -- the device keeps working in the dialog -- because the
//signals are emitted last, after every use of m_device below; this
//guard only keeps two reads from ever overlapping.
if (m_reading || !m_device) {
return;
}
m_reading = true;
unsigned char buffer[MAX_REPORT_SIZE];
bool got_any = false;
//Everything read in one poll is one moment: a device that sends
//translation and rotation as two reports, or two samples that
//queued up, gives one sample here -- the latest state -- so the
//listener's time scaling sees one sample per real interval.
bool moved = false;
SpaceMouseSample latest;
QList<int> pressed;
for (;;)
{
const int size = hid_read(m_device, buffer, sizeof buffer);
if (size == 0) {
break;
}
if (size < 0) {
close();
m_scan_timer->start();
break;
}
got_any = true;
const SpaceMouseHid::Decoder::Result result = m_decoder.feed(
QByteArray(reinterpret_cast<const char *>(buffer), size));
if (result.motion) {
moved = true;
latest = result.sample;
}
pressed += result.pressed;
}
if (m_device) {
if (got_any) {
m_idle_polls = 0;
if (m_read_timer->interval() != FAST_POLL_MS) {
m_read_timer->setInterval(FAST_POLL_MS);
}
} else if (++m_idle_polls == IDLE_POLLS_BEFORE_SLOW) {
m_read_timer->setInterval(SLOW_POLL_MS);
}
}
//Signals last: whatever they trigger (even the device going away
//during a dialog), nothing after them touches the device.
m_reading = false;
if (moved) {
emit motion(latest);
}
for (int button : pressed) {
emit buttonPressed(button);
}
}
/**
@brief HidBackend::close
*/
void HidBackend::close()
{
if (m_read_timer) {
m_read_timer->stop();
}
if (m_device) {
hid_close(m_device);
m_device = nullptr;
}
}
+69
View File
@@ -0,0 +1,69 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef HIDBACKEND_H
#define HIDBACKEND_H
#include "spacemousebackend.h"
#include "spacemousehid.h"
class QTimer;
struct hid_device_;
/**
@brief The HidBackend class
SpaceMouseBackend that reads the device directly over USB through
hidapi, with no 3Dconnexion driver or SDK: the backend for Windows and
macOS, and for Linux without spacenavd. Only compiled in when
QET_SPACEMOUSE_BACKEND_HID is defined (cmake/find_spacemouse.cmake).
The raw reports are decoded by SpaceMouseHid, from the device's own
report descriptor, into the same values spacenavd would give.
hidapi has no event to wait on, so the device is polled from the main
thread: fast while it is moving, slowly once it has been still for a
moment. With no device, it looks for one every few seconds, so plugging
one in works without restarting QElectroTech. As with every backend,
no device is the normal case and is never reported as an error.
*/
class HidBackend : public SpaceMouseBackend
{
Q_OBJECT
public:
explicit HidBackend(QObject *parent = nullptr);
~HidBackend() override;
bool isAvailable() const override { return m_device != nullptr; }
private slots:
void scan();
void readReports();
private:
void close();
bool m_hid_initialised = false;
hid_device_ *m_device = nullptr;
SpaceMouseHid::Decoder m_decoder;
QTimer *m_scan_timer = nullptr;
QTimer *m_read_timer = nullptr;
int m_idle_polls = 0;
bool m_reading = false;
};
#endif // HIDBACKEND_H
+15 -22
View File
@@ -18,6 +18,8 @@
#ifndef SPACEMOUSEBACKEND_H
#define SPACEMOUSEBACKEND_H
#include "spacemousemotion.h"
#include <QObject>
/**
@@ -25,23 +27,16 @@
Platform seam for discussion #599's 3D mouse support. A backend owns
one platform's connection to the actual 6-DOF device driver -- opening
it, pumping whatever event source that platform uses, closing it -- and
reports motion through the one signal below. Everything platform-
independent (which DiagramView to apply motion to, the pan/zoom
primitives to call, the Z-to-zoom-factor mapping) lives in
SpaceMouseListener instead, once, so it does not have to be duplicated
reports motion through the signals below. Everything platform-
independent (which view to apply motion to, the pan/zoom primitives to
call, the sample-to-motion mapping) lives in SpaceMouseListener and
SpaceMouseMotion instead, once, so it does not have to be duplicated
or re-verified per backend.
SpnavBackend (Linux, spacenavd/libspnav) is the only implementation so
far -- built, linked, and its "no daemon/device present" path actually
run in the environment this was written in. A Windows/macOS backend
(3Dconnexion's proprietary 3DxWare SDK) would implement this same
interface and be selected in SpaceMouseListener's constructor, without
changing SpnavBackend or anything downstream of the motion signal.
Deliberately not attempted here: this was written on Linux with no
3DxWare SDK and no Windows/macOS toolchain available to compile,
link, or run a single line of it against, and shipping platform code
that has never even built would be a materially different, weaker
thing than everything else in this feature.
Two implementations, chosen at build time (cmake/find_spacemouse.cmake):
SpnavBackend (Linux, through spacenavd/libspnav) and HidBackend (any
platform, directly over USB through hidapi, with no 3Dconnexion driver
or SDK). Both report the same values for the same movement.
*/
class SpaceMouseBackend : public QObject
{
@@ -59,13 +54,11 @@ class SpaceMouseBackend : public QObject
virtual bool isAvailable() const = 0;
signals:
/// One raw device sample. dx/dy are the two axes
/// SpaceMouseListener maps to horizontal/vertical pan, dz the
/// one it maps to zoom. Units and range are whatever the
/// backend's own driver reports -- SpaceMouseListener's own
/// scale/divisor constants are what turn them into pixels and a
/// zoom factor, not this signal.
void motion(int dx, int dy, int dz);
/// One raw device sample, all six axes. Units and range are
/// whatever the backend's own driver reports --
/// SpaceMouseMotion::map() and the user's settings are what
/// turn them into pixels and a zoom factor, not this signal.
void motion(const SpaceMouseSample &sample);
/// One device button was pressed. \a button is whatever index
/// the backend's own driver numbers it as -- there is no
+357
View File
@@ -0,0 +1,357 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "spacemousehid.h"
#include <QHash>
#include <algorithm>
namespace {
constexpr unsigned short PAGE_GENERIC_DESKTOP = 0x01;
constexpr unsigned short PAGE_BUTTON = 0x09;
constexpr unsigned short USAGE_MULTI_AXIS = 0x08;
constexpr unsigned short USAGE_X = 0x30; // X, Y, Z, Rx, Ry, Rz follow
//Newer devices (SpaceMouse Enterprise, the wireless ones) report the
//buttons held as a list of 16-bit button numbers in this report,
//outside the Button usage page.
constexpr int REPORT_BUTTON_LIST = 0x1c;
//spacenavd rescales absolute axes to this range, so doing the same
//makes both backends hand QET the same numbers.
constexpr int SCALED_MIN = -500;
constexpr int SCALED_MAX = 500;
//Limits on what a descriptor can make the parser walk.
constexpr quint32 MAX_FIELDS = 1024;
constexpr qint64 MAX_BITS = 1 << 20;
//3D mice sold under Logitech's vendor id, for the ones whose
//interface does not report a usage.
const QSet<unsigned short> LOGITECH_3D_MICE = {
0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625,
0xc626, 0xc627, 0xc628, 0xc629, 0xc62b
};
/// Read \a size bits at \a offset, little-endian as HID packs them.
bool extract(const QByteArray &payload, int offset, int size, bool is_signed, int *value)
{
if (size <= 0 || size > 32 || offset < 0
|| (offset + size + 7) / 8 > payload.size()) {
return false;
}
quint32 raw = 0;
for (int b = 0; b < size; ++b) {
const int bit = offset + b;
if (static_cast<quint8>(payload.at(bit / 8)) & (1u << (bit % 8))) {
raw |= 1u << b;
}
}
if (is_signed && size < 32 && (raw & (1u << (size - 1)))) {
raw |= ~((1u << size) - 1);
}
*value = static_cast<qint32>(raw);
return true;
}
int scaled(const SpaceMouseHid::Field &field, int value)
{
if (field.relative || field.logical_max <= field.logical_min) {
return value;
}
const qint64 range = qint64(field.logical_max) - field.logical_min;
return static_cast<int>((qint64(value) - field.logical_min)
* (SCALED_MAX - SCALED_MIN) / range + SCALED_MIN);
}
}
bool SpaceMouseHid::isSpaceMouse(unsigned short vendor, unsigned short product,
unsigned short usage_page, unsigned short usage)
{
const bool multi_axis = usage_page == PAGE_GENERIC_DESKTOP && usage == USAGE_MULTI_AXIS;
const bool usage_unknown = usage_page == 0 && usage == 0;
if (vendor == VENDOR_3DCONNEXION) {
return multi_axis || usage_unknown;
}
if (vendor == VENDOR_LOGITECH) {
return multi_axis || (usage_unknown && LOGITECH_3D_MICE.contains(product));
}
return false;
}
bool SpaceMouseHid::Layout::hasAxes() const
{
for (const Field &f : axes) {
if (f.isValid()) {
return true;
}
}
return false;
}
/**
@brief SpaceMouseHid::parseDescriptor
Walks the short items of a HID report descriptor (HID 1.11, 6.2.2),
keeping only what the decoder needs: the Input fields carrying the six
Generic Desktop axes and the Button page.
@param descriptor
@return see the declaration
*/
SpaceMouseHid::Layout SpaceMouseHid::parseDescriptor(const QByteArray &descriptor)
{
struct Globals {
quint32 usage_page = 0;
qint32 logical_min = 0;
qint32 logical_max = 0;
quint32 report_size = 0;
quint32 report_count = 0;
quint32 report_id = 0;
};
Layout layout;
Globals globals;
QList<Globals> stack;
QList<quint32> usages; // full 32-bit usages (page << 16 | id)
quint32 usage_min = 0, usage_max = 0;
bool have_range = false;
QHash<quint32, int> next_bit; // per report id
auto clearLocals = [&]() {
usages.clear();
have_range = false;
usage_min = usage_max = 0;
};
auto fullUsage = [&](quint32 value, int size) {
return size == 4 ? value : (globals.usage_page << 16) | value;
};
int i = 0;
const int n = descriptor.size();
while (i < n)
{
const quint8 prefix = static_cast<quint8>(descriptor.at(i));
if (prefix == 0xfe) { // long item: never used by these devices
if (i + 1 >= n) break;
i += 3 + static_cast<quint8>(descriptor.at(i + 1));
continue;
}
const int size = (prefix & 0x03) == 3 ? 4 : (prefix & 0x03);
const int type = (prefix >> 2) & 0x03;
const int tag = prefix >> 4;
if (i + 1 + size > n) break;
quint32 uvalue = 0;
for (int b = 0; b < size; ++b) {
uvalue |= quint32(static_cast<quint8>(descriptor.at(i + 1 + b))) << (8 * b);
}
qint32 svalue = static_cast<qint32>(uvalue);
if (size > 0 && size < 4 && (uvalue & (1u << (8 * size - 1)))) {
svalue = static_cast<qint32>(uvalue | ~((1u << (8 * size)) - 1));
}
i += 1 + size;
if (type == 1) // global
{
switch (tag) {
case 0: globals.usage_page = uvalue; break;
case 1: globals.logical_min = svalue; break;
case 2: globals.logical_max = svalue; break;
case 7: globals.report_size = uvalue; break;
case 8: globals.report_id = uvalue; layout.numbered_reports = true; break;
case 9: globals.report_count = uvalue; break;
case 10: stack.append(globals); break;
case 11: if (!stack.isEmpty()) globals = stack.takeLast(); break;
}
}
else if (type == 2) // local
{
switch (tag) {
case 0: usages.append(fullUsage(uvalue, size)); break;
case 1: usage_min = fullUsage(uvalue, size); have_range = true; break;
case 2: usage_max = fullUsage(uvalue, size); have_range = true; break;
}
}
else if (type == 0) // main
{
if (tag == 8) // Input
{
const bool constant = uvalue & 0x01;
const bool variable = uvalue & 0x02;
const bool relative = uvalue & 0x04;
int &bit = next_bit[globals.report_id];
//A real report is at most a few hundred bytes; a count
//beyond that is a broken (or hostile) descriptor, and
//walking it field by field would stall the application.
const quint32 count = qMin<quint32>(globals.report_count, MAX_FIELDS);
for (quint32 k = 0; k < count; ++k)
{
quint32 usage = 0;
if (have_range && usage_max >= usage_min) {
usage = qMin(usage_min + k, usage_max);
} else if (!usages.isEmpty()) {
usage = usages.at(qMin<int>(k, usages.size() - 1));
}
Field field;
field.report_id = static_cast<int>(globals.report_id);
field.bit_offset = bit + static_cast<int>(k * globals.report_size);
field.bit_size = static_cast<int>(globals.report_size);
field.logical_min = globals.logical_min;
field.logical_max = globals.logical_max;
field.relative = relative;
if (!constant && variable) {
const quint32 page = usage >> 16, id = usage & 0xffff;
if (page == PAGE_GENERIC_DESKTOP && id >= USAGE_X && id < USAGE_X + 6) {
layout.axes[id - USAGE_X] = field;
} else if (page == PAGE_BUTTON && id >= 1 && id <= 64) {
if (layout.buttons.size() < int(id)) {
layout.buttons.resize(id);
}
layout.buttons[id - 1] = field;
}
}
}
bit = static_cast<int>(qMin<qint64>(
bit + qint64(count) * globals.report_size, MAX_BITS));
}
clearLocals(); // every main item (Input, Collection, ...) ends the locals
}
}
return layout;
}
SpaceMouseHid::Layout SpaceMouseHid::fallbackLayout()
{
Layout layout;
layout.numbered_reports = true;
for (int a = 0; a < 6; ++a) {
Field &f = layout.axes[a];
f.report_id = a < 3 ? 1 : 2;
f.bit_offset = (a % 3) * 16;
f.bit_size = 16;
f.logical_min = -32768; // signed, and relative: passed through as sent
f.logical_max = 32767;
f.relative = true;
}
layout.buttons.resize(32);
for (int b = 0; b < 32; ++b) {
Field &f = layout.buttons[b];
f.report_id = 3;
f.bit_offset = b;
f.bit_size = 1;
f.logical_max = 1;
}
return layout;
}
SpaceMouseHid::Decoder::Decoder(const Layout &layout) :
m_layout(layout)
{}
/**
@brief SpaceMouseHid::Decoder::feed
@param report : one report as read from the device, report id first
when the device numbers its reports
@return see Result
*/
SpaceMouseHid::Decoder::Result SpaceMouseHid::Decoder::feed(const QByteArray &report)
{
Result result;
if (report.isEmpty()) {
return result;
}
const int id = m_layout.numbered_reports ? static_cast<quint8>(report.at(0)) : 0;
const QByteArray payload = m_layout.numbered_reports ? report.mid(1) : report;
for (int a = 0; a < 6; ++a)
{
const Field &f = m_layout.axes[a];
int value = 0;
if (f.isValid() && f.report_id == id
&& extract(payload, f.bit_offset, f.bit_size, f.logical_min < 0, &value)) {
m_axes[a] = scaled(f, value);
result.motion = true;
}
}
//Some devices send rotation after translation in one longer report
//1, while their layout (or the fallback, which has no descriptor)
//puts rotation in a report of its own: read it from report 1 too.
if (id == 1 && payload.size() >= 12
&& m_layout.axes[3].isValid() && m_layout.axes[3].report_id != 1) {
for (int a = 3; a < 6; ++a) {
const Field &f = m_layout.axes[a];
int value = 0;
if (extract(payload, 48 + f.bit_offset, f.bit_size, f.logical_min < 0, &value)) {
m_axes[a] = scaled(f, value);
}
}
}
QSet<int> down;
bool buttons_in_report = false;
for (int b = 0; b < m_layout.buttons.size(); ++b)
{
const Field &f = m_layout.buttons.at(b);
int value = 0;
if (f.isValid() && f.report_id == id
&& extract(payload, f.bit_offset, f.bit_size, false, &value)) {
buttons_in_report = true;
if (value) {
down.insert(b);
}
}
}
if (!buttons_in_report && m_layout.numbered_reports && id == REPORT_BUTTON_LIST)
{
buttons_in_report = true;
for (int offset = 0; offset + 1 < payload.size(); offset += 2) {
const int number = static_cast<quint8>(payload.at(offset))
| static_cast<quint8>(payload.at(offset + 1)) << 8;
//0 means no button, so the numbers start at 1: count from
//0 like the Button page and spacenavd do.
if (number) {
down.insert(number - 1);
}
}
}
if (buttons_in_report)
{
for (int b : down) {
if (!m_down.contains(b)) {
result.pressed.append(b);
}
}
std::sort(result.pressed.begin(), result.pressed.end());
m_down = down;
}
if (result.motion) {
result.sample.x = m_axes[0];
result.sample.y = m_axes[1];
result.sample.z = m_axes[2];
result.sample.rx = m_axes[3];
result.sample.ry = m_axes[4];
result.sample.rz = m_axes[5];
}
return result;
}
+107
View File
@@ -0,0 +1,107 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef SPACEMOUSEHID_H
#define SPACEMOUSEHID_H
#include "spacemousemotion.h"
#include <QByteArray>
#include <QList>
#include <QSet>
#include <QVector>
/**
Decoding of a 3D mouse's raw USB HID reports, for the backends that read
the device directly (HidBackend) rather than through a driver that has
already decoded them (spacenavd). No I/O here, so it is unit-tested from
recorded bytes (tests/qttest/tst_spacemousehid).
*/
namespace SpaceMouseHid
{
/// USB vendor ids 3Dconnexion devices use: Logitech's for the older
/// ones, 3Dconnexion's own for everything since about 2011.
constexpr unsigned short VENDOR_LOGITECH = 0x046d;
constexpr unsigned short VENDOR_3DCONNEXION = 0x256f;
/// @return whether a HID interface is a 3D mouse: a 3Dconnexion
/// vendor id and the Generic Desktop "multi-axis controller" usage.
/// Logitech devices that do not report a usage are matched by
/// product id instead, from the list of their 3D mice.
bool isSpaceMouse(unsigned short vendor, unsigned short product,
unsigned short usage_page, unsigned short usage);
/// Where one value sits in the reports, per the report descriptor.
struct Field
{
int report_id = 0; ///< 0 when the device does not number its reports
int bit_offset = 0; ///< after the report id byte, if any
int bit_size = 0;
int logical_min = 0;
int logical_max = 0;
bool relative = false;
bool isValid() const { return bit_size > 0; }
};
/// What the report descriptor says about the six axes and the buttons.
struct Layout
{
Field axes[6]; ///< x, y, z, rx, ry, rz
QVector<Field> buttons; ///< button n (0-based) is buttons[n]
bool numbered_reports = false;
bool hasAxes() const;
};
/// @return the layout described by \a descriptor, or one where
/// hasAxes() is false if it describes no axes (or is empty)
Layout parseDescriptor(const QByteArray &descriptor);
/// @return the fixed layout of the classic 3Dconnexion reports
/// (1 = translation, 2 = rotation, 3 = button bitmask), used when
/// the descriptor cannot be read
Layout fallbackLayout();
/**
@brief The Decoder class
Turns a stream of raw reports into samples and button presses.
A device can send translation and rotation in separate reports, so
the decoder keeps the last value of every axis and reports all six
each time any of them changes.
*/
class Decoder
{
public:
struct Result
{
bool motion = false; ///< sample is new
SpaceMouseSample sample;
QList<int> pressed; ///< buttons pressed since the previous report
};
explicit Decoder(const Layout &layout = fallbackLayout());
Result feed(const QByteArray &report);
private:
Layout m_layout;
int m_axes[6] = {0, 0, 0, 0, 0, 0};
QSet<int> m_down; ///< buttons currently held
};
}
#endif // SPACEMOUSEHID_H
+93 -64
View File
@@ -22,8 +22,13 @@
#ifdef QET_SPACEMOUSE_BACKEND_SPNAV
# include "spnavbackend.h"
#endif
#ifdef QET_SPACEMOUSE_BACKEND_HID
# include "hidbackend.h"
#endif
#include "../diagramview.h"
#include "../editor/elementview.h"
#include "../editor/ui/qetelementeditor.h"
#include "../projectview.h"
#include "../qetdiagrameditor.h"
#include "../shortcutmanager.h"
@@ -31,24 +36,6 @@
#include <QApplication>
#include <QScrollBar>
namespace {
//A device motion delta is an integer on roughly the same order of
//magnitude as a QWheelEvent::angleDelta() tick (about +-120 per
//detent, more under a hard push/twist -- exact range depends on the
//backend and the user's own driver-level sensitivity setting, which
//is configured outside QET and out of scope here).
//DiagramView::wheelEvent() already turns such a tick into a small
//per-event zoom step via zoom(1 + value/1000); reused as a starting
//point.
//
//Neither this divisor nor PAN_SCALE below has been calibrated
//against real hardware -- there is none in the environment this was
//built in. Both are named constants specifically so that is a
//one-line fix once someone with a device tries it.
constexpr qreal ZOOM_DIVISOR = 1000.0;
constexpr qreal PAN_SCALE = 1.0;
}
/**
@brief SpaceMouseListener::SpaceMouseListener
Construct whichever backend is available for this platform and connect
@@ -58,14 +45,14 @@ namespace {
@param parent
*/
SpaceMouseListener::SpaceMouseListener(QObject *parent) :
QObject(parent)
QObject(parent),
m_settings(SpaceMouseSettings::load())
{
#ifdef QET_SPACEMOUSE_BACKEND_SPNAV
#if defined(QET_SPACEMOUSE_BACKEND_SPNAV)
m_backend = new SpnavBackend(this);
#elif defined(QET_SPACEMOUSE_BACKEND_HID)
m_backend = new HidBackend(this);
#endif
//A future Windows/macOS backend (3Dconnexion's proprietary 3DxWare
//SDK) slots in here behind its own QET_SPACEMOUSE_BACKEND_3DXWARE
//guard, without changing anything below this constructor.
if (m_backend) {
connect(m_backend, &SpaceMouseBackend::motion,
@@ -85,59 +72,101 @@ bool SpaceMouseListener::isAvailable() const
}
/**
@brief SpaceMouseListener::zoomFactorForZAxis
@param z : raw Z-axis (push/pull) delta from a device motion sample
@return the multiplicative factor DiagramView::zoom() expects
@brief SpaceMouseListener::reloadSettings
*/
qreal SpaceMouseListener::zoomFactorForZAxis(int z)
void SpaceMouseListener::reloadSettings()
{
return 1.0 + (static_cast<qreal>(z) / ZOOM_DIVISOR);
m_settings = SpaceMouseSettings::load();
}
/**
@brief SpaceMouseListener::applyMotion
Apply one motion sample to whichever DiagramView is currently active.
X/Y translation pans it, Z translation zooms it -- the same two
primitives (scrollbars, DiagramView::zoom()) DiagramView::wheelEvent()
already drives from a physical wheel, so there is no new navigation
logic here, only a new input source feeding the existing one.
Apply one motion sample to the view of the active window: the current
folio of a diagram editor, or the drawing of an element editor.
Translation pans it and push/pull (or twist, per the user's settings)
zooms it -- the same two primitives (scrollbars, zoom()) each view's
wheelEvent() already drives from a physical wheel, so there is no new
navigation logic here, only a new input source feeding the existing one.
@param sample
*/
void SpaceMouseListener::applyMotion(const SpaceMouseSample &sample)
{
const qint64 elapsed_ms = m_since_last_sample.isValid()
? m_since_last_sample.restart()
: -1;
if (!m_since_last_sample.isValid()) {
m_since_last_sample.start();
}
if (elapsed_ms < 0 || elapsed_ms > SpaceMouseMotion::MAX_PERIOD_MS) {
//the device was at rest: nothing left over to carry on with
m_scroll_remainder = QPointF();
}
Which of a device's three translation axes is "left/right" vs
"forward/back" vs "up/down", and their sign, is a hardware convention
this could not be checked against real hardware while writing it -- see
the PR description.
const SpaceMouseViewMotion motion =
SpaceMouseMotion::map(sample, elapsed_ms, m_settings);
const bool pans = motion.scroll_x != 0 || motion.scroll_y != 0;
const bool zooms = motion.zoom_factor != 1.0;
QWidget *window = qApp->activeWindow();
if (auto *editor = qobject_cast<QETDiagramEditor *>(window))
{
ProjectView *project_view = editor->currentProjectView();
if (!project_view) {
return;
}
DiagramView *view = project_view->currentDiagram();
if (!view) {
return;
}
if (pans) {
scrollView(view, motion.scroll_x, motion.scroll_y);
}
if (zooms) {
view->zoom(motion.zoom_factor);
}
}
else if (auto *element_editor = qobject_cast<QETElementEditor *>(window))
{
ElementView *view = element_editor->elementView();
if (!view) {
return;
}
if (pans)
{
//The element editor's scene rect only just covers what is
//on screen, so grow it before each sample, as its own
//middle-button pan does on release -- otherwise the
//scrollbars have no range and the pan does nothing.
view->adjustSceneRect();
scrollView(view, motion.scroll_x, motion.scroll_y);
}
if (zooms) {
view->zoom(motion.zoom_factor);
}
}
}
/**
@brief SpaceMouseListener::scrollView
Move a view's scrollbars by (\a dx, \a dy) pixels -- the same way both
editors' own middle-button drag pans them -- keeping the fractional part
for the next sample.
@param view
@param dx
@param dy
@param dz
*/
void SpaceMouseListener::applyMotion(int dx, int dy, int dz)
void SpaceMouseListener::scrollView(QGraphicsView *view, qreal dx, qreal dy)
{
auto *editor = qobject_cast<QETDiagramEditor *>(qApp->activeWindow());
if (!editor) {
return;
}
m_scroll_remainder += QPointF(dx, dy);
const int whole_x = qRound(m_scroll_remainder.x());
const int whole_y = qRound(m_scroll_remainder.y());
m_scroll_remainder -= QPointF(whole_x, whole_y);
ProjectView *project_view = editor->currentProjectView();
if (!project_view) {
return;
}
DiagramView *view = project_view->currentDiagram();
if (!view) {
return;
}
if (dx || dy)
{
view->horizontalScrollBar()->setValue(
view->horizontalScrollBar()->value() - qRound(dx * PAN_SCALE));
view->verticalScrollBar()->setValue(
view->verticalScrollBar()->value() - qRound(dy * PAN_SCALE));
}
if (dz) {
view->zoom(zoomFactorForZAxis(dz));
}
view->horizontalScrollBar()->setValue(view->horizontalScrollBar()->value() + whole_x);
view->verticalScrollBar()->setValue(view->verticalScrollBar()->value() + whole_y);
}
/**
+28 -14
View File
@@ -18,24 +18,30 @@
#ifndef SPACEMOUSELISTENER_H
#define SPACEMOUSELISTENER_H
#include <QObject>
#include "spacemousemotion.h"
#include <QElapsedTimer>
#include <QObject>
#include <QPointF>
class QGraphicsView;
class SpaceMouseBackend;
/**
@brief The SpaceMouseListener class
https://github.com/qelectrotech/qelectrotech-source-mirror/discussions/599 :
bridges a 3Dconnexion SpaceMouse/SpacePilot 6-DOF device to
DiagramView's existing pan/zoom primitives (the same
horizontalScrollBar()/verticalScrollBar()/zoom() calls
DiagramView::wheelEvent() already uses for a physical wheel), and its
bridges a 3Dconnexion SpaceMouse/SpacePilot 6-DOF device to the
existing pan/zoom primitives of DiagramView and ElementView (the same
horizontalScrollBar()/verticalScrollBar()/zoom() calls their
wheelEvent() already uses for a physical wheel), and its
buttons to named QET actions via ShortcutManager -- the same registry
keyboard shortcuts already use, so a device button can trigger anything
in that registry (undo, redo, rotate selection, ...) without QET having
a second, device-specific action list.
Everything here is platform-independent: which DiagramView to apply
motion to, the pan/zoom calls, the Z-to-zoom-factor mapping, and button
Everything here is platform-independent: which view to apply
motion to, the pan/zoom calls, the sample-to-motion mapping
(SpaceMouseMotion, tuned by the user's SpaceMouseSettings), and button
dispatch via SpaceMouseButtonMap + ShortcutManager. Talking to the
actual device driver is a SpaceMouseBackend's job (see its class
comment) -- this class owns one and applies whatever it reports,
@@ -65,16 +71,14 @@ class SpaceMouseListener : public QObject
/// when this is false.
bool isAvailable() const;
/// Pure translation from a device Z-axis delta to the
/// multiplicative factor DiagramView::zoom() expects. A free
/// function so the mapping can be unit-tested without a live
/// backend connection or a real device.
static qreal zoomFactorForZAxis(int z);
/// Re-read SpaceMouseSettings, after the configuration page
/// saved new ones.
void reloadSettings();
private slots:
/// Apply one motion sample -- from whichever backend is in use
/// -- to whichever DiagramView is currently active.
void applyMotion(int dx, int dy, int dz);
/// -- to the view of the active diagram or element editor.
void applyMotion(const SpaceMouseSample &sample);
/// Look up which action id, if any, SpaceMouseButtonMap binds
/// \a button to, and trigger it via ShortcutManager. Does
@@ -83,7 +87,17 @@ class SpaceMouseListener : public QObject
void applyButton(int button);
private:
void scrollView(QGraphicsView *view, qreal dx, qreal dy);
SpaceMouseBackend *m_backend = nullptr;
SpaceMouseSettings m_settings;
/// Time since the previous motion sample -- see
/// SpaceMouseMotion::stepFor().
QElapsedTimer m_since_last_sample;
/// Fractions of a pixel not yet scrolled. Scrollbars only take
/// whole pixels; without this a slow push that maps to under
/// half a pixel per sample would never move the view at all.
QPointF m_scroll_remainder;
};
#endif // SPACEMOUSELISTENER_H
+147
View File
@@ -0,0 +1,147 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "spacemousemotion.h"
#include <QSettings>
#include <QtMath>
namespace {
//A device deflection is an integer on roughly the same order of
//magnitude as a QWheelEvent::angleDelta() tick (about +-120 per
//detent, more under a hard push/twist -- exact range depends on the
//backend and the driver's own sensitivity). DiagramView::wheelEvent()
//turns such a tick into a zoom step of about 1 + value/1000, reused
//here as a starting point.
//
//Neither constant has been calibrated against real hardware; the
//user-facing speed settings scale both.
constexpr qreal ZOOM_DIVISOR = 1000.0;
constexpr qreal PAN_SCALE = 1.0;
const QString GROUP = QStringLiteral("spacemouse/motion/");
}
/**
@brief SpaceMouseSettings::load
@return the settings saved by the configuration page, or the defaults
*/
SpaceMouseSettings SpaceMouseSettings::load()
{
QSettings settings;
SpaceMouseSettings s;
s.pan_speed = settings.value(GROUP + "pan_speed", s.pan_speed).toInt();
s.zoom_speed = settings.value(GROUP + "zoom_speed", s.zoom_speed).toInt();
s.dead_zone = settings.value(GROUP + "dead_zone", s.dead_zone).toInt();
s.invert_pan_x = settings.value(GROUP + "invert_pan_x", s.invert_pan_x).toBool();
s.invert_pan_y = settings.value(GROUP + "invert_pan_y", s.invert_pan_y).toBool();
s.invert_zoom = settings.value(GROUP + "invert_zoom", s.invert_zoom).toBool();
s.zoom_axis = settings.value(GROUP + "zoom_axis").toString() == QLatin1String("twist")
? ZoomAxis::Twist
: ZoomAxis::PushPull;
return s;
}
/**
@brief SpaceMouseSettings::save
*/
void SpaceMouseSettings::save() const
{
QSettings settings;
settings.setValue(GROUP + "pan_speed", pan_speed);
settings.setValue(GROUP + "zoom_speed", zoom_speed);
settings.setValue(GROUP + "dead_zone", dead_zone);
settings.setValue(GROUP + "invert_pan_x", invert_pan_x);
settings.setValue(GROUP + "invert_pan_y", invert_pan_y);
settings.setValue(GROUP + "invert_zoom", invert_zoom);
settings.setValue(GROUP + "zoom_axis",
zoom_axis == ZoomAxis::Twist ? QStringLiteral("twist")
: QStringLiteral("push_pull"));
}
bool SpaceMouseSettings::operator==(const SpaceMouseSettings &other) const
{
return pan_speed == other.pan_speed
&& zoom_speed == other.zoom_speed
&& dead_zone == other.dead_zone
&& invert_pan_x == other.invert_pan_x
&& invert_pan_y == other.invert_pan_y
&& invert_zoom == other.invert_zoom
&& zoom_axis == other.zoom_axis;
}
/**
@brief SpaceMouseMotion::stepFor
@param elapsed_ms
@return see the declaration
*/
qreal SpaceMouseMotion::stepFor(qint64 elapsed_ms)
{
if (elapsed_ms < 0 || elapsed_ms > MAX_PERIOD_MS) {
return 1.0;
}
return elapsed_ms / NOMINAL_PERIOD_MS;
}
/**
@brief SpaceMouseMotion::applyDeadZone
@param value
@param dead_zone
@return see the declaration
*/
int SpaceMouseMotion::applyDeadZone(int value, int dead_zone)
{
if (dead_zone <= 0) {
return value;
}
if (qAbs(value) <= dead_zone) {
return 0;
}
return value > 0 ? value - dead_zone : value + dead_zone;
}
/**
@brief SpaceMouseMotion::map
Pan is a scrollbar delta: pushing the cap right moves the drawing right,
so the scrollbar goes the other way, as in DiagramView::wheelEvent().
Zoom is exponential in the deflection, so the factor is always positive
and an equal push and pull cancel out exactly.
@param sample
@param elapsed_ms
@param settings
@return see the declaration
*/
SpaceMouseViewMotion SpaceMouseMotion::map(const SpaceMouseSample &sample,
qint64 elapsed_ms,
const SpaceMouseSettings &settings)
{
const qreal step = stepFor(elapsed_ms);
const int x = applyDeadZone(sample.x, settings.dead_zone);
const int y = applyDeadZone(sample.y, settings.dead_zone);
const int zoom_value = applyDeadZone(
settings.zoom_axis == SpaceMouseSettings::ZoomAxis::Twist ? sample.rz : sample.z,
settings.dead_zone);
const qreal pan = PAN_SCALE * settings.pan_speed / 100.0 * step;
const qreal zoom = settings.zoom_speed / 100.0 / ZOOM_DIVISOR * step;
SpaceMouseViewMotion motion;
motion.scroll_x = -x * pan * (settings.invert_pan_x ? -1 : 1);
motion.scroll_y = -y * pan * (settings.invert_pan_y ? -1 : 1);
motion.zoom_factor = qExp(zoom_value * zoom * (settings.invert_zoom ? -1 : 1));
return motion;
}
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef SPACEMOUSEMOTION_H
#define SPACEMOUSEMOTION_H
#include <QtGlobal>
/**
@brief The SpaceMouseSample struct
One raw motion sample from a SpaceMouseBackend: the device's current
deflection on its six axes, in whatever units the backend's driver
reports. With spacenavd's default configuration x is left/right, y is
forward/back, z is push/pull, and rz is the twist of the cap.
*/
struct SpaceMouseSample
{
int x = 0, y = 0, z = 0;
int rx = 0, ry = 0, rz = 0;
};
/**
@brief The SpaceMouseSettings struct
The user's 3D mouse motion preferences (Configuration > Souris 3D).
The defaults reproduce the behaviour before these settings existed.
*/
struct SpaceMouseSettings
{
enum class ZoomAxis { PushPull, Twist };
int pan_speed = 100; ///< percent
int zoom_speed = 100; ///< percent
int dead_zone = 0; ///< raw device units, applied to every axis used
bool invert_pan_x = false;
bool invert_pan_y = false;
bool invert_zoom = false;
ZoomAxis zoom_axis = ZoomAxis::PushPull;
static SpaceMouseSettings load();
void save() const;
bool operator==(const SpaceMouseSettings &other) const;
};
/**
@brief The SpaceMouseViewMotion struct
What one sample does to a view: a scrollbar delta in pixels, and a
multiplicative zoom factor (1.0 = no zoom).
*/
struct SpaceMouseViewMotion
{
qreal scroll_x = 0;
qreal scroll_y = 0;
qreal zoom_factor = 1.0;
};
/**
Pure translation from device samples to view motion, kept free of any
widget or device so it can be unit-tested (tests/qttest/tst_spacemousemotion).
*/
namespace SpaceMouseMotion
{
/// Sample period the speeds are calibrated for: about what
/// spacenavd delivers. A device sampled twice as often moves the
/// view by half as much per sample, so the speed on screen does not
/// depend on the event rate of the platform or driver.
constexpr qreal NOMINAL_PERIOD_MS = 16.0;
/// A gap longer than this means the device was at rest before this
/// sample, not that one sample covers the whole gap.
constexpr qint64 MAX_PERIOD_MS = 100;
/// @return how many nominal periods a sample covers, given the
/// milliseconds since the previous one: none for a sample in the
/// same millisecond (it covers no time), one for the first sample
/// or the first after a rest (elapsed negative or over the maximum)
qreal stepFor(qint64 elapsed_ms);
/// @return \a value with \a dead_zone removed: 0 inside it, and
/// shifted towards 0 outside it so motion starts smoothly at the edge
int applyDeadZone(int value, int dead_zone);
/// @return what \a sample does to the view, \a elapsed_ms after the
/// previous sample, under \a settings
SpaceMouseViewMotion map(const SpaceMouseSample &sample,
qint64 elapsed_ms,
const SpaceMouseSettings &settings);
}
#endif // SPACEMOUSEMOTION_H
+8 -4
View File
@@ -65,10 +65,14 @@ void SpnavBackend::readEvents()
while (spnav_poll_event(&event))
{
if (event.type == SPNAV_EVENT_MOTION) {
//A 6-DOF device also reports rotation (rx, ry, rz); QET's
//view has nothing rotation maps to, so those three axes are
//read by nothing here.
emit motion(event.motion.x, event.motion.y, event.motion.z);
SpaceMouseSample sample;
sample.x = event.motion.x;
sample.y = event.motion.y;
sample.z = event.motion.z;
sample.rx = event.motion.rx;
sample.ry = event.motion.ry;
sample.rz = event.motion.rz;
emit motion(sample);
}
else if (event.type == SPNAV_EVENT_BUTTON && event.button.press) {
//Release (press == 0) is not reported -- SpaceMouseListener
+84 -5
View File
@@ -20,9 +20,13 @@
#include "../../qeticons.h"
#include "../../shortcutmanager.h"
#include "../../spacemouse/spacemousebuttonmap.h"
#include "../../spacemouse/spacemousemotion.h"
#include <QCheckBox>
#include <QComboBox>
#include <QFormLayout>
#include <QFrame>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
@@ -61,6 +65,8 @@ SpaceMouseConfigPage::SpaceMouseConfigPage(QWidget *parent) :
horiz_line->setFrameShape(QFrame::HLine);
vlayout->addWidget(horiz_line);
vlayout->addWidget(buildMotionGroup());
auto *intro_label = new QLabel(
tr("Associez un numéro de bouton de votre souris 3D (SpaceMouse, SpacePilot...) "
"à une action de QElectroTech. Le numéro de bouton dépend de votre appareil "
@@ -68,7 +74,9 @@ SpaceMouseConfigPage::SpaceMouseConfigPage(QWidget *parent) :
"les valeurs à partir de 0.",
"spacemouse config page intro"));
intro_label->setWordWrap(true);
vlayout->addWidget(intro_label);
auto *buttons_layout = new QVBoxLayout();
buttons_layout->addWidget(intro_label);
m_table = new QTableWidget(0, 3, this);
m_table->setHorizontalHeaderLabels({tr("N° bouton"), tr("Action"), QString()});
@@ -78,7 +86,7 @@ SpaceMouseConfigPage::SpaceMouseConfigPage(QWidget *parent) :
m_table->verticalHeader()->setVisible(false);
m_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_table->setSelectionMode(QAbstractItemView::NoSelection);
vlayout->addWidget(m_table);
buttons_layout->addWidget(m_table);
auto *add_button = new QPushButton(QET::Icons::Add, tr("Ajouter une association"), this);
connect(add_button, &QPushButton::clicked, this, &SpaceMouseConfigPage::addRow);
@@ -86,7 +94,11 @@ SpaceMouseConfigPage::SpaceMouseConfigPage(QWidget *parent) :
auto *bottom_layout = new QHBoxLayout();
bottom_layout->addWidget(add_button);
bottom_layout->addStretch();
vlayout->addLayout(bottom_layout);
buttons_layout->addLayout(bottom_layout);
auto *buttons_group = new QGroupBox(tr("Boutons"), this);
buttons_group->setLayout(buttons_layout);
vlayout->addWidget(buttons_group);
setLayout(vlayout);
@@ -97,6 +109,61 @@ SpaceMouseConfigPage::~SpaceMouseConfigPage()
{
}
/**
@brief SpaceMouseConfigPage::buildMotionGroup
@return the "motion" part of the page, filled from the saved
SpaceMouseSettings
*/
QGroupBox *SpaceMouseConfigPage::buildMotionGroup()
{
const SpaceMouseSettings settings = SpaceMouseSettings::load();
auto speed_box = [this](int value) {
auto *spin = new QSpinBox(this);
spin->setRange(10, 400);
spin->setSingleStep(10);
spin->setSuffix(QStringLiteral(" %"));
spin->setValue(value);
return spin;
};
m_pan_speed = speed_box(settings.pan_speed);
m_zoom_speed = speed_box(settings.zoom_speed);
m_dead_zone = new QSpinBox(this);
m_dead_zone->setRange(0, 200);
m_dead_zone->setValue(settings.dead_zone);
m_dead_zone->setToolTip(tr("Les petits mouvements en dessous de ce seuil sont ignorés. "
"Augmentez-le si la vue dérive quand vous ne touchez pas la souris."));
m_zoom_axis = new QComboBox(this);
m_zoom_axis->addItem(tr("Pousser / tirer le capuchon"),
static_cast<int>(SpaceMouseSettings::ZoomAxis::PushPull));
m_zoom_axis->addItem(tr("Tourner le capuchon"),
static_cast<int>(SpaceMouseSettings::ZoomAxis::Twist));
m_zoom_axis->setCurrentIndex(
m_zoom_axis->findData(static_cast<int>(settings.zoom_axis)));
m_invert_pan_x = new QCheckBox(tr("Inverser le déplacement horizontal"), this);
m_invert_pan_x->setChecked(settings.invert_pan_x);
m_invert_pan_y = new QCheckBox(tr("Inverser le déplacement vertical"), this);
m_invert_pan_y->setChecked(settings.invert_pan_y);
m_invert_zoom = new QCheckBox(tr("Inverser le zoom"), this);
m_invert_zoom->setChecked(settings.invert_zoom);
auto *form = new QFormLayout();
form->addRow(tr("Vitesse de déplacement :"), m_pan_speed);
form->addRow(tr("Vitesse du zoom :"), m_zoom_speed);
form->addRow(tr("Zoomer en :"), m_zoom_axis);
form->addRow(tr("Zone morte :"), m_dead_zone);
form->addRow(m_invert_pan_x);
form->addRow(m_invert_pan_y);
form->addRow(m_invert_zoom);
auto *group = new QGroupBox(tr("Mouvement"), this);
group->setLayout(form);
return group;
}
/**
@brief SpaceMouseConfigPage::populateTable
Fill the table with one row per binding currently persisted in
@@ -187,8 +254,9 @@ void SpaceMouseConfigPage::removeSelectedRow()
/**
@brief SpaceMouseConfigPage::applyConf
Persist exactly what the table currently shows: every previously saved
binding is cleared first, then every row still in the table (after any
Save the motion settings, then persist exactly what the table
currently shows: every previously saved binding is cleared first,
then every row still in the table (after any
adds/edits/removals) is written back. Simpler and less error-prone than
tracking which individual rows changed, and cheap -- this runs once,
when the user validates the surrounding ConfigDialog, not on every
@@ -196,6 +264,17 @@ void SpaceMouseConfigPage::removeSelectedRow()
*/
void SpaceMouseConfigPage::applyConf()
{
SpaceMouseSettings settings;
settings.pan_speed = m_pan_speed->value();
settings.zoom_speed = m_zoom_speed->value();
settings.dead_zone = m_dead_zone->value();
settings.zoom_axis = static_cast<SpaceMouseSettings::ZoomAxis>(
m_zoom_axis->currentData().toInt());
settings.invert_pan_x = m_invert_pan_x->isChecked();
settings.invert_pan_y = m_invert_pan_y->isChecked();
settings.invert_zoom = m_invert_zoom->isChecked();
settings.save();
const QMap<int, QString> previous = SpaceMouseButtonMap::allBindings();
for (auto it = previous.constBegin(); it != previous.constEnd(); ++it) {
SpaceMouseButtonMap::setActionId(it.key(), QString());
+17 -2
View File
@@ -20,12 +20,19 @@
#include "configpage.h"
class QCheckBox;
class QComboBox;
class QGroupBox;
class QSpinBox;
class QTableWidget;
/**
@brief The SpaceMouseConfigPage class
Configuration page listing every device-button-to-action binding
(SpaceMouseButtonMap), letting the user add, edit and remove them.
Configuration page for the 3D mouse: how motion pans and zooms the view
(SpaceMouseSettings: speeds, dead zone, which axes are inverted, and
whether zoom follows push/pull or twist), then every
device-button-to-action binding (SpaceMouseButtonMap), letting the
user add, edit and remove them.
Modelled directly on ShortcutsConfigPage, one row per binding instead
of one row per shortcut, since the two are close cousins: both end in
the same place -- ShortcutManager::trigger()/registerAction() -- just
@@ -60,9 +67,17 @@ class SpaceMouseConfigPage : public ConfigPage
void removeSelectedRow();
private:
QGroupBox *buildMotionGroup();
void populateTable();
void appendRow(int button, const QString &action_id);
QSpinBox *m_pan_speed;
QSpinBox *m_zoom_speed;
QSpinBox *m_dead_zone;
QComboBox *m_zoom_axis;
QCheckBox *m_invert_pan_x;
QCheckBox *m_invert_pan_y;
QCheckBox *m_invert_zoom;
QTableWidget *m_table;
};
+22
View File
@@ -181,6 +181,28 @@ add_test(NAME tst_sheetbackgroundsetting COMMAND tst_sheetbackgroundsetting)
target_include_directories(tst_sheetbackgroundsetting PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_sheetbackgroundsetting PRIVATE Qt::Test Qt::Gui)
# SpaceMouseMotion::map() -- how a 3D mouse sample pans and zooms a view.
# Pure code with no device or widget behind it, so it is built and run
# whether or not QET_ENABLE_SPACEMOUSE is on.
add_executable(
tst_spacemousemotion
tst_spacemousemotion.cpp
${QET_DIR}/sources/spacemouse/spacemousemotion.cpp)
add_test(NAME tst_spacemousemotion COMMAND tst_spacemousemotion)
target_include_directories(tst_spacemousemotion PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_spacemousemotion PRIVATE Qt::Test Qt::Core)
# SpaceMouseHid -- decoding a 3D mouse's raw USB reports. Pure code, built
# whether or not QET_ENABLE_SPACEMOUSE is on; recordings from real devices
# go in fixtures/spacemouse/.
add_executable(
tst_spacemousehid
tst_spacemousehid.cpp
${QET_DIR}/sources/spacemouse/spacemousehid.cpp)
add_test(NAME tst_spacemousehid COMMAND tst_spacemousehid)
target_include_directories(tst_spacemousehid PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_spacemousehid PRIVATE Qt::Test Qt::Core)
# CrashHandler::formatInt() -- the async-signal-safe decimal formatter the
# signal handler uses for the "Signal: N" line of a crash dump. Compiles
# crashhandler.cpp and logring.cpp alongside; the handler deliberately
+353
View File
@@ -0,0 +1,353 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
/*
SpaceMouseHid -- decoding a 3D mouse's raw USB reports, for the backend
that reads the device directly (Windows, macOS, and Linux without
spacenavd).
The descriptors below are written by hand from the HID specification,
in the shape 3Dconnexion devices use. Recordings from real devices,
made with misc/spacemouse-capture.py, go in fixtures/spacemouse/ and are
checked by recordedDevices(): each step says what the user did, so the
decoded motion must point the right way.
*/
#include "spacemouse/spacemousehid.h"
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtTest>
using namespace SpaceMouseHid;
namespace {
QByteArray hex(const char *text) { return QByteArray::fromHex(text); }
// Report 1: X, Y, Z; report 2: Rx, Ry, Rz; each 16-bit, -350..350,
// Input(Data,Var,<rel_or_abs>). Report 3: two buttons and padding.
QByteArray classicDescriptor(bool relative)
{
const char *input = relative ? "8106" : "8102";
return hex(QByteArray("05010908a101"
"a100" "8501" "16a2fe" "265e01" "093009310932" "7510" "9503") + input + "c0"
"a100" "8502" "093309340935" "7510" "9503" + input + "c0"
"a102" "8503" "0509" "1901" "2902" "1500" "2501" "7501" "9502" "8102"
"950e" "8103" "c0"
"c0");
}
QByteArray report(int id, std::initializer_list<qint16> values)
{
QByteArray r(1, char(id));
for (qint16 v : values) {
r.append(char(v & 0xff));
r.append(char((v >> 8) & 0xff));
}
return r;
}
}
class TstSpaceMouseHid : public QObject
{
Q_OBJECT
private slots:
void recognisesOnly3DMice_data();
void recognisesOnly3DMice();
void parsesTheClassicDescriptor();
void parsesADescriptorWithoutReportIds();
void survivesBrokenDescriptors_data();
void survivesBrokenDescriptors();
void separateTranslationAndRotationReports();
void combinedReportOnTheFallbackLayout();
void absoluteAxesAreScaledLikeSpacenavd();
void relativeAxesArePassedThrough();
void buttonBitmaskReportsEachPressOnce();
void buttonListReport();
void ignoresShortAndUnknownReports();
void recordedDevices_data();
void recordedDevices();
};
void TstSpaceMouseHid::recognisesOnly3DMice_data()
{
QTest::addColumn<int>("vendor");
QTest::addColumn<int>("product");
QTest::addColumn<int>("page");
QTest::addColumn<int>("usage");
QTest::addColumn<bool>("match");
QTest::newRow("3Dconnexion, multi-axis") << 0x256f << 0xc635 << 1 << 8 << true;
QTest::newRow("3Dconnexion, usage unknown") << 0x256f << 0xc652 << 0 << 0 << true;
QTest::newRow("3Dconnexion receiver's mouse interface") << 0x256f << 0xc652 << 1 << 2 << false;
QTest::newRow("Logitech SpaceNavigator, usage unknown") << 0x046d << 0xc626 << 0 << 0 << true;
QTest::newRow("Logitech, multi-axis") << 0x046d << 0xc629 << 1 << 8 << true;
QTest::newRow("Logitech receiver, usage unknown") << 0x046d << 0xc52b << 0 << 0 << false;
QTest::newRow("other vendor, multi-axis") << 0x045e << 0x0001 << 1 << 8 << false;
}
void TstSpaceMouseHid::recognisesOnly3DMice()
{
QFETCH(int, vendor);
QFETCH(int, product);
QFETCH(int, page);
QFETCH(int, usage);
QFETCH(bool, match);
QCOMPARE(isSpaceMouse(vendor, product, page, usage), match);
}
void TstSpaceMouseHid::parsesTheClassicDescriptor()
{
const Layout layout = parseDescriptor(classicDescriptor(true));
QVERIFY(layout.numbered_reports);
QVERIFY(layout.hasAxes());
for (int a = 0; a < 6; ++a) {
const Field &f = layout.axes[a];
QCOMPARE(f.report_id, a < 3 ? 1 : 2);
QCOMPARE(f.bit_offset, (a % 3) * 16);
QCOMPARE(f.bit_size, 16);
QCOMPARE(f.logical_min, -350);
QCOMPARE(f.logical_max, 350);
QVERIFY(f.relative);
}
QCOMPARE(layout.buttons.size(), 2);
QCOMPARE(layout.buttons[0].report_id, 3);
QCOMPARE(layout.buttons[0].bit_offset, 0);
QCOMPARE(layout.buttons[1].bit_offset, 1);
QCOMPARE(layout.buttons[1].bit_size, 1);
QVERIFY(!parseDescriptor(classicDescriptor(false)).axes[0].relative);
}
void TstSpaceMouseHid::parsesADescriptorWithoutReportIds()
{
// one report: X, Y, Z as 8-bit signed, then 8 buttons
const QByteArray d = hex("05010908a101" "1581" "257f" "093009310932" "7508" "9503" "8106"
"0509" "1901" "2908" "1500" "2501" "7501" "9508" "8102" "c0");
const Layout layout = parseDescriptor(d);
QVERIFY(!layout.numbered_reports);
QCOMPARE(layout.axes[2].bit_offset, 16);
QCOMPARE(layout.buttons.size(), 8);
QCOMPARE(layout.buttons[7].bit_offset, 24 + 7);
Decoder decoder(layout);
const Decoder::Result r = decoder.feed(hex("05fb7f80")); // 5, -5, 127, button 8
QVERIFY(r.motion);
QCOMPARE(r.sample.x, 5);
QCOMPARE(r.sample.y, -5);
QCOMPARE(r.sample.z, 127);
QCOMPARE(r.pressed, QList<int>{7});
}
void TstSpaceMouseHid::survivesBrokenDescriptors_data()
{
QTest::addColumn<QByteArray>("descriptor");
QTest::newRow("empty") << QByteArray();
QTest::newRow("truncated item") << hex("0501090816");
QTest::newRow("long item, truncated") << hex("fe");
QTest::newRow("pop with empty stack") << hex("b4b4b4");
QTest::newRow("huge report count") << hex("05010930" "7510" "97ffffff7f" "8102");
QTest::newRow("classic, cut in half") << classicDescriptor(true).left(30);
}
void TstSpaceMouseHid::survivesBrokenDescriptors()
{
QFETCH(QByteArray, descriptor);
QElapsedTimer timer;
timer.start();
const Layout layout = parseDescriptor(descriptor);
QVERIFY2(timer.elapsed() < 500, "a broken descriptor must not stall the parser");
Decoder decoder(layout);
decoder.feed(report(1, {1, 2, 3})); // must not crash or read out of bounds
}
void TstSpaceMouseHid::separateTranslationAndRotationReports()
{
Decoder decoder(parseDescriptor(classicDescriptor(true)));
Decoder::Result r = decoder.feed(report(1, {100, -5, 20}));
QVERIFY(r.motion);
QCOMPARE(r.sample.x, 100);
QCOMPARE(r.sample.y, -5);
QCOMPARE(r.sample.z, 20);
QCOMPARE(r.sample.rz, 0);
r = decoder.feed(report(2, {7, 8, -9}));
QVERIFY(r.motion);
QCOMPARE(r.sample.x, 100); // translation remembered
QCOMPARE(r.sample.rx, 7);
QCOMPARE(r.sample.ry, 8);
QCOMPARE(r.sample.rz, -9);
}
void TstSpaceMouseHid::combinedReportOnTheFallbackLayout()
{
Decoder decoder;
const Decoder::Result r = decoder.feed(report(1, {1, 2, 3, 4, 5, 6}));
QVERIFY(r.motion);
QCOMPARE(r.sample.x, 1);
QCOMPARE(r.sample.z, 3);
QCOMPARE(r.sample.rx, 4);
QCOMPARE(r.sample.rz, 6);
}
void TstSpaceMouseHid::absoluteAxesAreScaledLikeSpacenavd()
{
Decoder decoder(parseDescriptor(classicDescriptor(false)));
const Decoder::Result r = decoder.feed(report(1, {350, -350, 0}));
QCOMPARE(r.sample.x, 500);
QCOMPARE(r.sample.y, -500);
QCOMPARE(r.sample.z, 0);
}
void TstSpaceMouseHid::relativeAxesArePassedThrough()
{
Decoder decoder(parseDescriptor(classicDescriptor(true)));
const Decoder::Result r = decoder.feed(report(1, {350, -350, 0}));
QCOMPARE(r.sample.x, 350);
QCOMPARE(r.sample.y, -350);
}
void TstSpaceMouseHid::buttonBitmaskReportsEachPressOnce()
{
Decoder decoder;
QCOMPARE(decoder.feed(hex("0301000000")).pressed, QList<int>{0});
QCOMPARE(decoder.feed(hex("0301000000")).pressed, QList<int>{}); // still held
QCOMPARE(decoder.feed(hex("0303000000")).pressed, QList<int>{1});
QCOMPARE(decoder.feed(hex("0300000000")).pressed, QList<int>{}); // released
QCOMPARE(decoder.feed(hex("0302000000")).pressed, QList<int>{1});
QCOMPARE(decoder.feed(hex("0300010000")).pressed, QList<int>{8});
QVERIFY(!decoder.feed(hex("0300000000")).motion);
}
void TstSpaceMouseHid::buttonListReport()
{
Decoder decoder;
// numbers start at 1 (0 = none); reported from 0, like the others
QCOMPARE(decoder.feed(hex("1c0d000000")).pressed, QList<int>{12});
QCOMPARE(decoder.feed(hex("1c0d000e00")).pressed, QList<int>{13});
QCOMPARE(decoder.feed(hex("1c00000000")).pressed, QList<int>{});
QCOMPARE(decoder.feed(hex("1c0d00")).pressed, QList<int>{12});
QCOMPARE(decoder.feed(hex("1c01000000")).pressed, QList<int>{0}); // the lowest number, 1
}
void TstSpaceMouseHid::ignoresShortAndUnknownReports()
{
Decoder decoder(parseDescriptor(classicDescriptor(true)));
QVERIFY(!decoder.feed(QByteArray()).motion);
QVERIFY(!decoder.feed(hex("01ab")).motion);
QVERIFY(!decoder.feed(hex("55010203040506")).motion);
QVERIFY(decoder.feed(hex("55010203040506")).pressed.isEmpty());
}
void TstSpaceMouseHid::recordedDevices_data()
{
QTest::addColumn<QString>("path");
const QDir dir(QFINDTESTDATA("fixtures/spacemouse"));
const QStringList files = dir.exists()
? dir.entryList({QStringLiteral("*.json")}, QDir::Files)
: QStringList();
for (const QString &f : files) {
QTest::newRow(qPrintable(f)) << dir.filePath(f);
}
if (files.isEmpty()) {
QTest::newRow("none") << QString();
}
}
/*
For each recording, the axis the user moved must be the one that moved
most, in the right direction. The expected signs are the raw USB
convention as Blender documents it for Windows, and match the Linux
kernel's (so spacenavd's defaults): +x right, +y towards the user,
+z down. A recording that disagrees is a finding about that device.
*/
void TstSpaceMouseHid::recordedDevices()
{
QFETCH(QString, path);
if (path.isEmpty()) {
QSKIP("no recordings in fixtures/spacemouse yet");
}
QFile file(path);
QVERIFY(file.open(QIODevice::ReadOnly));
const QJsonObject capture = QJsonDocument::fromJson(file.readAll()).object();
const QByteArray descriptor = QByteArray::fromHex(
capture.value("report_descriptor").toString().toLatin1());
Layout layout = parseDescriptor(descriptor);
if (!layout.hasAxes()) {
layout = fallbackLayout();
}
struct Expect { const char *step; int axis; int sign; };
const Expect expectations[] = {
{"right", 0, +1}, {"left", 0, -1},
{"toward", 1, +1}, {"away", 1, -1},
{"down", 2, +1}, {"up", 2, -1},
};
QHash<QString, QJsonArray> steps;
for (const QJsonValue &s : capture.value("steps").toArray()) {
steps.insert(s.toObject().value("step").toString(),
s.toObject().value("reports").toArray());
}
for (const Expect &e : expectations)
{
if (!steps.contains(e.step)) {
continue;
}
Decoder decoder(layout);
qint64 sum[6] = {0, 0, 0, 0, 0, 0};
for (const QJsonValue &r : steps.value(e.step)) {
const Decoder::Result res = decoder.feed(
QByteArray::fromHex(r.toArray().at(1).toString().toLatin1()));
if (res.motion) {
const int v[6] = {res.sample.x, res.sample.y, res.sample.z,
res.sample.rx, res.sample.ry, res.sample.rz};
for (int a = 0; a < 3; ++a) sum[a] += v[a];
}
}
int strongest = 0;
for (int a = 1; a < 3; ++a) {
if (qAbs(sum[a]) > qAbs(sum[strongest])) strongest = a;
}
QVERIFY2(strongest == e.axis && (sum[e.axis] > 0) == (e.sign > 0),
qPrintable(QStringLiteral("step '%1': sums x=%2 y=%3 z=%4")
.arg(e.step).arg(sum[0]).arg(sum[1]).arg(sum[2])));
}
// every recorded button press must decode as a press
if (steps.contains("buttons")) {
Decoder decoder(layout);
int presses = 0;
for (const QJsonValue &r : steps.value("buttons")) {
presses += decoder.feed(QByteArray::fromHex(
r.toArray().at(1).toString().toLatin1())).pressed.size();
}
QVERIFY2(presses > 0, "the buttons step decoded no presses");
}
}
QTEST_GUILESS_MAIN(TstSpaceMouseHid)
#include "tst_spacemousehid.moc"
+290
View File
@@ -0,0 +1,290 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
/*
SpaceMouseMotion::map() -- how one 3D mouse sample pans and zooms a
view, under the user's settings.
Nobody working on this owns a device, so this is where the behaviour is
pinned down: speed must not depend on how often the driver sends
samples, zoom must stay a positive factor however hard the cap is
pushed, and each setting must do what its label says. The defaults must
also keep the behaviour from before the settings existed.
Uses its own QSettings scope, so it never touches the configuration of
whoever runs it.
*/
#include "spacemouse/spacemousemotion.h"
#include <QSettings>
#include <QtMath>
#include <QtTest>
namespace {
SpaceMouseSample translate(int x, int y, int z)
{
SpaceMouseSample s;
s.x = x;
s.y = y;
s.z = z;
return s;
}
constexpr qint64 NOMINAL = 16;
}
class TstSpaceMouseMotion : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void stepFollowsElapsedTime_data();
void stepFollowsElapsedTime();
void deadZone_data();
void deadZone();
void defaultsKeepThePreviousBehaviour();
void speedDoesNotDependOnSampleRate();
void zoomIsAlwaysAPositiveFactor();
void equalPushAndPullCancelOut();
void speedSettingsScale();
void invertFlipsOnlyItsOwnAxis();
void twistZoomsAndPushPullIsIgnored();
void deadZoneAppliesToEveryAxisUsed();
void settingsDefaultWhenNothingIsSaved();
void settingsRoundTrip();
};
void TstSpaceMouseMotion::initTestCase()
{
QCoreApplication::setOrganizationName(
QStringLiteral("QElectroTech-tst_spacemousemotion"));
QCoreApplication::setApplicationName(
QStringLiteral("tst_spacemousemotion"));
QSettings().clear();
}
void TstSpaceMouseMotion::stepFollowsElapsedTime_data()
{
QTest::addColumn<qint64>("elapsed");
QTest::addColumn<qreal>("step");
QTest::newRow("first sample") << qint64(-1) << 1.0;
QTest::newRow("same millisecond: covers no time") << qint64(0) << 0.0;
QTest::newRow("twice as fast") << qint64(8) << 0.5;
QTest::newRow("nominal") << NOMINAL << 1.0;
QTest::newRow("twice as slow") << qint64(32) << 2.0;
QTest::newRow("longest gap still counted")
<< SpaceMouseMotion::MAX_PERIOD_MS
<< SpaceMouseMotion::MAX_PERIOD_MS / SpaceMouseMotion::NOMINAL_PERIOD_MS;
QTest::newRow("after a rest") << SpaceMouseMotion::MAX_PERIOD_MS + 1 << 1.0;
}
void TstSpaceMouseMotion::stepFollowsElapsedTime()
{
QFETCH(qint64, elapsed);
QFETCH(qreal, step);
QCOMPARE(SpaceMouseMotion::stepFor(elapsed), step);
}
void TstSpaceMouseMotion::deadZone_data()
{
QTest::addColumn<int>("value");
QTest::addColumn<int>("dead_zone");
QTest::addColumn<int>("result");
QTest::newRow("no dead zone") << 1 << 0 << 1;
QTest::newRow("inside") << 2 << 3 << 0;
QTest::newRow("on the edge") << -3 << 3 << 0;
QTest::newRow("outside, positive") << 5 << 3 << 2;
QTest::newRow("outside, negative") << -5 << 3 << -2;
}
void TstSpaceMouseMotion::deadZone()
{
QFETCH(int, value);
QFETCH(int, dead_zone);
QFETCH(int, result);
QCOMPARE(SpaceMouseMotion::applyDeadZone(value, dead_zone), result);
}
void TstSpaceMouseMotion::defaultsKeepThePreviousBehaviour()
{
const SpaceMouseSettings defaults;
// Before the settings existed a sample moved the scrollbar by
// minus its deflection, and zoomed by 1 + z/1000.
const SpaceMouseViewMotion pan =
SpaceMouseMotion::map(translate(20, -7, 0), NOMINAL, defaults);
QCOMPARE(pan.scroll_x, -20.0);
QCOMPARE(pan.scroll_y, 7.0);
QCOMPARE(pan.zoom_factor, 1.0);
const SpaceMouseViewMotion zoom =
SpaceMouseMotion::map(translate(0, 0, 50), NOMINAL, defaults);
QCOMPARE(zoom.scroll_x, 0.0);
QCOMPARE(zoom.scroll_y, 0.0);
QVERIFY2(qAbs(zoom.zoom_factor - 1.05) < 0.002,
qPrintable(QString::number(zoom.zoom_factor)));
}
void TstSpaceMouseMotion::speedDoesNotDependOnSampleRate()
{
const SpaceMouseSettings defaults;
const SpaceMouseSample push = translate(30, 12, 80);
// One driver sending every 16 ms, another every 8 ms, cap held the
// same for 16 ms: the view must end up in the same place.
const SpaceMouseViewMotion slow = SpaceMouseMotion::map(push, 16, defaults);
const SpaceMouseViewMotion fast = SpaceMouseMotion::map(push, 8, defaults);
QCOMPARE(2 * fast.scroll_x, slow.scroll_x);
QCOMPARE(2 * fast.scroll_y, slow.scroll_y);
QVERIFY(qFuzzyCompare(fast.zoom_factor * fast.zoom_factor, slow.zoom_factor));
}
void TstSpaceMouseMotion::zoomIsAlwaysAPositiveFactor()
{
SpaceMouseSettings fastest;
fastest.zoom_speed = 400;
// 1 + z/1000 went to zero and below for a hard pull; a zoom of
// zero or less would flip or collapse the view.
for (int z : {-100000, -5000, -1000, -350, 350, 5000}) {
const qreal factor =
SpaceMouseMotion::map(translate(0, 0, z), NOMINAL, fastest).zoom_factor;
QVERIFY2(factor > 0 && qIsFinite(factor),
qPrintable(QStringLiteral("z=%1 gives %2").arg(z).arg(factor)));
}
}
void TstSpaceMouseMotion::equalPushAndPullCancelOut()
{
const SpaceMouseSettings defaults;
const qreal in = SpaceMouseMotion::map(translate(0, 0, 120), NOMINAL, defaults).zoom_factor;
const qreal out = SpaceMouseMotion::map(translate(0, 0, -120), NOMINAL, defaults).zoom_factor;
QVERIFY(qFuzzyCompare(in * out, 1.0));
}
void TstSpaceMouseMotion::speedSettingsScale()
{
const SpaceMouseSettings defaults;
SpaceMouseSettings doubled;
doubled.pan_speed = 200;
doubled.zoom_speed = 200;
const SpaceMouseSample push = translate(15, 25, 60);
const SpaceMouseViewMotion base = SpaceMouseMotion::map(push, NOMINAL, defaults);
const SpaceMouseViewMotion twice = SpaceMouseMotion::map(push, NOMINAL, doubled);
QCOMPARE(twice.scroll_x, 2 * base.scroll_x);
QCOMPARE(twice.scroll_y, 2 * base.scroll_y);
QVERIFY(qFuzzyCompare(twice.zoom_factor, base.zoom_factor * base.zoom_factor));
}
void TstSpaceMouseMotion::invertFlipsOnlyItsOwnAxis()
{
const SpaceMouseSettings defaults;
const SpaceMouseSample push = translate(15, 25, 60);
const SpaceMouseViewMotion base = SpaceMouseMotion::map(push, NOMINAL, defaults);
SpaceMouseSettings s;
s.invert_pan_x = true;
SpaceMouseViewMotion m = SpaceMouseMotion::map(push, NOMINAL, s);
QCOMPARE(m.scroll_x, -base.scroll_x);
QCOMPARE(m.scroll_y, base.scroll_y);
QCOMPARE(m.zoom_factor, base.zoom_factor);
s = SpaceMouseSettings();
s.invert_pan_y = true;
m = SpaceMouseMotion::map(push, NOMINAL, s);
QCOMPARE(m.scroll_x, base.scroll_x);
QCOMPARE(m.scroll_y, -base.scroll_y);
QCOMPARE(m.zoom_factor, base.zoom_factor);
s = SpaceMouseSettings();
s.invert_zoom = true;
m = SpaceMouseMotion::map(push, NOMINAL, s);
QCOMPARE(m.scroll_x, base.scroll_x);
QCOMPARE(m.scroll_y, base.scroll_y);
QVERIFY(qFuzzyCompare(m.zoom_factor * base.zoom_factor, 1.0));
}
void TstSpaceMouseMotion::twistZoomsAndPushPullIsIgnored()
{
SpaceMouseSettings twist;
twist.zoom_axis = SpaceMouseSettings::ZoomAxis::Twist;
SpaceMouseSample pushed = translate(0, 0, 200);
QCOMPARE(SpaceMouseMotion::map(pushed, NOMINAL, twist).zoom_factor, 1.0);
SpaceMouseSample twisted;
twisted.rz = 50;
const qreal by_twist = SpaceMouseMotion::map(twisted, NOMINAL, twist).zoom_factor;
const qreal by_push = SpaceMouseMotion::map(translate(0, 0, 50), NOMINAL,
SpaceMouseSettings()).zoom_factor;
QCOMPARE(by_twist, by_push);
// the other rotations never zoom
SpaceMouseSample tilted;
tilted.rx = 300;
tilted.ry = -300;
QCOMPARE(SpaceMouseMotion::map(tilted, NOMINAL, twist).zoom_factor, 1.0);
}
void TstSpaceMouseMotion::deadZoneAppliesToEveryAxisUsed()
{
SpaceMouseSettings s;
s.dead_zone = 10;
const SpaceMouseViewMotion drift =
SpaceMouseMotion::map(translate(9, -10, 8), NOMINAL, s);
QCOMPARE(drift.scroll_x, 0.0);
QCOMPARE(drift.scroll_y, 0.0);
QCOMPARE(drift.zoom_factor, 1.0);
s.zoom_axis = SpaceMouseSettings::ZoomAxis::Twist;
SpaceMouseSample twisted;
twisted.rz = 10;
QCOMPARE(SpaceMouseMotion::map(twisted, NOMINAL, s).zoom_factor, 1.0);
}
void TstSpaceMouseMotion::settingsDefaultWhenNothingIsSaved()
{
QSettings().clear();
QVERIFY(SpaceMouseSettings::load() == SpaceMouseSettings());
}
void TstSpaceMouseMotion::settingsRoundTrip()
{
SpaceMouseSettings s;
s.pan_speed = 150;
s.zoom_speed = 40;
s.dead_zone = 7;
s.invert_pan_x = true;
s.invert_pan_y = true;
s.invert_zoom = true;
s.zoom_axis = SpaceMouseSettings::ZoomAxis::Twist;
s.save();
QVERIFY(SpaceMouseSettings::load() == s);
QVERIFY(!(SpaceMouseSettings::load() == SpaceMouseSettings()));
QSettings().clear();
}
QTEST_GUILESS_MAIN(TstSpaceMouseMotion)
#include "tst_spacemousemotion.moc"