From 17ebbffbca999da1ad33cf71364b7b349c378ccb Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 25 Sep 2026 09:56:04 +1200 Subject: [PATCH] Add 3D mouse speed, direction, dead zone and twist-to-zoom settings The 3D mouse's pan and zoom speeds were fixed guesses, and each sample was applied as it came, so the speed on screen depended on how often the driver sends samples -- different for every platform and device. Motion now goes through SpaceMouseMotion::map(), which scales each sample by the time since the previous one, and applies the user's settings from a new "Mouvement" section of Configuration > Souris 3D: pan and zoom speed, a dead zone, inverting each axis, and zooming by push/pull (as before) or by twisting the cap. The defaults keep the previous behaviour. Zoom is now exponential in the deflection, so the factor stays positive however hard the cap is pulled (1 + z/1000 went negative past z = -1000) and an equal push and pull cancel out. Sub- pixel pan is carried over between samples instead of being rounded away. The backend now reports all six axes. tst_spacemousemotion covers the mapping without a device and is built whether or not QET_ENABLE_SPACEMOUSE is on. The new behaviour was also checked end to end with tools/spnav-shim (qelectrotech-docker): twist with a dead zone of 10 ignores push/pull and small drift, and a twist of 60 gives the same frame as a push of 50 with the defaults. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + sources/qetapp.cpp | 6 + sources/spacemouse/spacemousebackend.h | 14 +- sources/spacemouse/spacemouselistener.cpp | 109 ++++--- sources/spacemouse/spacemouselistener.h | 30 +- sources/spacemouse/spacemousemotion.cpp | 147 +++++++++ sources/spacemouse/spacemousemotion.h | 102 ++++++ sources/spacemouse/spnavbackend.cpp | 12 +- .../ui/configpage/spacemouseconfigpage.cpp | 89 +++++- sources/ui/configpage/spacemouseconfigpage.h | 19 +- tests/qttest/CMakeLists.txt | 11 + tests/qttest/tst_spacemousemotion.cpp | 290 ++++++++++++++++++ 12 files changed, 750 insertions(+), 81 deletions(-) create mode 100644 sources/spacemouse/spacemousemotion.cpp create mode 100644 sources/spacemouse/spacemousemotion.h create mode 100644 tests/qttest/tst_spacemousemotion.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 41a35bd0c..708ac35e4 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -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 ) diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index ebe88d084..c57a907cd 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -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 } /** diff --git a/sources/spacemouse/spacemousebackend.h b/sources/spacemouse/spacemousebackend.h index 49578a349..e2d464aa4 100644 --- a/sources/spacemouse/spacemousebackend.h +++ b/sources/spacemouse/spacemousebackend.h @@ -18,6 +18,8 @@ #ifndef SPACEMOUSEBACKEND_H #define SPACEMOUSEBACKEND_H +#include "spacemousemotion.h" + #include /** @@ -59,13 +61,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 diff --git a/sources/spacemouse/spacemouselistener.cpp b/sources/spacemouse/spacemouselistener.cpp index 2426077fe..325a5f334 100644 --- a/sources/spacemouse/spacemouselistener.cpp +++ b/sources/spacemouse/spacemouselistener.cpp @@ -33,34 +33,6 @@ #include #include -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; - - //Pan a view by one motion sample, through its scrollbars -- the same - //way both editors' own middle-button drag pans them. - void panView(QGraphicsView *view, int dx, int dy) - { - view->horizontalScrollBar()->setValue( - view->horizontalScrollBar()->value() - qRound(dx * PAN_SCALE)); - view->verticalScrollBar()->setValue( - view->verticalScrollBar()->value() - qRound(dy * PAN_SCALE)); - } -} - /** @brief SpaceMouseListener::SpaceMouseListener Construct whichever backend is available for this platform and connect @@ -70,7 +42,8 @@ namespace { @param parent */ SpaceMouseListener::SpaceMouseListener(QObject *parent) : - QObject(parent) + QObject(parent), + m_settings(SpaceMouseSettings::load()) { #ifdef QET_SPACEMOUSE_BACKEND_SPNAV m_backend = new SpnavBackend(this); @@ -97,34 +70,40 @@ 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(z) / ZOOM_DIVISOR); + m_settings = SpaceMouseSettings::load(); } /** @brief SpaceMouseListener::applyMotion 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. - X/Y translation pans it, Z translation 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. - - 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. - @param dx - @param dy - @param dz + 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(int dx, int dy, int dz) +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(); + } + + 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(window)) @@ -139,11 +118,11 @@ void SpaceMouseListener::applyMotion(int dx, int dy, int dz) return; } - if (dx || dy) { - panView(view, dx, dy); + if (pans) { + scrollView(view, motion.scroll_x, motion.scroll_y); } - if (dz) { - view->zoom(zoomFactorForZAxis(dz)); + if (zooms) { + view->zoom(motion.zoom_factor); } } else if (auto *element_editor = qobject_cast(window)) @@ -153,21 +132,41 @@ void SpaceMouseListener::applyMotion(int dx, int dy, int dz) return; } - if (dx || dy) + 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(); - panView(view, dx, dy); + scrollView(view, motion.scroll_x, motion.scroll_y); } - if (dz) { - view->zoom(zoomFactorForZAxis(dz)); + 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 +*/ +void SpaceMouseListener::scrollView(QGraphicsView *view, qreal dx, qreal dy) +{ + 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); + + view->horizontalScrollBar()->setValue(view->horizontalScrollBar()->value() + whole_x); + view->verticalScrollBar()->setValue(view->verticalScrollBar()->value() + whole_y); +} + /** @brief SpaceMouseListener::applyButton @param button diff --git a/sources/spacemouse/spacemouselistener.h b/sources/spacemouse/spacemouselistener.h index 00c6b822c..1e5df85ea 100644 --- a/sources/spacemouse/spacemouselistener.h +++ b/sources/spacemouse/spacemouselistener.h @@ -18,8 +18,13 @@ #ifndef SPACEMOUSELISTENER_H #define SPACEMOUSELISTENER_H -#include +#include "spacemousemotion.h" +#include +#include +#include + +class QGraphicsView; class SpaceMouseBackend; /** @@ -35,7 +40,8 @@ class SpaceMouseBackend; a second, device-specific action list. Everything here is platform-independent: which view to apply - motion to, the pan/zoom calls, the Z-to-zoom-factor mapping, and button + 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 the view of the active diagram or element editor. - void applyMotion(int dx, int dy, int dz); + 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 diff --git a/sources/spacemouse/spacemousemotion.cpp b/sources/spacemouse/spacemousemotion.cpp new file mode 100644 index 000000000..96de9624e --- /dev/null +++ b/sources/spacemouse/spacemousemotion.cpp @@ -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 . +*/ +#include "spacemousemotion.h" + +#include +#include + +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; +} diff --git a/sources/spacemouse/spacemousemotion.h b/sources/spacemouse/spacemousemotion.h new file mode 100644 index 000000000..dbd191db4 --- /dev/null +++ b/sources/spacemouse/spacemousemotion.h @@ -0,0 +1,102 @@ +/* + 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 . +*/ +#ifndef SPACEMOUSEMOTION_H +#define SPACEMOUSEMOTION_H + +#include + +/** + @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 (negative = no previous one) + 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 diff --git a/sources/spacemouse/spnavbackend.cpp b/sources/spacemouse/spnavbackend.cpp index 3fc7e6912..0bac5957b 100644 --- a/sources/spacemouse/spnavbackend.cpp +++ b/sources/spacemouse/spnavbackend.cpp @@ -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 diff --git a/sources/ui/configpage/spacemouseconfigpage.cpp b/sources/ui/configpage/spacemouseconfigpage.cpp index 8f2841c10..881fe51b1 100644 --- a/sources/ui/configpage/spacemouseconfigpage.cpp +++ b/sources/ui/configpage/spacemouseconfigpage.cpp @@ -20,9 +20,13 @@ #include "../../qeticons.h" #include "../../shortcutmanager.h" #include "../../spacemouse/spacemousebuttonmap.h" +#include "../../spacemouse/spacemousemotion.h" +#include #include +#include #include +#include #include #include #include @@ -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(SpaceMouseSettings::ZoomAxis::PushPull)); + m_zoom_axis->addItem(tr("Tourner le capuchon"), + static_cast(SpaceMouseSettings::ZoomAxis::Twist)); + m_zoom_axis->setCurrentIndex( + m_zoom_axis->findData(static_cast(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( + 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 previous = SpaceMouseButtonMap::allBindings(); for (auto it = previous.constBegin(); it != previous.constEnd(); ++it) { SpaceMouseButtonMap::setActionId(it.key(), QString()); diff --git a/sources/ui/configpage/spacemouseconfigpage.h b/sources/ui/configpage/spacemouseconfigpage.h index e8d6ec0ae..fa11456ec 100644 --- a/sources/ui/configpage/spacemouseconfigpage.h +++ b/sources/ui/configpage/spacemouseconfigpage.h @@ -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; }; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index c5c13e6ce..0f6c3b2d9 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -181,6 +181,17 @@ 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) + # 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 diff --git a/tests/qttest/tst_spacemousemotion.cpp b/tests/qttest/tst_spacemousemotion.cpp new file mode 100644 index 000000000..2a80be01b --- /dev/null +++ b/tests/qttest/tst_spacemousemotion.cpp @@ -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 . +*/ + +/* + 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 +#include +#include + +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("elapsed"); + QTest::addColumn("step"); + QTest::newRow("first sample") << qint64(-1) << 1.0; + QTest::newRow("same millisecond") << qint64(0) << 1.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("value"); + QTest::addColumn("dead_zone"); + QTest::addColumn("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"