Extract a SpaceMouseBackend seam ahead of a future Windows/macOS backend

The user asked for phase 2 (Windows/macOS via 3Dconnexion's proprietary
3DxWare SDK) on top of #635. This sandbox has no 3DxWare SDK, no Windows
toolchain, and no macOS toolchain -- nothing to compile, link, or run a
single line of platform code against, unlike the Linux/libspnav path,
which was built and actually tested here for real. Writing 3DxWare
integration code that has never even built would be a materially weaker,
unverifiable thing sitting in this PR, so it is not in this commit.

What is: the structural seam that makes adding it later a contained,
reviewable change instead of a rewrite of code that already works.

## Before

SpaceMouseListener did three unrelated things in one class: own the
libspnav connection, read spnav events, and apply motion to the active
DiagramView. A Windows/macOS backend would have had to either duplicate
all of the DiagramView-facing logic (the pan/zoom calls, the Z-to-zoom-
factor mapping, the "which view is active" lookup -- all already verified)
or bolt onto the same class with a maze of #ifdefs. Either way, touching
that file again would put the already-tested Linux path back in scope for
review.

## After

- SpaceMouseBackend: a tiny interface (isAvailable(), a motion(dx,dy,dz)
  signal). A backend's only job is owning one platform's connection to the
  driver/daemon and translating its native event into this one signal.
- SpnavBackend: the libspnav code from the previous commit, moved behind
  that interface with no behaviour change -- still spnav_open() in the
  constructor, still a QSocketNotifier on spnav_fd(), still silent when no
  daemon/device is present.
- SpaceMouseListener: now backend-agnostic. Owns whichever backend the
  platform provides, applies its motion to the active DiagramView exactly
  as before. The DiagramView-facing code (pan/zoom calls,
  zoomFactorForZAxis) did not need to change at all -- only its input
  changed from a spnav_event_motion struct to three plain ints.

A future 3DxWare backend implements SpaceMouseBackend, is selected in
SpaceMouseListener's constructor behind its own
QET_SPACEMOUSE_BACKEND_3DXWARE guard (see the comment marking exactly
where), and never has to touch SpnavBackend or SpaceMouseListener's
DiagramView-facing half.

## CMake: one user option, one define per backend

QET_ENABLE_SPACEMOUSE is unchanged as the single option a user sets.
Internally, find_spacemouse.cmake now decides *which* backend (if any)
that resolves to: on Linux with libspnav found, QET_SPACEMOUSE_BACKEND_SPNAV
plus the umbrella QET_SPACEMOUSE_SUPPORT. Turning the option on anywhere
else today downgrades cleanly with a warning naming discussion #599,
instead of trying (and failing) to find libspnav on a platform that
doesn't ship it. Adding 3DxWare later means adding one more branch here,
not restructuring this file.

## Verified this is a pure refactor, not just "still compiles"

Reconfigured and rebuilt both ways from scratch:
 - option off: unchanged from before -- no new source files compiled, zero
   new object code.
 - option on: both new files compile with zero warnings, binary still
   links against libspnav.so.0 (confirmed via ldd), and run to completion
   in this environment (which has no spacenavd) with zero crashes and zero
   spnav-related output -- identical to before the refactor.
 - zoomFactorForZAxis re-linked and re-run in isolation: identical output
   to the pre-refactor commit (z=0 -> exactly 1.0, z=+-350 -> 1.35/0.65),
   confirming the math moved unchanged rather than being reimplemented.
This commit is contained in:
ispyisail
2026-08-02 21:22:42 +12:00
parent 2e4bb486bb
commit e35cab33f0
8 changed files with 329 additions and 115 deletions
+71
View File
@@ -0,0 +1,71 @@
/*
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 SPACEMOUSEBACKEND_H
#define SPACEMOUSEBACKEND_H
#include <QObject>
/**
@brief The SpaceMouseBackend class
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
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.
*/
class SpaceMouseBackend : public QObject
{
Q_OBJECT
public:
explicit SpaceMouseBackend(QObject *parent = nullptr) : QObject(parent) {}
~SpaceMouseBackend() override = default;
/// True once this backend actually has a live connection to a
/// driver/daemon. False is the ordinary case -- no daemon
/// running, no device attached -- not an error; see
/// SpaceMouseListener's class comment for why that distinction
/// matters.
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);
};
#endif // SPACEMOUSEBACKEND_H
+45 -63
View File
@@ -17,24 +17,27 @@
*/
#include "spacemouselistener.h"
#include "spacemousebackend.h"
#ifdef QET_SPACEMOUSE_BACKEND_SPNAV
# include "spnavbackend.h"
#endif
#include "../diagramview.h"
#include "../projectview.h"
#include "../qetdiagrameditor.h"
#include <QApplication>
#include <QScrollBar>
#include <QSocketNotifier>
#include <spnav.h>
namespace {
//A spnav motion delta is an integer on roughly the same order of
//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
//user's spacenavd sensitivity setting, which is configured once
//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.
//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
@@ -46,38 +49,40 @@ namespace {
/**
@brief SpaceMouseListener::SpaceMouseListener
Try to connect to spacenavd. Failure -- no daemon running, no device
attached -- is left silent: it is the expected state for most users and
must never surface as an error dialog or a log warning on every
ordinary startup.
Construct whichever backend is available for this platform and connect
its motion() signal. If none is compiled in, or the one that is can't
reach a device, isAvailable() simply stays false -- see the class
comment.
@param parent
*/
SpaceMouseListener::SpaceMouseListener(QObject *parent) :
QObject(parent)
{
if (spnav_open() == -1) {
return;
}
#ifdef QET_SPACEMOUSE_BACKEND_SPNAV
m_backend = new SpnavBackend(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.
m_available = true;
m_notifier = new QSocketNotifier(spnav_fd(), QSocketNotifier::Read, this);
connect(m_notifier, &QSocketNotifier::activated,
this, &SpaceMouseListener::readEvents);
if (m_backend) {
connect(m_backend, &SpaceMouseBackend::motion,
this, &SpaceMouseListener::applyMotion);
}
}
/**
@brief SpaceMouseListener::~SpaceMouseListener
@brief SpaceMouseListener::isAvailable
@return whether the platform backend has a live device connection
*/
SpaceMouseListener::~SpaceMouseListener()
bool SpaceMouseListener::isAvailable() const
{
if (m_available) {
spnav_close();
}
return m_backend && m_backend->isAvailable();
}
/**
@brief SpaceMouseListener::zoomFactorForZAxis
@param z : raw Z-axis (push/pull) delta from a spnav motion event
@param z : raw Z-axis (push/pull) delta from a device motion sample
@return the multiplicative factor DiagramView::zoom() expects
*/
qreal SpaceMouseListener::zoomFactorForZAxis(int z)
@@ -86,45 +91,22 @@ qreal SpaceMouseListener::zoomFactorForZAxis(int z)
}
/**
@brief SpaceMouseListener::readEvents
Called when the spacenavd socket has data available. Drains every event
currently queued -- spnav_poll_event() returns 0 once the queue is
empty -- rather than handling just one per activation, so events cannot
silently back up if several arrive between two Qt event loop turns.
*/
void SpaceMouseListener::readEvents()
{
spnav_event event;
while (spnav_poll_event(&event))
{
if (event.type == SPNAV_EVENT_MOTION) {
dispatchMotion(event.motion);
}
//Button events (SPNAV_EVENT_BUTTON) are deliberately not
//handled: mapping device buttons to QET actions is the "Related,
//not proposed here" follow-up in discussion #599, not this
//phase.
}
}
/**
@brief SpaceMouseListener::dispatchMotion
Apply one motion event to whichever DiagramView is currently active.
@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.
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.
Which of the three translation axes is "left/right" vs "forward/back"
vs "up/down" on the physical device, and their sign, is a hardware
convention this could not be checked against real hardware while
writing it -- see the PR description.
@param motion
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
*/
void SpaceMouseListener::dispatchMotion(const spnav_event_motion &motion)
void SpaceMouseListener::applyMotion(int dx, int dy, int dz)
{
auto *editor = qobject_cast<QETDiagramEditor *>(qApp->activeWindow());
if (!editor) {
@@ -141,15 +123,15 @@ void SpaceMouseListener::dispatchMotion(const spnav_event_motion &motion)
return;
}
if (motion.x || motion.y)
if (dx || dy)
{
view->horizontalScrollBar()->setValue(
view->horizontalScrollBar()->value() - qRound(motion.x * PAN_SCALE));
view->horizontalScrollBar()->value() - qRound(dx * PAN_SCALE));
view->verticalScrollBar()->setValue(
view->verticalScrollBar()->value() - qRound(motion.y * PAN_SCALE));
view->verticalScrollBar()->value() - qRound(dy * PAN_SCALE));
}
if (motion.z) {
view->zoom(zoomFactorForZAxis(motion.z));
if (dz) {
view->zoom(zoomFactorForZAxis(dz));
}
}
+31 -32
View File
@@ -20,27 +20,31 @@
#include <QObject>
class QSocketNotifier;
struct spnav_event_motion;
class SpaceMouseBackend;
/**
@brief The SpaceMouseListener class
Phase 1 (Linux, libspnav) of
https://github.com/qelectrotech/qelectrotech-source-mirror/discussions/599 :
bridges a 3Dconnexion SpaceMouse/SpacePilot 6-DOF device, via the
spacenavd daemon and libspnav, to DiagramView's existing pan/zoom
primitives (the same horizontalScrollBar()/verticalScrollBar()/zoom()
calls DiagramView::wheelEvent() already uses for a physical wheel).
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).
Only compiled in when the QET_ENABLE_SPACEMOUSE CMake option is on. Even
then, constructing one is always safe: when no spacenavd is running or
no device is attached -- the expected state for the overwhelming
majority of users, even of a build with the option on -- it silently
does nothing rather than failing or nagging the user. There is exactly
one of these, owned by QETApp, because a physical 6-DOF device is a
single ambient input source for the whole application, not something
tied to one window; motion is applied to whichever DiagramView is
currently active (see targetView()).
Everything here is platform-independent: which DiagramView to apply
motion to, the pan/zoom calls, and the Z-to-zoom-factor mapping.
Talking to the actual device driver is a SpaceMouseBackend's job (see
its class comment) -- this class owns one and applies whatever it
reports, without knowing or caring which platform it came from.
Only compiled in when QET_SPACEMOUSE_SUPPORT is defined. Even then,
constructing one is always safe: if no backend is available for this
platform, or the one that exists can't reach a driver/daemon (no
device attached -- the expected state for the overwhelming majority of
users, even of a build with the option on), this silently does nothing
rather than failing or nagging the user. There is exactly one of
these, owned by QETApp, because a physical 6-DOF device is a single
ambient input source for the whole application, not something tied to
one window.
*/
class SpaceMouseListener : public QObject
{
@@ -48,32 +52,27 @@ class SpaceMouseListener : public QObject
public:
explicit SpaceMouseListener(QObject *parent = nullptr);
~SpaceMouseListener() override;
~SpaceMouseListener() override = default;
/// True once a connection to spacenavd was established.
/// False is the common case, not an error -- see the class
/// comment -- so callers should not warn the user when this
/// is false.
bool isAvailable() const {return m_available;}
/// True once the platform backend has a live connection to a
/// driver/daemon. False is the common case, not an error -- see
/// the class comment -- so callers should not warn the user
/// 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
/// spacenavd connection or a real device.
/// backend connection or a real device.
static qreal zoomFactorForZAxis(int z);
private slots:
/// Drain and dispatch every event currently queued on the
/// spacenavd socket. Connected to a QSocketNotifier on that
/// socket's fd rather than polled on a timer, so this is
/// idle-cost-free between events.
void readEvents();
/// Apply one motion sample -- from whichever backend is in use
/// -- to whichever DiagramView is currently active.
void applyMotion(int dx, int dy, int dz);
private:
void dispatchMotion(const spnav_event_motion &motion);
bool m_available = false;
QSocketNotifier *m_notifier = nullptr;
SpaceMouseBackend *m_backend = nullptr;
};
#endif // SPACEMOUSELISTENER_H
+78
View File
@@ -0,0 +1,78 @@
/*
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 "spnavbackend.h"
#include <QSocketNotifier>
#include <spnav.h>
/**
@brief SpnavBackend::SpnavBackend
Try to connect to spacenavd. Failure -- no daemon running, no device
attached -- is left silent: it is the expected state for most users and
must never surface as an error dialog or a log warning on every
ordinary startup.
@param parent
*/
SpnavBackend::SpnavBackend(QObject *parent) :
SpaceMouseBackend(parent)
{
if (spnav_open() == -1) {
return;
}
m_available = true;
m_notifier = new QSocketNotifier(spnav_fd(), QSocketNotifier::Read, this);
connect(m_notifier, &QSocketNotifier::activated,
this, &SpnavBackend::readEvents);
}
/**
@brief SpnavBackend::~SpnavBackend
*/
SpnavBackend::~SpnavBackend()
{
if (m_available) {
spnav_close();
}
}
/**
@brief SpnavBackend::readEvents
Called when the spacenavd socket has data available. Drains every event
currently queued -- spnav_poll_event() returns 0 once the queue is
empty -- rather than handling just one per activation, so events cannot
silently back up if several arrive between two Qt event loop turns.
*/
void SpnavBackend::readEvents()
{
spnav_event event;
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);
}
//Button events (SPNAV_EVENT_BUTTON) are deliberately not
//handled: mapping device buttons to QET actions is the "Related,
//not proposed here" follow-up in discussion #599, not this
//phase.
}
}
+58
View File
@@ -0,0 +1,58 @@
/*
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 SPNAVBACKEND_H
#define SPNAVBACKEND_H
#include "spacemousebackend.h"
class QSocketNotifier;
/**
@brief The SpnavBackend class
Linux SpaceMouseBackend, via the spacenavd daemon and libspnav. Only
compiled in when QET_SPACEMOUSE_BACKEND_SPNAV is defined (set by
cmake/find_spacemouse.cmake once it has actually found libspnav).
Connecting is always safe even when no daemon is running or no device
is attached -- the expected state for the overwhelming majority of
users, even of a build with 3D mouse support compiled in -- this
silently leaves isAvailable() false rather than failing loudly.
*/
class SpnavBackend : public SpaceMouseBackend
{
Q_OBJECT
public:
explicit SpnavBackend(QObject *parent = nullptr);
~SpnavBackend() override;
bool isAvailable() const override {return m_available;}
private slots:
/// Drain and emit every event currently queued on the spacenavd
/// socket. Connected to a QSocketNotifier on that socket's fd
/// rather than polled on a timer, so this is idle-cost-free
/// between events.
void readEvents();
private:
bool m_available = false;
QSocketNotifier *m_notifier = nullptr;
};
#endif // SPNAVBACKEND_H