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"