Compare commits

...

8 Commits

Author SHA1 Message Date
Laurent Trinques d43c535cc9 Merge pull request #1030 from ispyisail/fix/bugtracker-343-save-order
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m2s
Fix bugtracker #343: saving reorders texts and shapes after an edit
2026-09-25 15:55:44 +02:00
Laurent Trinques 59dc59cf2f Merge pull request #1029 from ispyisail/fix/bugtracker-340-pdfxid
Fix bugtracker #340: small PDF text drawn too bold in Adobe Acrobat
2026-09-25 14:35:43 +02:00
Laurent Trinques 469c8cbc70 Merge pull request #1032 from ispyisail/feat/spacemouse-3dxware-macos
Fix 3D mouse doing nothing on macOS when 3DxWare is installed
2026-09-25 14:33:59 +02:00
ispyisail e7f19de2da Mention ConnexionBackend in SpaceMouseBackend's class comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:25:27 +12:00
ispyisail 4c9a307cb9 Say what realLibrary() can and cannot check
Measured on a macOS 15 runner with 3DxWare 10.8.13: an ad-hoc signed
test binary with the hardened runtime loads 3DconnexionClient with or
without the entitlement, so ad-hoc signing does not enforce library
validation and CI cannot prove the entitlement is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:07:34 +12:00
ispyisail 4381c6caa3 Read the 3D mouse through 3DxWare on macOS when it is installed
On macOS, 3DxWare installs a driver extension that takes the 3D mouse
over. With it installed, HidBackend opens the device but receives
nothing, so the mouse did nothing in QElectroTech until 3DxWare was
uninstalled (discussion #599, PR #1028). Most Mac owners of a 3D mouse
have 3DxWare installed.

ConnexionBackend asks 3DxWare for the motion instead, through
3DconnexionClient.framework, as Blender does. SpaceMouseListener tries
it first. When 3DxWare is not installed, or is installed but its driver
is not running, it falls back to HidBackend, so the device works in
both setups. Take-over mode stops 3DxWare's own actions in QET, so the
view does not move twice.

The library is loaded at run time from where 3DxWare installs it:
nothing is linked or bundled, and the build needs no SDK. The few
declarations are written here, from Blender's
GHOST_NDOFManagerCocoa.mm, because 3Dconnexion's SDK headers may not be
redistributed. 3DxWare's axes are y up and z away from the user; they
are mapped to QET's raw USB convention by comparing Blender's 3DxWare
and spacenavd code paths.

The release script signs with the hardened runtime, which refuses a
library another team signed. misc/qelectrotech.entitlements adds
com.apple.security.cs.disable-library-validation (Blender's notarized
build carries the same one), and MacQetDeploy_arm64_cmake.sh now passes
it to all four signings of the app, including the re-sign inside the
DMG.

Tested: tst_spacemouseconnexion runs the backend on every platform
against fakeconnexion, a stand-in library that answers from its own
thread as 3DxWare does: registration, the axis mapping, buttons, other
clients' messages, 3DxWare not installed or not running, deletion
with a message in flight. Flipping an axis sign or dropping the client
check turns it red. realLibrary() loads the real framework when
3DxWare is installed. Linux Qt 6 build with the 3D mouse enabled: all
18 tests pass.

Not tested: on a Mac with a real device. The axis signs and whether
buttons arrive as a bitmask with current 3DxWare are unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:54:07 +12:00
ispyisail 149ae961cc Fix bugtracker #343: saving reorders texts and shapes after an edit
Diagram::toXml() writes texts, images, shapes and tables in items()
order. The diagram scene uses NoIndex, and Qt's linear index sorts its
item list by pointer address the first time any item is removed from
the scene, which the editor does constantly (selection handles, for
one). From then on the order in the saved file follows memory
addresses, so moving one element reshuffles unrelated blocks and a
version-control diff of the project becomes unreadable.

Write those blocks in stacking order instead, read from a rect query,
which Qt sorts by z and insertion order even with NoIndex. Reloading a
file rebuilds exactly that order, so a resave is stable and the drawing
does not change. Elements and conductors were already sorted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-25 21:16:34 +12:00
ispyisail 4f309770f0 Fix bugtracker #340: small PDF text drawn too bold in Adobe Acrobat
Qt 6 declares the PDF/X namespace (pdfxid) in the XMP metadata of every
PDF it writes, even when the file is not PDF/X. Adobe Acrobat then draws
small text too bold at some zoom levels. Qt only needs the declaration
for PDF/X-4 output, which QElectroTech never asks for.

Blank the declaration out after export, in place with spaces so no
offsets move, in both the export window and the --export-pdf path.
Diagnosis and the Acrobat testing by Alf, bugtracker #340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-25 20:44:02 +12:00
16 changed files with 878 additions and 34 deletions
+12
View File
@@ -25,6 +25,7 @@ message(" - find_spacemouse")
# spnav Linux, through the spacenavd daemon (libspnav)
# hid any platform, directly over USB (hidapi), no 3Dconnexion driver
# auto spnav on Linux when libspnav is found, hid otherwise
# On macOS the 3DxWare backend is added to whichever of these is chosen.
# A backend whose library is not found downgrades the option to off with a
# warning, rather than failing configure for an opt-in feature.
set(QET_SPACEMOUSE_BACKEND "auto" CACHE STRING "3D mouse backend: auto, spnav or hid")
@@ -33,6 +34,7 @@ set_property(CACHE QET_SPACEMOUSE_BACKEND PROPERTY STRINGS auto spnav hid)
set(QET_SPACEMOUSE_ENABLED FALSE)
set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED FALSE)
set(QET_SPACEMOUSE_BACKEND_HID_ENABLED FALSE)
set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED FALSE)
if(QET_ENABLE_SPACEMOUSE)
find_package(PkgConfig)
@@ -73,6 +75,16 @@ if(QET_ENABLE_SPACEMOUSE)
endif()
endif()
# macOS: with 3DxWare installed the device can only be read through
# 3DxWare, so its backend comes too, whichever backend was asked for.
# It loads 3DxWare's library at run time and needs nothing to build.
if(APPLE)
set(QET_SPACEMOUSE_ENABLED TRUE)
set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED TRUE)
add_definitions(-DQET_SPACEMOUSE_BACKEND_CONNEXION)
message("QET_ENABLE_SPACEMOUSE ON (backend: 3DxWare when installed)")
endif()
if(QET_SPACEMOUSE_ENABLED)
add_definitions(-DQET_SPACEMOUSE_SUPPORT)
else()
+7
View File
@@ -905,6 +905,13 @@ if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
)
endif()
if(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED)
list(APPEND QET_SRC_FILES
${QET_DIR}/sources/spacemouse/connexionbackend.cpp
${QET_DIR}/sources/spacemouse/connexionbackend.h
)
endif()
set(TS_FILES
${QET_DIR}/lang/qet_ar.ts
${QET_DIR}/lang/qet_ca.ts
+5 -1
View File
@@ -398,10 +398,12 @@ done
echo "-- Signing main executable..."
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
"$BUNDLE/Contents/MacOS/$APPNAME"
echo "-- Signing bundle..."
codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$BUNDLE"
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
--entitlements "${current_dir}/misc/qelectrotech.entitlements" "$BUNDLE"
echo
echo "Verifying bundle signature..."
@@ -502,8 +504,10 @@ find "$MOUNT_POINT/$BUNDLE/Contents/PlugIns" \( -name "*.dylib" -o -name "*.so"
codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$lib"
done
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
"$MOUNT_POINT/$BUNDLE/Contents/MacOS/$APPNAME"
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
"$MOUNT_POINT/$BUNDLE"
echo "Verifying bundle signature inside DMG..."
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 3D mouse with 3DxWare installed: lets QElectroTech load
3Dconnexion's client library, which 3Dconnexion signs, at run time
(sources/spacemouse/connexionbackend.h). The hardened runtime
refuses it otherwise, and the 3D mouse does nothing. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+1
View File
@@ -227,6 +227,7 @@ int exportPdf(QETProject &project, const QString &output,
// Rewrite the URI link annotations into native internal GoTo actions, so
// the cross-references jump inside the document in any PDF viewer.
PdfLinks::convertUriToGoTo(output);
PdfLinks::removeUnusedPdfxNamespace(output);
out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n";
return 0;
+52 -30
View File
@@ -42,7 +42,9 @@
#include "qetinformation.h"
#include "qetproject.h"
#include "diagramsortkeys.h"
#include <QTextStream>
#include <algorithm>
#include <climits>
#include <cassert>
#include <math.h>
@@ -99,6 +101,43 @@ namespace {
QString b = terminalSortKey(cond->terminal2);
return (a <= b) ? (a + QLatin1Char('>') + b) : (b + QLatin1Char('>') + a);
}
/// Serialize @p items and append them to a new @p tag block of @p root,
/// in stacking order (@p stack_rank). items() cannot be trusted for
/// this: with NoIndex, the first removeItem() on the scene sorts Qt's
/// item list by pointer address, so from then on items() hands them over
/// in a per-run order (bugtracker #343). Stacking order is what reloading
/// the file rebuilds, so the drawing is unchanged and a resave is stable.
template <typename T>
void appendInStackingOrder(QDomDocument &document, QDomElement &root,
const QString &tag, const QVector<T *> &items,
const QHash<const QGraphicsItem *, int> &stack_rank)
{
if (items.isEmpty())
return;
struct Entry { int rank; QString xml_text; QDomElement xml; };
QVector<Entry> sorted;
for (T *item : items) {
Entry entry{stack_rank.value(item, INT_MAX), QString(),
item->toXml(document)};
// Only an item the stacking query missed needs a tiebreak.
if (entry.rank == INT_MAX) {
QTextStream stream(&entry.xml_text);
entry.xml.save(stream, 0);
}
sorted.append(entry);
}
std::stable_sort(sorted.begin(), sorted.end(),
[](const Entry &a, const Entry &b) {
return a.rank != b.rank ? a.rank < b.rank
: a.xml_text < b.xml_text;
});
auto block = document.createElement(tag);
for (const auto &entry : sorted)
block.appendChild(entry.xml);
root.appendChild(block);
}
}
int Diagram::xGrid = 10;
@@ -1223,37 +1262,20 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) {
dom_root.appendChild(dom_conductors);
}
if (!list_texts.isEmpty()) {
auto dom_texts = document.createElement(QStringLiteral("inputs"));
for (auto dti : list_texts) {
dom_texts.appendChild(dti->toXml(document));
}
dom_root.appendChild(dom_texts);
}
if (!list_images.isEmpty()) {
auto dom_images = document.createElement(QStringLiteral("images"));
for (auto dii : list_images) {
dom_images.appendChild(dii->toXml(document));
}
dom_root.appendChild(dom_images);
}
if (!list_shapes.isEmpty()) {
auto dom_shapes = document.createElement(QStringLiteral("shapes"));
for (auto dii : list_shapes) {
dom_shapes.appendChild(dii -> toXml(document));
}
dom_root.appendChild(dom_shapes);
}
if (table_vector.size()) {
auto tables = document.createElement(QStringLiteral("tables"));
for (auto table : table_vector) {
tables.appendChild(table->toXml(document));
}
dom_root.appendChild(tables);
// A rect query, unlike items(), returns true stacking order (z, then
// insertion order) even with NoIndex.
QHash<const QGraphicsItem *, int> stack_rank;
{
const QList<QGraphicsItem *> stacked = items(
QRectF(-1e9, -1e9, 2e9, 2e9), Qt::IntersectsItemBoundingRect,
Qt::AscendingOrder);
for (int i = 0 ; i < stacked.size() ; ++i)
stack_rank.insert(stacked.at(i), i);
}
appendInStackingOrder(document, dom_root, QStringLiteral("inputs"), list_texts, stack_rank);
appendInStackingOrder(document, dom_root, QStringLiteral("images"), list_images, stack_rank);
appendInStackingOrder(document, dom_root, QStringLiteral("shapes"), list_shapes, stack_rank);
appendInStackingOrder(document, dom_root, QStringLiteral("tables"), table_vector, stack_rank);
if (!strip_vector.isEmpty()) {
dom_root.appendChild(TerminalStripItemXml::toXml(strip_vector, document));
+23
View File
@@ -388,6 +388,29 @@ void convertUriToGoTo(const QString &pdfPath)
out.close();
}
void removeUnusedPdfxNamespace(const QString &pdfPath)
{
QFile f(pdfPath);
if (!f.open(QIODevice::ReadOnly)) return;
QByteArray data = f.readAll();
f.close();
// Qt only fills the pdfxid namespace in when it writes PDF/X-4.
if (data.contains("pdfxid:GTS_PDFXVersion")) return;
static const QByteArray decl =
" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"";
const int pos = data.indexOf(decl);
if (pos == -1) return;
// Same length, so the stream /Length and the xref offsets stay valid.
data.replace(pos, decl.size(), QByteArray(decl.size(), ' '));
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return;
f.write(data);
f.close();
}
void convertComponentInfoAnnotations(const QString &pdfPath,
const QList<ComponentInfo> &annotations)
{
+9
View File
@@ -74,6 +74,15 @@ namespace PdfLinks {
*/
void convertUriToGoTo(const QString &pdfPath);
/**
Post-process a Qt-generated PDF file: blank out the PDF/X namespace
declaration Qt 6 writes into the XMP metadata of every PDF. Adobe
Acrobat draws small text too bold when it is present (bugtracker #340).
Replaced in place with spaces, so no offset changes. No-op for a real
PDF/X file or when the declaration is absent.
*/
void removeUnusedPdfxNamespace(const QString &pdfPath);
struct ComponentInfo {
QString contents;
};
+1
View File
@@ -874,6 +874,7 @@ void ProjectPrintWindow::print()
// Convert URI link annotations into native internal GoTo/FitR
// actions so cross-references jump inside the document.
PdfLinks::convertUriToGoTo(pdfFile);
PdfLinks::removeUnusedPdfxNamespace(pdfFile);
this->close();
});
+252
View File
@@ -0,0 +1,252 @@
/*
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 "connexionbackend.h"
#include <QCoreApplication>
#include <QFileInfo>
#include <QMutex>
#include <QMutexLocker>
#include <cstring>
namespace {
//The part of 3DconnexionClient's interface QET uses, with the
//values Blender uses (intern/ghost/intern/GHOST_NDOFManagerCocoa.mm).
constexpr quint32 MSG_DEVICE_STATE = 0x33645352; // '3dSR'
constexpr quint16 CMD_HANDLE_BUTTONS = 2;
constexpr quint16 CMD_HANDLE_AXIS = 3;
constexpr quint16 MODE_TAKE_OVER = 1;
constexpr quint32 MASK_ALL = 0x3fff;
constexpr quint32 MASK_ALL_BUTTONS = 0xffffffff;
constexpr quint32 SIGNATURE = 0x51456c54; // 'QElT'
//ConnexionDeviceState is packed to 2 bytes. Offsets of the fields read.
constexpr int STATE_CLIENT = 2;
constexpr int STATE_COMMAND = 4;
constexpr int STATE_AXIS = 30;
constexpr int STATE_BUTTONS = 44;
using MessageHandler = void (*)(quint32, quint32, void *);
using DeviceHandler = void (*)(quint32);
using SetConnexionHandlers = qint16 (*)(MessageHandler, DeviceHandler, DeviceHandler, bool);
using RegisterConnexionClient = quint16 (*)(quint32, const quint8 *, quint16, quint32);
using SetConnexionClientButtonMask = void (*)(quint16, quint32);
using CleanupConnexionHandlers = void (*)();
using UnregisterConnexionClient = void (*)(quint16);
//3DxWare's callbacks carry no context, so the one client lives here.
//The mutex is shared with 3DxWare's thread.
QMutex instance_mutex;
ConnexionBackend *instance = nullptr;
void deviceChanged(quint32) {}
}
const char ConnexionBackend::DEFAULT_LIBRARY[] =
"/Library/Frameworks/3DconnexionClient.framework/3DconnexionClient";
/**
@brief ConnexionBackend::ConnexionBackend
Load 3DxWare's client library and register with it. Not installed, or
installed but not running, leaves isAvailable() false: the ordinary
case, never reported as an error.
@param parent
@param library : the client library; only tests pass another
*/
ConnexionBackend::ConnexionBackend(QObject *parent, const QString &library) :
SpaceMouseBackend(parent),
m_library(library)
{
{
QMutexLocker lock(&instance_mutex);
if (instance) {
return; //3DxWare takes one set of handlers per process
}
}
if (!m_library.load()) {
return;
}
const auto set_handlers = reinterpret_cast<SetConnexionHandlers>(
m_library.resolve("SetConnexionHandlers"));
const auto register_client = reinterpret_cast<RegisterConnexionClient>(
m_library.resolve("RegisterConnexionClient"));
const auto button_mask = reinterpret_cast<SetConnexionClientButtonMask>(
m_library.resolve("SetConnexionClientButtonMask"));
m_cleanup = reinterpret_cast<CleanupConnexionHandlers>(
m_library.resolve("CleanupConnexionHandlers"));
m_unregister = reinterpret_cast<UnregisterConnexionClient>(
m_library.resolve("UnregisterConnexionClient"));
if (!set_handlers || !register_client || !m_cleanup || !m_unregister) {
return;
}
{
QMutexLocker lock(&instance_mutex);
instance = this;
}
//Fails while 3DxWare is installed but its driver is not running.
if (set_handlers(&ConnexionBackend::onMessage, deviceChanged, deviceChanged, true) != 0) {
shutdown();
return;
}
m_handlers_installed = true;
//3DxWare only sends a client its messages while that application
//is in front, and recognises it by its executable's name (a Pascal
//string). Take-over mode stops 3DxWare's own actions in QET, so the
//view never moves twice.
QByteArray name = QFileInfo(QCoreApplication::applicationFilePath())
.fileName().toUtf8().left(255);
name.prepend(char(name.size()));
m_client = register_client(SIGNATURE,
reinterpret_cast<const quint8 *>(name.constData()),
MODE_TAKE_OVER, MASK_ALL);
if (!m_client) {
shutdown();
return;
}
if (button_mask) {
button_mask(m_client, MASK_ALL_BUTTONS);
}
}
/**
@brief ConnexionBackend::~ConnexionBackend
*/
ConnexionBackend::~ConnexionBackend()
{
shutdown();
}
/**
@brief ConnexionBackend::sampleFromAxes
3DxWare reports y up and z away from the user; the raw USB reports, and
so every other backend, have y towards the user and z down. Derived from
Blender, whose 3DxWare and spacenavd code paths must agree.
@param axis : TX, TY, TZ, RX, RY, RZ as 3DxWare sends them
@return the same movement in QET's convention
*/
SpaceMouseSample ConnexionBackend::sampleFromAxes(const qint16 axis[6])
{
SpaceMouseSample sample;
sample.x = axis[0];
sample.y = -axis[2];
sample.z = -axis[1];
sample.rx = axis[3];
sample.ry = -axis[5];
sample.rz = -axis[4];
return sample;
}
/**
@brief ConnexionBackend::newlyPressed
@param before : the button bitmask of the previous message
@param now : the button bitmask of this one
@return the 0-based buttons pressed since \a before, as HidBackend numbers them
*/
QList<int> ConnexionBackend::newlyPressed(quint32 before, quint32 now)
{
QList<int> pressed;
const quint32 down = now & ~before;
for (int bit = 0; bit < 32; ++bit) {
if (down & (quint32(1) << bit)) {
pressed.append(bit);
}
}
return pressed;
}
/**
@brief ConnexionBackend::onMessage
Runs on 3DxWare's thread. Copies what matters out of the message and
hands it to the main thread.
@param type : the message type
@param argument : a ConnexionDeviceState for MSG_DEVICE_STATE
*/
void ConnexionBackend::onMessage(quint32, quint32 type, void *argument)
{
if (type != MSG_DEVICE_STATE || !argument) {
return;
}
const char *state = static_cast<const char *>(argument);
quint16 client;
quint16 command;
qint16 axis[6];
quint32 buttons;
std::memcpy(&client, state + STATE_CLIENT, sizeof client);
std::memcpy(&command, state + STATE_COMMAND, sizeof command);
std::memcpy(axis, state + STATE_AXIS, sizeof axis);
std::memcpy(&buttons, state + STATE_BUTTONS, sizeof buttons);
//Queued with the backend as context: if it is deleted before the
//main thread gets to it, Qt drops the call.
QMutexLocker lock(&instance_mutex);
ConnexionBackend *backend = instance;
if (!backend) {
return;
}
QMetaObject::invokeMethod(backend, [=]() {
backend->handleState(client, command, axis, buttons);
}, Qt::QueuedConnection);
}
/**
@brief ConnexionBackend::handleState
On the main thread: turn one device state into signals.
*/
void ConnexionBackend::handleState(quint16 client, quint16 command,
const qint16 axis[6], quint32 buttons)
{
if (client != m_client) {
return; //3DxWare sends every state to every client
}
if (command == CMD_HANDLE_AXIS) {
emit motion(sampleFromAxes(axis));
} else if (command == CMD_HANDLE_BUTTONS) {
//State first: a button can open a dialog whose event loop
//delivers the next message before emit returns.
const QList<int> pressed = newlyPressed(m_buttons, buttons);
m_buttons = buttons;
for (int button : pressed) {
emit buttonPressed(button);
}
}
}
/**
@brief ConnexionBackend::shutdown
Unregister and stop 3DxWare's callbacks. The library stays loaded, since
3DxWare's thread may still be returning from one.
*/
void ConnexionBackend::shutdown()
{
if (m_client && m_unregister) {
m_unregister(m_client);
}
m_client = 0;
if (m_handlers_installed && m_cleanup) {
m_cleanup();
}
m_handlers_installed = false;
QMutexLocker lock(&instance_mutex);
if (instance == this) {
instance = nullptr;
}
}
+81
View File
@@ -0,0 +1,81 @@
/*
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 CONNEXIONBACKEND_H
#define CONNEXIONBACKEND_H
#include "spacemousebackend.h"
#include <QLibrary>
/**
@brief The ConnexionBackend class
SpaceMouseBackend that reads the device through 3Dconnexion's own macOS
driver, 3DxWare, the way Blender does. Only compiled in on macOS
(cmake/find_spacemouse.cmake).
3DxWare installs a driver extension that takes the device over, so
with 3DxWare installed HidBackend can open the device but receives
nothing (discussion #599). This backend asks 3DxWare for the motion
instead. SpaceMouseListener tries it first and falls back to HidBackend
when 3DxWare is not installed or not running, so the device works
either way.
The client library, 3DconnexionClient.framework, is loaded at run time
from where 3DxWare installs it: nothing is linked, bundled or needed to
build. The few declarations below are written here, as Blender does,
rather than taken from 3Dconnexion's SDK, whose headers may not be
redistributed.
3DxWare delivers its messages on a thread of its own; they are passed
to the main thread before any signal is emitted.
*/
class ConnexionBackend : public SpaceMouseBackend
{
Q_OBJECT
public:
static const char DEFAULT_LIBRARY[];
explicit ConnexionBackend(QObject *parent = nullptr,
const QString &library = QString::fromLatin1(DEFAULT_LIBRARY));
~ConnexionBackend() override;
bool isAvailable() const override { return m_client != 0; }
/// 3DxWare's six axes, in its own order and signs, as QET's
/// raw USB convention (+x right, +y towards the user, +z down).
static SpaceMouseSample sampleFromAxes(const qint16 axis[6]);
/// The 0-based buttons set in \a now but not in \a before.
static QList<int> newlyPressed(quint32 before, quint32 now);
private:
static void onMessage(quint32 connection, quint32 type, void *argument);
void handleState(quint16 client, quint16 command,
const qint16 axis[6], quint32 buttons);
void shutdown();
QLibrary m_library;
quint16 m_client = 0;
bool m_handlers_installed = false;
quint32 m_buttons = 0;
void (*m_cleanup)() = nullptr;
void (*m_unregister)(quint16) = nullptr;
};
#endif // CONNEXIONBACKEND_H
+3 -1
View File
@@ -36,7 +36,9 @@
Two implementations, chosen at build time (cmake/find_spacemouse.cmake):
SpnavBackend (Linux, through spacenavd/libspnav) and HidBackend (any
platform, directly over USB through hidapi, with no 3Dconnexion driver
or SDK). Both report the same values for the same movement.
or SDK). On macOS a third, ConnexionBackend, reads through 3DxWare and
is tried first at run time. All report the same values for the same
movement.
*/
class SpaceMouseBackend : public QObject
{
+19 -2
View File
@@ -25,6 +25,9 @@
#ifdef QET_SPACEMOUSE_BACKEND_HID
# include "hidbackend.h"
#endif
#ifdef QET_SPACEMOUSE_BACKEND_CONNEXION
# include "connexionbackend.h"
#endif
#include "../diagramview.h"
#include "../editor/elementview.h"
@@ -48,10 +51,24 @@ SpaceMouseListener::SpaceMouseListener(QObject *parent) :
QObject(parent),
m_settings(SpaceMouseSettings::load())
{
#if defined(QET_SPACEMOUSE_BACKEND_CONNEXION)
//When 3DxWare is installed and running it has the device to
//itself, so ask it first; otherwise read the device directly.
auto *connexion = new ConnexionBackend(this);
if (connexion->isAvailable()) {
m_backend = connexion;
} else {
delete connexion;
}
#endif
#if defined(QET_SPACEMOUSE_BACKEND_SPNAV)
m_backend = new SpnavBackend(this);
if (!m_backend) {
m_backend = new SpnavBackend(this);
}
#elif defined(QET_SPACEMOUSE_BACKEND_HID)
m_backend = new HidBackend(this);
if (!m_backend) {
m_backend = new HidBackend(this);
}
#endif
if (m_backend) {
+17
View File
@@ -203,6 +203,23 @@ add_test(NAME tst_spacemousehid COMMAND tst_spacemousehid)
target_include_directories(tst_spacemousehid PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_spacemousehid PRIVATE Qt::Test Qt::Core)
# ConnexionBackend -- the macOS 3DxWare backend. Built on every platform and
# run against fakeconnexion, a stand-in for 3DxWare's client library that
# exports the same calls and answers from a thread of its own.
add_library(fakeconnexion MODULE fakeconnexion.cpp)
target_link_libraries(fakeconnexion PRIVATE Qt::Core)
add_executable(
tst_spacemouseconnexion
tst_spacemouseconnexion.cpp
${QET_DIR}/sources/spacemouse/connexionbackend.cpp
${QET_DIR}/sources/spacemouse/spacemousebackend.h)
add_dependencies(tst_spacemouseconnexion fakeconnexion)
add_test(NAME tst_spacemouseconnexion COMMAND tst_spacemouseconnexion)
target_compile_definitions(tst_spacemouseconnexion PRIVATE
FAKE_CONNEXION="$<TARGET_FILE:fakeconnexion>")
target_include_directories(tst_spacemouseconnexion PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_spacemouseconnexion 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
+119
View File
@@ -0,0 +1,119 @@
/*
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/>.
*/
/*
A stand-in for 3DxWare's 3DconnexionClient.framework, for
tst_spacemouseconnexion: it exports the calls ConnexionBackend makes,
plus fake*() controls the test uses to script 3DxWare's side. Like the
real driver, it delivers messages on a thread of its own.
*/
#include <QtGlobal>
#include <cstdint>
#include <cstring>
#include <string>
#include <thread>
#define FAKE_EXPORT extern "C" Q_DECL_EXPORT
namespace {
using MessageHandler = void (*)(uint32_t, uint32_t, void *);
using DeviceHandler = void (*)(uint32_t);
MessageHandler handler = nullptr;
int16_t handlers_result = 0;
uint16_t client_result = 7;
std::string registered_name;
uint32_t registered_mode = 0;
uint32_t registered_mask = 0;
uint32_t registered_buttons = 0;
int unregisters = 0;
int cleanups = 0;
}
FAKE_EXPORT int16_t SetConnexionHandlers(MessageHandler message, DeviceHandler, DeviceHandler, bool)
{
if (handlers_result == 0) {
handler = message;
}
return handlers_result;
}
FAKE_EXPORT void CleanupConnexionHandlers()
{
handler = nullptr;
++cleanups;
}
FAKE_EXPORT uint16_t RegisterConnexionClient(uint32_t, const uint8_t *name, uint16_t mode, uint32_t mask)
{
registered_name.assign(reinterpret_cast<const char *>(name) + 1, name[0]);
registered_mode = mode;
registered_mask = mask;
return client_result;
}
FAKE_EXPORT void SetConnexionClientButtonMask(uint16_t, uint32_t mask)
{
registered_buttons = mask;
}
FAKE_EXPORT void UnregisterConnexionClient(uint16_t)
{
++unregisters;
}
/// Reset, and set what SetConnexionHandlers and RegisterConnexionClient return.
FAKE_EXPORT void fakeReset(int16_t handlers, uint16_t client)
{
handler = nullptr;
handlers_result = handlers;
client_result = client;
registered_name.clear();
registered_mode = registered_mask = registered_buttons = 0;
unregisters = cleanups = 0;
}
/// What the backend registered with: name, mode, mask, button mask, and
/// how many times it unregistered and cleaned up.
FAKE_EXPORT const char *fakeRegistration(uint32_t *mode, uint32_t *mask, uint32_t *buttons,
int *unregistered, int *cleaned)
{
*mode = registered_mode;
*mask = registered_mask;
*buttons = registered_buttons;
*unregistered = unregisters;
*cleaned = cleanups;
return registered_name.c_str();
}
/// Send one ConnexionDeviceState (packed to 2 bytes, 48 bytes long) from
/// another thread, as 3DxWare does.
FAKE_EXPORT void fakeSend(uint16_t client, uint16_t command, const int16_t axis[6], uint32_t buttons)
{
unsigned char state[48] = {};
std::memcpy(state + 2, &client, 2);
std::memcpy(state + 4, &command, 2);
std::memcpy(state + 30, axis, 12);
std::memcpy(state + 44, &buttons, 4);
std::thread driver([&state]() {
if (handler) {
handler(0, 0x33645352, state); // '3dSR'
}
});
driver.join();
}
+265
View File
@@ -0,0 +1,265 @@
/*
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/>.
*/
/*
ConnexionBackend, the macOS backend that reads a 3D mouse through
3DxWare. It is tested on every platform against fakeconnexion, which
exports the calls 3DxWare's client library does and sends messages from
its own thread as 3DxWare does. What only a Mac with 3DxWare can check
-- that the real library behaves like the fake -- is not tested here.
*/
#include "spacemouse/connexionbackend.h"
#include <QCoreApplication>
#include <QFileInfo>
#include <QLibrary>
#include <QSignalSpy>
#include <QtTest>
#include <functional>
namespace {
constexpr quint16 CLIENT = 7;
constexpr quint16 AXIS = 3;
constexpr quint16 BUTTONS = 2;
using Reset = void (*)(qint16, quint16);
using Registration = const char *(*)(quint32 *, quint32 *, quint32 *, int *, int *);
using Send = void (*)(quint16, quint16, const qint16 *, quint32);
}
class TstSpaceMouseConnexion : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void init();
void noLibrary();
void driverNotRunning();
void registration();
void motion();
void otherClients();
void buttons();
void deletedBeforeDelivery();
void oneClientPerProcess();
void realLibrary();
private:
QList<SpaceMouseSample> collect(ConnexionBackend &backend,
const std::function<void()> &send);
QLibrary m_fake{QStringLiteral(FAKE_CONNEXION)};
Reset m_reset = nullptr;
Registration m_registration = nullptr;
Send m_send = nullptr;
};
/*
A fake that fails to load fails every test that uses it, in init(),
rather than here: realLibrary() does not use it, and CI runs it alone
under code-signing settings that may refuse the fake too.
*/
void TstSpaceMouseConnexion::initTestCase()
{
if (m_fake.load()) {
m_reset = reinterpret_cast<Reset>(m_fake.resolve("fakeReset"));
m_registration = reinterpret_cast<Registration>(m_fake.resolve("fakeRegistration"));
m_send = reinterpret_cast<Send>(m_fake.resolve("fakeSend"));
}
}
void TstSpaceMouseConnexion::init()
{
if (qstrcmp(QTest::currentTestFunction(), "realLibrary") == 0) {
return;
}
QVERIFY2(m_reset && m_registration && m_send, qPrintable(m_fake.errorString()));
m_reset(0, CLIENT);
}
QList<SpaceMouseSample> TstSpaceMouseConnexion::collect(
ConnexionBackend &backend, const std::function<void()> &send)
{
QList<SpaceMouseSample> samples;
auto c = connect(&backend, &SpaceMouseBackend::motion,
[&samples](const SpaceMouseSample &s) { samples.append(s); });
send();
QCoreApplication::processEvents();
disconnect(c);
return samples;
}
/// 3DxWare not installed: the ordinary case.
void TstSpaceMouseConnexion::noLibrary()
{
ConnexionBackend backend(nullptr, QStringLiteral("/nonexistent/3DconnexionClient"));
QVERIFY(!backend.isAvailable());
}
/// Installed but its driver is not running: SetConnexionHandlers fails, and
/// nothing is left registered for 3DxWare to call.
void TstSpaceMouseConnexion::driverNotRunning()
{
m_reset(-36, CLIENT);
{
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
QVERIFY(!backend.isAvailable());
}
quint32 mode, mask, buttons;
int unregistered, cleaned;
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
QCOMPARE(unregistered, 0);
QCOMPARE(cleaned, 0);
//And a registration refused (client 0) leaves nothing either.
m_reset(0, 0);
{
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
QVERIFY(!backend.isAvailable());
}
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
QCOMPARE(cleaned, 1);
}
/// Registers by executable name, in take-over mode, for everything, and
/// undoes it all on destruction.
void TstSpaceMouseConnexion::registration()
{
quint32 mode, mask, buttons;
int unregistered, cleaned;
{
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
QVERIFY(backend.isAvailable());
const QString name = QString::fromUtf8(
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned));
QCOMPARE(name, QFileInfo(QCoreApplication::applicationFilePath()).fileName());
QCOMPARE(mode, 1u);
QCOMPARE(mask, 0x3fffu);
QCOMPARE(buttons, 0xffffffffu);
QCOMPARE(unregistered, 0);
}
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
QCOMPARE(unregistered, 1);
QCOMPARE(cleaned, 1);
}
/// Motion arrives from 3DxWare's thread and is emitted on this one, in
/// QET's axis convention.
void TstSpaceMouseConnexion::motion()
{
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
QVERIFY(backend.isAvailable());
QThread *emitted_on = nullptr;
connect(&backend, &SpaceMouseBackend::motion,
[&emitted_on]() { emitted_on = QThread::currentThread(); });
const qint16 axis[6] = {10, 20, 30, 40, 50, 60};
const QList<SpaceMouseSample> samples = collect(backend, [&]() {
m_send(CLIENT, AXIS, axis, 0);
});
QCOMPARE(samples.size(), 1);
QCOMPARE(emitted_on, QThread::currentThread());
const SpaceMouseSample &s = samples.first();
QCOMPARE(s.x, 10);
QCOMPARE(s.y, -30);
QCOMPARE(s.z, -20);
QCOMPARE(s.rx, 40);
QCOMPARE(s.ry, -60);
QCOMPARE(s.rz, -50);
}
/// 3DxWare sends every state to every client; only ours counts.
void TstSpaceMouseConnexion::otherClients()
{
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
const qint16 axis[6] = {1, 2, 3, 4, 5, 6};
QCOMPARE(collect(backend, [&]() { m_send(CLIENT + 1, AXIS, axis, 0); }).size(), 0);
}
/// A press is a bit set that was clear; 0-based, as HidBackend numbers them.
void TstSpaceMouseConnexion::buttons()
{
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
QSignalSpy spy(&backend, &SpaceMouseBackend::buttonPressed);
const qint16 still[6] = {};
m_send(CLIENT, BUTTONS, still, 0b101);
QCoreApplication::processEvents();
QCOMPARE(spy.size(), 2);
QCOMPARE(spy.at(0).at(0).toInt(), 0);
QCOMPARE(spy.at(1).at(0).toInt(), 2);
m_send(CLIENT, BUTTONS, still, 0b111);
m_send(CLIENT, BUTTONS, still, 0);
m_send(CLIENT, BUTTONS, still, 0b100);
QCoreApplication::processEvents();
QCOMPARE(spy.size(), 4);
QCOMPARE(spy.at(2).at(0).toInt(), 1);
QCOMPARE(spy.at(3).at(0).toInt(), 2);
QCOMPARE(ConnexionBackend::newlyPressed(0, 0x80000000u), QList<int>{31});
QVERIFY(ConnexionBackend::newlyPressed(0b11, 0b01).isEmpty());
}
/// A message queued for a backend deleted before the main thread reads it
/// is dropped, not delivered to freed memory.
void TstSpaceMouseConnexion::deletedBeforeDelivery()
{
auto *backend = new ConnexionBackend(nullptr, QStringLiteral(FAKE_CONNEXION));
const qint16 axis[6] = {1, 2, 3, 4, 5, 6};
m_send(CLIENT, AXIS, axis, 0);
delete backend;
QCoreApplication::processEvents();
//And 3DxWare calling after shutdown finds no one to call.
m_send(CLIENT, AXIS, axis, 0);
QCoreApplication::processEvents();
}
/// 3DxWare takes one set of handlers per process.
void TstSpaceMouseConnexion::oneClientPerProcess()
{
ConnexionBackend first(nullptr, QStringLiteral(FAKE_CONNEXION));
ConnexionBackend second(nullptr, QStringLiteral(FAKE_CONNEXION));
QVERIFY(first.isAvailable());
QVERIFY(!second.isAvailable());
const qint16 axis[6] = {5, 0, 0, 0, 0, 0};
QCOMPARE(collect(first, [&]() { m_send(CLIENT, AXIS, axis, 0); }).size(), 1);
}
/// On a Mac with 3DxWare installed: this process can load 3DxWare's real
/// library. A Developer ID build with the hardened runtime needs
/// misc/qelectrotech.entitlements for that; an ad-hoc signature does not
/// enforce library validation, so CI cannot show the difference. Whether
/// 3DxWare's driver then answers depends on the machine, so it is not checked.
void TstSpaceMouseConnexion::realLibrary()
{
QLibrary library(QString::fromLatin1(ConnexionBackend::DEFAULT_LIBRARY));
if (!QFileInfo::exists(library.fileName())) {
QSKIP("3DxWare is not installed");
}
QVERIFY2(library.load(), qPrintable(library.errorString()));
QVERIFY(library.resolve("SetConnexionHandlers"));
ConnexionBackend backend;
qInfo("3DxWare's driver %s", backend.isAvailable() ? "answered" : "did not answer");
}
QTEST_GUILESS_MAIN(TstSpaceMouseConnexion)
#include "tst_spacemouseconnexion.moc"