mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-26 03:44:14 +02:00
Compare commits
14 Commits
9987f05835
...
ac40d110fb
| Author | SHA1 | Date | |
|---|---|---|---|
| ac40d110fb | |||
| 72cf86ece0 | |||
| 8aefdcc481 | |||
| 3a45625d69 | |||
| 988357057f | |||
| e9676ef87e | |||
| ca507dc0f3 | |||
| 1932150ea6 | |||
| 23241fba69 | |||
| f2f1051914 | |||
| 313aafc95b | |||
| aaeaff55cc | |||
| 17ebbffbca | |||
| 04e2279a7f |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
Executable
+195
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user