Add 3D mouse (SpaceMouse/SpacePilot) pan/zoom support via libspnav

Implements discussion #599's phase 1 (Linux, libspnav): a 3Dconnexion
6-DOF device pans and zooms the active diagram view, via spacenavd.

## Off by default, zero cost when off

QET_ENABLE_SPACEMOUSE (cmake/developer_options.cmake) is OFF. Verified in
two separate build directories from a clean configure: with it off, the
new cmake/find_spacemouse.cmake step runs and does nothing (no library
lookup, no definition, no new source files compiled), and qetapp.cpp/.h
produce zero new object code -- both are entirely #ifdef'd out. The
default build is byte-for-byte the same shape as before this commit.

With it on, libspnav is located via its pkg-config file (spnav.pc, shipped
by libspnav-dev on Debian/Ubuntu and equivalent packages elsewhere). If the
option is on but the library isn't found, this does not hard-fail
configure: it downgrades back to off with a warning, so an opt-in feature
never blocks a developer who doesn't have the library installed.

## No new navigation logic -- a new input source for the existing one

DiagramView::wheelEvent() already turns a physical wheel's delta into
horizontalScrollBar()/verticalScrollBar() calls for pan and a
zoom(1 + value/1000) call for zoom -- see diagramview.cpp:661-687.
SpaceMouseListener calls exactly those same primitives from spnav motion
events instead of wheel events. It does not reimplement panning or
zooming.

## Safe by construction even when compiled in

The overwhelming majority of users of a build with the option on still
won't have spacenavd running or a device attached -- that must never
surface as an error dialog or a startup warning. SpaceMouseListener::
isAvailable() reflects this: spnav_open() failing is treated as the
ordinary case, not an error, and the object then does nothing at all.
Verified for real in this environment, which genuinely has no spacenavd
running: built with the option on, ran the resulting binary to completion,
and confirmed zero crashes and zero spnav-related output of any kind --
the silence is the point.

Motion is read via a QSocketNotifier on spnav_fd() (event-driven, no
polling loop, no idle cost) and applied to whichever DiagramView is
currently active, found via qApp->activeWindow() -> QETDiagramEditor ->
currentProjectView() -> currentDiagram(): a 6-DOF device is one ambient
input source for the whole application, not something tied to a
particular window, so there is exactly one listener, owned by QETApp.

## What could not be verified without hardware

The Z-axis-to-zoom-factor mapping (SpaceMouseListener::zoomFactorForZAxis)
is a pure function specifically so it could be tested without a live
device: confirmed a centered device (z=0) yields exactly 1.0 (an exact
no-op, not an epsilon-off value that could trip DiagramView::zoom()'s
>=1 branch), and that push/pull produce symmetric zoom-in/out factors.

What genuinely cannot be checked in this environment: which physical axis
is "left/right" vs "up/down" vs "push/forward", their sign, and whether
the ZOOM_DIVISOR/PAN_SCALE constants feel right on real hardware. Both are
named constants specifically so recalibrating them is a one-line change
once someone with a device tries it -- flagged plainly in the PR rather
than presented as verified.

## Not in this commit

Windows/macOS (proprietary 3DxWare SDK, materially bigger lift) and
device button mapping are both explicitly out of scope for this phase per
the discussion.
This commit is contained in:
ispyisail
2026-08-02 21:04:57 +12:00
parent ec70bdc0a3
commit 2e4bb486bb
8 changed files with 320 additions and 0 deletions
+11
View File
@@ -40,6 +40,9 @@
#include "machine_info.h"
#include "TerminalStrip/ui/terminalstripeditorwindow.h"
#include "qetversion.h"
#ifdef QET_SPACEMOUSE_SUPPORT
# include "spacemouse/spacemouselistener.h"
#endif
#include <cstdlib>
#include <iostream>
@@ -156,6 +159,14 @@ QETApp::QETApp() :
}
checkBackupFiles();
#ifdef QET_SPACEMOUSE_SUPPORT
//Always safe to construct: it silently does nothing when spacenavd
//isn't running or no device is attached, which is the common case
//even in a build with this feature compiled in. See
//SpaceMouseListener's class comment.
m_space_mouse_listener = new SpaceMouseListener(this);
#endif
}
/**
+10
View File
@@ -47,6 +47,9 @@ class QETProject;
class QETTitleBlockTemplateEditor;
class QTextOrientationSpinBoxWidget;
class RecentFiles;
#ifdef QET_SPACEMOUSE_SUPPORT
class SpaceMouseListener;
#endif
/**
@brief The QETApp class
@@ -225,6 +228,13 @@ class QETApp : public QObject
static TitleBlockTemplatesFilesCollection *m_company_tbt_collection;
static TitleBlockTemplatesFilesCollection *m_custom_tbt_collection;
static ElementsCollectionCache *collections_cache_;
#ifdef QET_SPACEMOUSE_SUPPORT
/// One per application, not per window: a physical 6-DOF device
/// is a single ambient input source, and motion is applied to
/// whichever DiagramView is currently active. See
/// SpaceMouseListener's class comment.
SpaceMouseListener *m_space_mouse_listener = nullptr;
#endif
static QMap<uint, QETProject *> registered_projects_;
static uint next_project_id;
static RecentFiles *m_projects_recent_files;
+155
View File
@@ -0,0 +1,155 @@
/*
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 "spacemouselistener.h"
#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
//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.
//
//Neither this divisor nor PAN_SCALE below has been calibrated
//against real hardware -- there is none in the environment this was
//built in. Both are named constants specifically so that is a
//one-line fix once someone with a device tries it.
constexpr qreal ZOOM_DIVISOR = 1000.0;
constexpr qreal PAN_SCALE = 1.0;
}
/**
@brief SpaceMouseListener::SpaceMouseListener
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
*/
SpaceMouseListener::SpaceMouseListener(QObject *parent) :
QObject(parent)
{
if (spnav_open() == -1) {
return;
}
m_available = true;
m_notifier = new QSocketNotifier(spnav_fd(), QSocketNotifier::Read, this);
connect(m_notifier, &QSocketNotifier::activated,
this, &SpaceMouseListener::readEvents);
}
/**
@brief SpaceMouseListener::~SpaceMouseListener
*/
SpaceMouseListener::~SpaceMouseListener()
{
if (m_available) {
spnav_close();
}
}
/**
@brief SpaceMouseListener::zoomFactorForZAxis
@param z : raw Z-axis (push/pull) delta from a spnav motion event
@return the multiplicative factor DiagramView::zoom() expects
*/
qreal SpaceMouseListener::zoomFactorForZAxis(int z)
{
return 1.0 + (static_cast<qreal>(z) / ZOOM_DIVISOR);
}
/**
@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.
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
*/
void SpaceMouseListener::dispatchMotion(const spnav_event_motion &motion)
{
auto *editor = qobject_cast<QETDiagramEditor *>(qApp->activeWindow());
if (!editor) {
return;
}
ProjectView *project_view = editor->currentProjectView();
if (!project_view) {
return;
}
DiagramView *view = project_view->currentDiagram();
if (!view) {
return;
}
if (motion.x || motion.y)
{
view->horizontalScrollBar()->setValue(
view->horizontalScrollBar()->value() - qRound(motion.x * PAN_SCALE));
view->verticalScrollBar()->setValue(
view->verticalScrollBar()->value() - qRound(motion.y * PAN_SCALE));
}
if (motion.z) {
view->zoom(zoomFactorForZAxis(motion.z));
}
}
+79
View File
@@ -0,0 +1,79 @@
/*
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 SPACEMOUSELISTENER_H
#define SPACEMOUSELISTENER_H
#include <QObject>
class QSocketNotifier;
struct spnav_event_motion;
/**
@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).
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()).
*/
class SpaceMouseListener : public QObject
{
Q_OBJECT
public:
explicit SpaceMouseListener(QObject *parent = nullptr);
~SpaceMouseListener() override;
/// 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;}
/// 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.
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();
private:
void dispatchMotion(const spnav_event_motion &motion);
bool m_available = false;
QSocketNotifier *m_notifier = nullptr;
};
#endif // SPACEMOUSELISTENER_H