Compare commits

..

23 Commits

Author SHA1 Message Date
ispyisail b61193f66f Revert "german translation qm file" 2026-09-26 17:50:02 +12:00
ispyisail 4cffdf93b6 Merge pull request #1037 from Kellermorph/german-translation
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m15s
german translation qm file
2026-09-26 08:28:04 +12:00
Kellermorph 286002fe25 german translation qm file 2026-09-25 21:48:49 +02:00
ispyisail 5f0b015186 Merge pull request #1031 from ispyisail/fix/bugtracker-240-backup-crash
Fix bugtracker #240: crash when a recovery file cannot be opened
2026-09-26 07:13:40 +12:00
ispyisail e5738c5b7d Merge pull request #1024 from arummler/fix-picture-insert
Fix picture and graph primitives issues
2026-09-26 07:13:20 +12:00
ispyisail c3c264cd78 Merge pull request #1010 from Kellermorph/full-contact-comb
Full contact comb
2026-09-26 07:12:57 +12:00
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
Andre Rummler 71782aec20 Fix wheel scale hardening 2026-09-25 15:09:21 +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
Andre Rummler 1330dbb435 Fix janking behaviour during resize and Ctrl action. 2026-09-25 13:37:49 +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 07d7aedc26 Fix bugtracker #240: crash when a recovery file cannot be opened
After a crash, QElectroTech offers to reopen its recovery files. If one
cannot be read, answering OK crashed the program instead of showing the
"could not open" warning: QETProject(KAutoSaveFile *) takes ownership of
the file and deletes it on failure, and openBackupFiles() then read the
file name from the deleted object to build the warning. Read the name
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-25 21:45:07 +12:00
Kellermorph 5622308ba2 Derive empty-slot labels from the group's declared terminals
Both issues from the review, preview of empty contact comb slots only:

- Slice the flat label list over the declared poles in drawAsContacts
  (terminals per pole from labels.size()/contactCount) instead of a
  fixed stride of 2 (3 for a switch) inside drawContact: terminalCount
  and contactCount are edited independently, so with 3 poles and the
  default terminal count of 2 the fixed stride starved every pole but
  the first. Available labels are now distributed across the poles.
- Map changeover labels of a slot to their own position always
  (common=stored[0] right, NC=stored[1] bottom-left, NO=stored[2]
  top-left, missing entries stay empty) instead of falling back to the
  raw stored order, which put the numbers on the wrong contact halves
  when the terminal count was below three.
- Element editor: changing the contact count now keeps the terminal
  count in step (same terminals per contact, type default 2/3 for
  inconsistent data, minimum 3 for a switch), so the mismatch cannot
  be created anymore; legacy mismatched data is handled by the new
  slicing.
- Drop the now redundant elmt check before is_power_ctc (it already
  includes it) - the dead null check from the review.
2026-09-25 11:24:35 +02: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
Andre Rummler bb037ee3ff Fix: when pasting the picture or graphics object is nowunder the top left corner instead far away. 2026-09-25 00:24:15 +02:00
Andre Rummler d1256872c2 Fix transformation order. 2026-09-24 15:21:42 +02:00
Kellermorph 800189debb Address review: restore hover entries for empty position text
- Call updateLabel() explicitly when the xref is created in itemChange for a master that must show its configured contact groups without slaves (same pattern as the PLC branch above).
- Register the hover/click hit rect of a contact independently of its position text again, as before this feature: only the drawing stays guarded by !str.isEmpty(), and the map insert is now keyed on elmt so free slots (nullptr) never enter the map.
- Revert is_power_ctc to the original element-type test (with a null guard): the Power-flag term was redundant for every caller that passes an element, so no linked contact changes classification.
- Clarify the label-order comment: single pole NO/NC are swapped, changeover labels are rotated per pole inside drawContact() (multi pole included), multi pole NO/NC groups keep the master order.
2026-09-24 15:11:31 +02:00
Andre Rummler 522e008c26 Fix rotation of inserted picture. 2026-09-24 13:37:06 +02:00
Kellermorph c1cc9a5b98 Show all master-defined slaves in the contact comb behind a new option
Add a new cross reference setting per xref type (coil, protection,
commutator, PLC), labelled "Afficher tous les esclaves definis par le
maitre" and persisted as showallconfiguredslaves. When it is enabled,
the contacts display is selected and the master declares contact
groups, the contact comb draws every contact group of the master in the
master's own order, even when no slave is linked to it yet. Masters
without declared contact groups and the option turned off keep the
previous behaviour exactly: linked slaves only, sorted by position.

- XRefProperties: new property stored in the settings and in the
  project XML (attribute showallconfiguredslaves, absent means false so
  old files are unaffected), included in operator==.
- XRefPropertiesWidget: new checkbox placed after the terminal names
  one, enabled only while "Afficher en contacts" is selected; its
  enabled state is now also set explicitly when a type is loaded (a
  radio button that does not change emits no toggled()).
- For the PLC type the contacts/cross radios, the two display
  checkboxes and the cross options group are hidden: a PLC master is
  always drawn as its IO table, those settings have no effect there.
  Positioning and label settings, which the table really uses, stay.
- CrossRefItem: free slots draw the symbol of the group plus the
  terminal names the master defines (pairs swapped for a single pole
  NO/NC contact, labels of a changeover contact rotated one step
  counter-clockwise), without position text and without hover/click.
  Linked slaves keep drawing from their own data at their assigned
  group position; links without a group are appended at the end in
  position order.
- Xref lifecycle: the item is created and kept without linked slaves
  for snap-to-bottom (MasterElement::mustShowXrefWithoutSlave) and for
  snap-to-label (DynamicElementTextItem::updateXref and
  ElementTextItemGroup::updateXref, which now also run when the element
  lands on the scene and re-establish their project connection), so a
  freshly placed master shows its comb immediately instead of only
  after the next settings change. updateLabel() resets its geometry
  when the option is turned off again, so no stale ghost stays.
2026-09-24 09:59:21 +02:00
38 changed files with 1518 additions and 165 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));
+69 -6
View File
@@ -117,7 +117,16 @@ void DiagramEventAddImage::mousePressEvent(QGraphicsSceneMouseEvent *event)
}
else if (m_image && !m_pressed && event->button() == Qt::RightButton)
{
m_image->setRotation(m_image->rotation() + 90);
// rotationAngle()/setRotationAngle(), not QGraphicsItem's own
// rotation()/setRotation(): DiagramImageItem's whole handle/undo/
// XML-save machinery reads exclusively from its own m_transform
// (see diagramimageitem.cpp's toXml() comment) and never looks at
// QGraphicsItem's built-in convenience property at all -- using
// it here left a rotation that displayed correctly in this tool
// but silently vanished the moment the item was saved and
// reloaded, and additionally desynced the rotate-handle's pivot
// math once the image was later selected for editing.
m_image->setRotationAngle(m_image->rotationAngle() + 90);
event->setAccepted(true);
}
}
@@ -167,6 +176,22 @@ void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
}
else
{
// setPivot(QPointF(0, 0)), not left at its default
// boundingRect().center(): DiagramImageItem's own m_transform
// is pivot-centered, so with the default center pivot the
// setPos(qMin(...)) below would no longer land on the image's
// actual top-left corner the moment scale != 1 (the corner
// only coincides with pos() when the pivot is the local
// origin). Anchoring here to (0, 0) -- exactly the same
// temporary-anchor trick DiagramImageItem::handlerMousePressEvent()
// already uses for its own Resize handles -- makes pos()
// keep meaning "scene position of the top-left corner"
// regardless of scale, so the qMin(...) line below still
// needs no change at all. setPivot() itself is a no-op past
// the first call (same pivot value), and compensates pos()
// automatically so nothing visibly jumps at the switch.
m_image->setPivot(QPointF(0, 0));
const QSizeF naturalSize = m_image->boundingRect().size();
if (naturalSize.width() > 0 && naturalSize.height() > 0)
{
@@ -179,7 +204,12 @@ void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
// free-form one -- breaking aspect ratio on purpose is
// its own, separate, larger piece of work.
const qreal newScale = qBound(0.01, qMax(scaleX, scaleY), 50.0);
m_image->setScale(newScale);
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own
// scale(): see the mousePressEvent right-click rotate
// comment above for why -- identical reasoning, identical
// fix.
m_image->setScaleFactorX(newScale);
m_image->setScaleFactorY(newScale);
}
m_image->setPos(qMin(m_press_pos.x(), pos.x()), qMin(m_press_pos.y(), pos.y()));
}
@@ -206,6 +236,17 @@ void DiagramEventAddImage::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
if (m_image && m_pressed && event->button() == Qt::LeftButton)
{
// Undo whatever temporary origin-anchoring mouseMoveEvent's
// resize-drag branch did (a no-op if it never engaged): every
// other DiagramImageItem code path -- handles, undo, XML save --
// expects an image's pivot to be its bounding-rect center unless
// the user deliberately customized it, exactly like a completed
// handle-resize already resets it via resetPivotToBoundingRectCenter().
// Doing this before reading pos() below is what makes the pushed
// command capture the final, center-pivot position rather than
// the drag's temporary corner-anchored one.
m_image->setPivot(m_image->boundingRect().center());
m_diagram->undoStack().push(new AddGraphicsObjectCommand(m_image, m_diagram, m_image->pos()));
for (QGraphicsView *view : m_diagram->views()) {
@@ -246,10 +287,32 @@ void DiagramEventAddImage::wheelEvent(QGraphicsSceneWheelEvent *event)
return;
}
qreal scaling = m_image->scale();
event->delta() > 1? scaling += 0.01 : scaling -= 0.01;
if (scaling>0.01 && scaling <= 2) {
m_image->setScale(scaling);
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own scale(): see
// the right-click rotate comment in mousePressEvent for why. Wheel-
// scaling only ever runs while !m_pressed (guarded above), i.e. before
// any drag-resize has anchored the pivot to the origin (see
// mouseMoveEvent), so the pivot here is still the default
// boundingRect().center() and this scales the image in place around
// its own middle, exactly like before.
//
// Step each axis from its own current value rather than reading X and
// writing it back to both: scaleFactorX and scaleFactorY cannot
// actually differ at this point today (every other mutator in this
// class -- the drag-resize branch above, and this same wheelEvent --
// only ever sets them to the same value, and mouseReleaseEvent commits
// and ends this tool on any left-button release, so a handle-based
// non-uniform resize can never happen first and leave this instance
// still alive). Stepping both from their own value rather than
// collapsing Y to X costs nothing today and removes the trap if that
// invariant ever stops holding.
qreal scalingX = m_image->scaleFactorX();
qreal scalingY = m_image->scaleFactorY();
const qreal step = event->delta() > 1 ? 0.01 : -0.01;
scalingX += step;
scalingY += step;
if (scalingX > 0.01 && scalingX <= 2 && scalingY > 0.01 && scalingY <= 2) {
m_image->setScaleFactorX(scalingX);
m_image->setScaleFactorY(scalingY);
}
event->setAccepted(true);
+35 -24
View File
@@ -41,7 +41,6 @@
DiagramEventAddPaste::DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos) :
DiagramEventInterface(diagram)
{
Q_UNUSED(start_pos); // items stay at their original XML position
//DiagramEventInterface::init() is called by Diagram::setEventInterface
//only when it is replacing an earlier interface, so call it here as
//DiagramEventAddMacro does.
@@ -77,21 +76,26 @@
const QList<QGraphicsItem *> movable = m_content.items(MovableItems);
if (movable.isEmpty()) return;
//Compute the top-left of all items' positions (not bounding
//rects) and snap to grid: this is the point that gets placed
//under the cursor, and the baseline moveTo() measures from.
QPointF top_left;
bool first = true;
//Compute the top-left of all items' actual on-screen bounding
//boxes (not their raw pos()) and snap to grid: this is the point
//that gets placed under the cursor, and the baseline moveTo()
//measures from. mapToScene(boundingRect()) matters here, not
//pos() alone: pos() is the scene location of an item's local
//origin, but for anything with a pivot-centered transform (a
//scaled or rotated image, in particular) that origin can sit far
//from where the item is actually drawn -- pivot + scale*(0 -
//pivot) is nowhere near (0, 0) once scale is well under 1. Using
//pos() here silently pasted content at the right *delta* from a
//point that wasn't actually where the content visually was,
//producing a constant, scale-dependent offset between the cursor
//and the pasted picture. Diagram::fromXml()'s own position
//parameter already gets this right the same way, for the same
//reason.
QRectF items_rect;
for (auto *item : movable) {
const QPointF p = item->pos();
if (first) {
top_left = p;
first = false;
} else {
if (p.x() < top_left.x()) top_left.setX(p.x());
if (p.y() < top_left.y()) top_left.setY(p.y());
}
items_rect = items_rect.united(item->mapToScene(item->boundingRect()).boundingRect());
}
const QPointF top_left = items_rect.topLeft();
QSettings settings;
const int xGrid = settings.value(QStringLiteral("diagrameditor/Xgrid"),
Diagram::xGrid).toInt();
@@ -102,9 +106,24 @@
qRound(p.x() / xGrid) * xGrid,
qRound(p.y() / yGrid) * yGrid);
};
const QPointF grid_origin = snapGrid(top_left);
const QPointF grid_origin = snapGrid(start_pos);
//Store each item's original position. moveTo() applies a
//Land the pasted content under the cursor immediately, rather than
//leaving it at the copied source's own coordinates: fromXml() above
//loads items at their original position purely because it doesn't
//know the target yet, not because that is where a paste should end
//up. The previous approach instead left items there and warped the
//OS cursor to match -- QCursor::setPos() is silently ignored by
//many window managers and compositors (Wayland in particular), so
//on any of those the warp simply never happened and the paste was
//left wherever it had originally been copied from, which could be
//anywhere on the folio -- exactly the "far from the cursor" bug.
const QPointF initial_delta = grid_origin - snapGrid(top_left);
for (auto *item : movable) {
item->setPos(item->pos() + initial_delta);
}
//Store each item's now-placed position. moveTo() applies a
//grid-snapped delta from the baseline to these, so items
//preserve their layout and move in whole grid steps.
for (auto *item : movable) {
@@ -134,14 +153,6 @@
if (const auto qde = QETApp::diagramEditorAncestorOf(view)) {
m_status_bar = qde->statusBar();
}
//Warp the cursor to the group's grid-snapped origin so
//the actual cursor position matches m_initial_cursor.
//Without this the first mouseMoveEvent computes a large
//delta (cursor is still at the Ctrl+V press location)
//and the items jump on first touch.
const QPoint view_pos = view->mapFromScene(m_initial_cursor);
const QPoint global_pos = view->viewport()->mapToGlobal(view_pos);
QCursor::setPos(global_pos);
}
}
showHint();
+30 -6
View File
@@ -84,7 +84,10 @@ void DiagramEventAddPdf::mousePressEvent(QGraphicsSceneMouseEvent *event)
}
else if (m_image && event->button() == Qt::RightButton)
{
m_image->setRotation(m_image->rotation() + 90);
// rotationAngle()/setRotationAngle(), not QGraphicsItem's own
// rotation()/setRotation(): see DiagramEventAddImage's identical
// fix (mousePressEvent) for why -- same class, same reasoning.
m_image->setRotationAngle(m_image->rotationAngle() + 90);
event->setAccepted(true);
}
}
@@ -132,14 +135,35 @@ void DiagramEventAddPdf::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
*/
void DiagramEventAddPdf::wheelEvent(QGraphicsSceneWheelEvent *event)
{
if (!m_is_added || !m_image || event->modifiers() != Qt::CTRL) {
// event->modifiers() & Qt::ControlModifier, not != Qt::CTRL: the same
// exact-equality bug already found and fixed elsewhere this session --
// Ctrl held together with any other modifier would silently fail to
// register as Ctrl at all.
if (!m_is_added || !m_image || !(event->modifiers() & Qt::ControlModifier)) {
return;
}
qreal scaling = m_image->scale();
event->delta() > 1 ? scaling += 0.01 : scaling -= 0.01;
if (scaling > 0.01 && scaling <= 2) {
m_image->setScale(scaling);
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own scale(): see
// DiagramEventAddImage's identical fix for why. No drag-to-resize
// exists here, and the pivot is never touched elsewhere in this
// class, so it stays at its default boundingRect().center() and this
// scales the page in place around its own middle, exactly like
// before.
//
// Step each axis from its own current value rather than reading X and
// writing it back to both: scaleFactorX and scaleFactorY cannot
// actually differ here today (nothing in this class ever sets them to
// different values, and there is no drag-resize at all), but stepping
// both independently costs nothing and removes the trap if that ever
// changes.
qreal scalingX = m_image->scaleFactorX();
qreal scalingY = m_image->scaleFactorY();
const qreal step = event->delta() > 1 ? 0.01 : -0.01;
scalingX += step;
scalingY += step;
if (scalingX > 0.01 && scalingX <= 2 && scalingY > 0.01 && scalingY <= 2) {
m_image->setScaleFactorX(scalingX);
m_image->setScaleFactorY(scalingY);
}
event->setAccepted(true);
+25 -21
View File
@@ -87,9 +87,9 @@ DiagramEventAddShape::~DiagramEventAddShape()
Applies a drag/click position to the in-progress shape, honouring two
modifiers that mirror how the very same shape can already be edited
afterward, once placed:
- Ctrl, for Rectangle/Ellipse only: the first click becomes the
shape's *center* rather than a corner, growing symmetrically as
the cursor moves away from it -- the same meaning Ctrl already
- Ctrl, for Rectangle/Ellipse only: the first click's point acts as
the shape's *center* rather than a corner, growing symmetrically
as the cursor moves away from it -- the same meaning Ctrl already
has on a Resize handle (anchor at center). Deliberately not
offered for Line: unlike the Rectangle/Ellipse case, there's no
established convention for "a line grows symmetrically from its
@@ -100,12 +100,18 @@ DiagramEventAddShape::~DiagramEventAddShape()
dragged dimensions is currently larger and mirroring that onto
the other, preserving the direction the user is actually
dragging in.
Both can combine (Ctrl+Shift: a centered square/circle). Whether or
not Ctrl is currently held, the non-anchored branch always rebuilds
from m_anchor_point rather than nudging the existing rect/line --
otherwise, if Ctrl had been held earlier in the same drag (moving the
shape's own first point to a mirrored position), releasing it would
leave that point stuck there instead of actually restoring it.
Both can combine (Ctrl+Shift: a centered square/circle). Whether Ctrl
currently anchors from the center is re-decided on every call, from
the live modifiers passed in here -- not frozen at whatever was held
on the first click. Every comparable tool (Illustrator, Photoshop,
Figma, Inkscape...) lets you press or release the center-origin
modifier at any point mid-drag, with the shape immediately jumping to
match; that jump is the expected feedback for changing which point is
anchored, not a glitch. Freezing the choice at the first click instead
meant holding Ctrl anywhere other than the initial mouse-down did
nothing visible -- which, tried the more natural way (drag first,
then reach for Ctrl once you decide you want it centered), read as
"Ctrl doesn't work" rather than as a deliberate one-shot decision.
*/
void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifiers mods)
{
@@ -125,14 +131,13 @@ void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifie
return;
}
// m_center_anchored is decided once, in mousePressEvent, not
// re-checked here on every call -- re-checking it live meant
// releasing Ctrl mid-drag (something you'd naturally do the moment
// your hand gets tired holding it, long before you're done resizing)
// silently snapped the shape back to corner-anchored, discarding
// what felt like an already-made decision. Deciding it once at the
// first click matches "I held Ctrl when I clicked, so this shape is
// centered" -- a single, predictable rule instead of a live toggle.
m_center_anchored = (mods & Qt::ControlModifier)
&& (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse);
if (m_center_anchored)
showCenterMarker(m_anchor_point);
else
hideCenterMarker();
QPointF target = pos;
if ((mods & Qt::ShiftModifier)
@@ -218,10 +223,9 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
m_shape_item = new QetShapeItem(pos, pos, m_shape_type);
m_anchor_point = pos;
// Decided once, here, rather than re-checked on every mouse
// move for the rest of the drag -- see applyPosition()'s doc
// comment for why continuous re-checking made releasing Ctrl
// mid-drag feel like a bug rather than a deliberate choice.
// Initial feedback only -- applyPosition() re-decides this
// live on every subsequent move, from whatever Ctrl state is
// held at the time.
m_center_anchored = (event->modifiers() & Qt::ControlModifier)
&& (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse);
if (m_center_anchored)
+1 -1
View File
@@ -59,7 +59,7 @@ class DiagramEventAddShape : public DiagramEventInterface
QGraphicsLineItem *m_help_horiz, *m_help_verti;
QPointF m_anchor_point; // the shape's first-click point -- meaningful once m_shape_item exists
QGraphicsEllipseItem *m_center_marker = nullptr; // shown only while Ctrl-anchoring is actually in effect, so it doubles as confirmation that it is
bool m_center_anchored = false; // decided once, at the first click -- see applyPosition()'s doc comment for why
bool m_center_anchored = false; // re-decided live on every applyPosition() call, from current Ctrl state
QPointF m_last_mouse_scene_pos; // raw, unsnapped -- lets a modifier-only change re-snap correctly when reapplied
};
+1
View File
@@ -584,6 +584,7 @@ void DiagramView::mousePressEvent(QMouseEvent *e)
*/
void DiagramView::mouseMoveEvent(QMouseEvent *e)
{
m_last_mouse_pos = e->pos();
setToolTip(tr("X: %1 Y: %2").arg(e->pos().x()).arg(e->pos().y()));
if (m_event_interface && m_event_interface->mouseMoveEvent(e)) return;
+7
View File
@@ -55,6 +55,7 @@ class DiagramView : public PaletteGraphicsView
QAction *m_multi_paste = nullptr;
QAction *m_create_template = nullptr;
QPoint m_paste_here_pos;
QPoint m_last_mouse_pos = QPoint(-1, -1);
QPointF m_drag_last_pos;
bool m_fresh_focus_in,
m_first_activation = true;
@@ -71,6 +72,12 @@ class DiagramView : public PaletteGraphicsView
void editSelection();
void setEventInterface (DVEventInterface *event_interface);
QList<QAction *> contextMenuActions() const;
/// Last mouse position seen by mouseMoveEvent(), in viewport
/// coordinates -- (-1, -1) if the mouse hasn't moved over this
/// view yet. Filled from ordinary Qt mouse events, not a global
/// cursor query (QCursor::pos()/setPos() are silently ignored by
/// several window managers and compositors, Wayland included).
QPoint lastMousePos() const { return m_last_mouse_pos; }
protected:
void mouseDoubleClickEvent(QMouseEvent *) override;
@@ -589,6 +589,29 @@ void ElementPropertiesEditorWidget::populateSlaveGroupsTable()
contact_ct->setValue(group.contactCount);
ui->m_slave_groups_table->setCellWidget(i, 2, contact_ct);
// When the contact count changes, keep the terminal count in step
// with it, otherwise the two drift apart (both are edited
// independently): the stored terminals-per-contact ratio is kept,
// or the contact type default (2, 3 for a switch) is used when the
// stored values don't divide evenly (inconsistent legacy data).
const int old_contacts = group.contactCount;
const int old_terminals = group.terminalCount;
connect(contact_ct, QOverload<int>::of(&QSpinBox::valueChanged),
this, [this, i, old_contacts, old_terminals](int val) {
if (i < m_data.m_slave_contact_groups.size()) {
readSlaveGroupsFromTable();
auto &group = m_data.m_slave_contact_groups[i];
int per_pole = old_terminals / qMax(1, old_contacts);
if (per_pole < 1 || old_terminals % qMax(1, old_contacts) != 0)
per_pole = group.type == ElementData::SW ? 3 : 2;
else if (group.type == ElementData::SW && per_pole < 3)
per_pole = 3; //a switch needs common, NC and NO
group.contactCount = val;
group.terminalCount = val * per_pole;
populateSlaveGroupsTable();
}
});
// Terminal count
auto *terminal_ct = new QSpinBox(ui->m_slave_groups_table);
terminal_ct->setMinimum(1);
+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();
});
+6
View File
@@ -30,6 +30,7 @@ XRefProperties::XRefProperties()
{
m_show_power_ctc = true;
m_show_terminal_name = true;
m_show_all_configured_slaves = false;
m_display = Cross;
m_snap_to = Bottom;
m_prefix_keys << "power" << "delay" << "switch";
@@ -51,6 +52,7 @@ void XRefProperties::toSettings(QSettings &settings,
{
settings.setValue(prefix % "showpowerctc", m_show_power_ctc);
settings.setValue(prefix % "showterminalname", m_show_terminal_name);
settings.setValue(prefix % "showallconfiguredslaves", m_show_all_configured_slaves);
QString display = m_display == Cross? "cross" : "contacts";
settings.setValue(prefix % "displayhas", display);
QString snap = m_snap_to == Bottom? "bottom" : "label";
@@ -84,6 +86,7 @@ void XRefProperties::fromSettings(const QSettings &settings,
{
m_show_power_ctc = settings.value(prefix % "showpowerctc", true).toBool();
m_show_terminal_name = settings.value(prefix % "showterminalname", true).toBool();
m_show_all_configured_slaves = settings.value(prefix % "showallconfiguredslaves", false).toBool();
QString display = settings.value(prefix % "displayhas", "cross").toString();
display == "cross"? m_display = Cross : m_display = Contacts;
QString snap = settings.value(prefix % "snapto", "label").toString();
@@ -115,6 +118,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const
xml_element.setAttribute("showpowerctc", m_show_power_ctc? "true" : "false");
xml_element.setAttribute("showterminalname", m_show_terminal_name? "true" : "false");
xml_element.setAttribute("showallconfiguredslaves", m_show_all_configured_slaves? "true" : "false");
QString display = m_display == Cross? "cross" : "contacts";
xml_element.setAttribute("displayhas", display);
QString snap = m_snap_to == Bottom? "bottom" : "label";
@@ -147,6 +151,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const
bool XRefProperties::fromXml(const QDomElement &xml_element) {
m_show_power_ctc = xml_element.attribute("showpowerctc") == "true";
m_show_terminal_name = xml_element.attribute("showterminalname", "true") == "true";
m_show_all_configured_slaves = xml_element.attribute("showallconfiguredslaves", "false") == "true";
QString display = xml_element.attribute("displayhas", "cross");
display == "cross"? m_display = Cross : m_display = Contacts;
QString snap = xml_element.attribute("snapto", "label");
@@ -200,6 +205,7 @@ QHash<QString, XRefProperties> XRefProperties::defaultProperties()
bool XRefProperties::operator ==(const XRefProperties &xrp) const{
return (m_show_power_ctc == xrp.m_show_power_ctc
&& m_show_terminal_name == xrp.m_show_terminal_name
&& m_show_all_configured_slaves == xrp.m_show_all_configured_slaves
&& m_display == xrp.m_display
&& m_snap_to == xrp.m_snap_to
&& m_prefix == xrp.m_prefix
+4
View File
@@ -60,6 +60,9 @@ class XRefProperties : public PropertiesInterface
void setShowTerminalName (const bool a) {m_show_terminal_name = a;}
bool showTerminalName () const {return m_show_terminal_name;}
void setShowAllConfiguredSlaves (const bool a) {m_show_all_configured_slaves = a;}
bool showAllConfiguredSlaves () const {return m_show_all_configured_slaves;}
void setDisplayHas (const DisplayHas dh) {m_display = dh;}
DisplayHas displayHas () const {return m_display;}
@@ -88,6 +91,7 @@ class XRefProperties : public PropertiesInterface
private:
bool m_show_power_ctc;
bool m_show_terminal_name;
bool m_show_all_configured_slaves;
DisplayHas m_display;
SnapTo m_snap_to;
Qt::AlignmentFlag m_xref_pos;
+15 -5
View File
@@ -367,10 +367,18 @@ void QETDiagramEditor::setUpActions()
//original, where it was easy to miss entirely; now it appears
//under the cursor and follows it until a click, Return, or Escape
//to cancel -- the same interaction as placing a new element.
const QPoint view_pos = dv->viewport()->mapFromGlobal(QCursor::pos());
const QPointF start_pos = dv->viewport()->rect().contains(view_pos)
? dv->mapToScene(view_pos)
: dv->mapToScene(dv->viewport()->rect().center());
//
//dv->lastMousePos() (an ordinary Qt mouse-move position), not
//QCursor::pos() (a global, OS-level cursor query): several
//window managers and compositors -- Wayland in particular --
//silently refuse that query, returning a stale or wrong
//position, which is exactly what made the pasted content land
//far from the cursor instead of under it.
const QPoint last_pos = dv->lastMousePos();
const QPoint view_pos = (last_pos.x() >= 0 && dv->viewport()->rect().contains(last_pos))
? last_pos
: dv->viewport()->rect().center();
const QPointF start_pos = dv->mapToScene(view_pos);
dv->diagram()->setEventInterface(
new DiagramEventAddPaste(dv->diagram(), start_pos));
@@ -2330,6 +2338,8 @@ void QETDiagramEditor::openBackupFiles(QList<KAutoSaveFile *> backup_files)
//Create the project
DialogWaiting::instance(this);
//QETProject takes ownership of file and deletes it, whether or not it opens
const QString file_name = file->managedFile().fileName();
QETProject *project = new QETProject(file, this);
if (project->state() != QETProject::Ok)
{
@@ -2340,7 +2350,7 @@ void QETDiagramEditor::openBackupFiles(QList<KAutoSaveFile *> backup_files)
tr("Échec de l'ouverture du projet", "message box title"),
QString(tr(
"Une erreur est survenue lors de l'ouverture du fichier %1.",
"message box content")).arg(file->managedFile().fileName()));
"message box content")).arg(file_name));
}
delete project;
DialogWaiting::dropInstance();
+269 -53
View File
@@ -30,6 +30,8 @@
#include "terminal.h"
#include "../properties/elementdata.h"
#include <algorithm>
//define the height of the header.
static int header = 5;
//define the minimal height of the cross (without header)
@@ -194,6 +196,37 @@ QString CrossRefItem::elementPositionText(
return txt;
}
/**
@brief CrossRefItem::showAllConfiguredSlaves
@param elmt : the element displaying the cross reference
@param xrp : xref properties of that element
@return true when the contact comb must show every slave contact the
master defines, even those no slave is linked to yet. That is the case
when the user asked for it, when the comb (contacts) display is the
one in use, and when the master really declares contact groups --
an element which declares none behaves exactly as before.
*/
bool CrossRefItem::showAllConfiguredSlaves(
const Element *elmt,
const XRefProperties &xrp)
{
if (!elmt) return false;
if (!xrp.showAllConfiguredSlaves()) return false;
if (xrp.displayHas() != XRefProperties::Contacts) return false;
return !elmt->elementData().m_slave_contact_groups.isEmpty();
}
/**
@brief CrossRefItem::mustDrawAllConfiguredSlaves
@return showAllConfiguredSlaves for the element of this item and the
current properties.
*/
bool CrossRefItem::mustDrawAllConfiguredSlaves() const
{
return showAllConfiguredSlaves(m_element, m_properties);
}
/**
@brief CrossRefItem::updateProperties
update the current properties
@@ -256,8 +289,11 @@ void CrossRefItem::updateLabel()
QTimer::singleShot(0, this, [this]{ update(); });
return;
}
//Draw cross or contact, only if master element is linked.
else if (! m_element->linkedElements().isEmpty())
//Draw cross or contact, if master element is linked, or if the user
//asks for the contact comb to show the contact groups of the master
//even before they get a slave.
else if (! m_element->linkedElements().isEmpty()
|| mustDrawAllConfiguredSlaves())
{
m_update_map = true;
XRefProperties::DisplayHas dh = m_properties.displayHas();
@@ -358,7 +394,8 @@ void CrossRefItem::paint(
if (m_element->elementData().m_master_type == ElementData::PLC)
return;
if (m_element->linkedElements().isEmpty()) return;
if (m_element->linkedElements().isEmpty()
&& !mustDrawAllConfiguredSlaves()) return;
QPen pen_;
pen_.setWidthF(0.5);
@@ -727,6 +764,41 @@ void CrossRefItem::drawAsCross(QPainter &painter)
fillCrossRef(painter);
}
namespace {
/**
@brief contactOption
Map the contact group declared by a master onto the CONTACTS flags
used by CrossRefItem::drawContact, so a group no slave is linked to
yet is drawn like the slave it waits for.
@param group : the contact group of the master
@return the flags describing the contact to draw
*/
int contactOption(const ElementData::SlaveContactGroup &group)
{
int option = 0;
switch (group.type)
{
case ElementData::NO: option = CrossRefItem::NO; break;
case ElementData::NC: option = CrossRefItem::NC; break;
case ElementData::SW: option = CrossRefItem::SW; break;
case ElementData::Other: option = CrossRefItem::Other; break;
}
switch (group.subtype)
{
case ElementData::Power: option += CrossRefItem::Power; break;
case ElementData::DelayOn: option += CrossRefItem::DelayOn; break;
case ElementData::DelayOff: option += CrossRefItem::DelayOff; break;
case ElementData::delayOnOff: option += CrossRefItem::DelayOnOff; break;
case ElementData::SSimple:
case ElementData::PLCSlave: break;
}
return option;
}
}
/**
@brief CrossRefItem::drawAsContacts
Draw this crossref with symbolic contacts
@@ -734,37 +806,99 @@ void CrossRefItem::drawAsCross(QPainter &painter)
*/
void CrossRefItem::drawAsContacts(QPainter &painter)
{
if (m_element -> isFree())
if (m_element -> isFree() && !mustDrawAllConfiguredSlaves())
return;
m_drawed_contacts = 0;
if (m_update_map) m_hovered_contacts_map.clear();
QRectF bounding_rect;
//Draw each linked contact
foreach (Element *elmt, m_element->linkedElements())
//Draw every contact group of the master, in the order the master
//defines them, when the user asked for it and the master declares
//contact groups. Otherwise the comb keeps its historical behavior:
//linked slaves only, in position order.
if (mustDrawAllConfiguredSlaves())
{
DiagramContext info = elmt->kindInformations();
const QVector<ElementData::SlaveContactGroup> groups =
m_element->elementData().m_slave_contact_groups;
for (int i=0; i<info["number"].toInt(); i++)
//A contact group waits for exactly one slave: a slave assigned to
//a group is drawn where the master puts it, whatever its position
//on the diagram.
QHash<int, Element *> slotted;
QList<Element *> unassigned;
foreach (Element *elmt, m_element->linkedElements()) //position order
{
int option = 0;
QString state = info["state"].toString();
if (state == "NO") option = NO;
else if (state == "NC") option = NC;
else if (state == "SW") option = SW;
else if (state == "Other") option = Other;
QString type = info["type"].toString();
if (type == "power") option += Power;
else if (type == "delayOn") option += DelayOn;
else if (type == "delayOff") option += DelayOff;
else if (type == "delayOnOff") option += DelayOnOff;
QRectF br = drawContact(painter, option, elmt, i);
bounding_rect = bounding_rect.united(br);
const int index = m_element->groupIndexForElement(elmt);
if (index >= 0 && index < groups.size() && !slotted.contains(index))
slotted.insert(index, elmt);
else
unassigned << elmt;
}
for (int i = 0; i < groups.size(); ++i)
{
if (Element *slave = slotted.value(i, nullptr))
bounding_rect = bounding_rect.united(
drawLinkedSlaveContacts(painter, slave));
else
{
//No slave is linked to this group yet: the contact
//symbol of the group is drawn with the terminal names
//the master defines for it, there is no cross reference
//to show for it.
const int option = contactOption(groups.at(i));
const int poles = qMax(1, groups.at(i).contactCount);
QStringList labels = groups.at(i).labels;
//A single pole simple contact (NO or NC) reads its two
//numbers the other way round (checked against the
//diagram). Changeover contacts are not handled here:
//their labels are mapped to the right contact half in
//drawContact(), per pole, so multi pole changeovers work
//too. Groups with several NO/NC poles keep the order the
//master defines: a 3 pole power contact already reads
//correctly that way.
if (poles == 1 && (option & NOC))
std::reverse(labels.begin(), labels.end());
//The declared terminals are distributed over the declared
//poles. terminalCount and contactCount are edited
//independently in the element editor, so the list can be
//shorter than two entries per pole (three for a switch):
//every pole then gets its share of what exists, instead
//of a fixed 2/3 stride starving all but the first poles.
const int per_pole = labels.size() / poles;
const int extra = labels.size() % poles;
int begin = 0;
for (int pole = 0; pole < poles; ++pole)
{
const int count = per_pole + (pole < extra ? 1 : 0);
const QStringList pole_labels = labels.mid(begin, count);
begin += count;
bounding_rect = bounding_rect.united(
drawContact(painter,
option,
nullptr,
pole,
pole_labels));
}
}
}
//Slaves the master doesn't assign to one of its groups (a link
//made before the master declared groups, for example) keep their
//usual place: the end of the comb, in position order.
foreach (Element *elmt, unassigned)
bounding_rect = bounding_rect.united(
drawLinkedSlaveContacts(painter, elmt));
}
else
{
//Draw each linked contact, in position order
foreach (Element *elmt, m_element->linkedElements())
bounding_rect = bounding_rect.united(
drawLinkedSlaveContacts(painter, elmt));
}
bounding_rect.adjust(-30, -4, 4, 4);
@@ -773,17 +907,61 @@ void CrossRefItem::drawAsContacts(QPainter &painter)
m_shape_path.addRect(bounding_rect);
}
/**
@brief CrossRefItem::drawLinkedSlaveContacts
Draw the contact symbols of one slave linked to this master.
@param painter : painter to use
@param elmt : the slave element to draw
@return the bounding rect of the draw
*/
QRectF CrossRefItem::drawLinkedSlaveContacts(QPainter &painter, Element *elmt)
{
QRectF bounding_rect;
DiagramContext info = elmt->kindInformations();
for (int i=0; i<info["number"].toInt(); i++)
{
int option = 0;
QString state = info["state"].toString();
if (state == "NO") option = NO;
else if (state == "NC") option = NC;
else if (state == "SW") option = SW;
else if (state == "Other") option = Other;
QString type = info["type"].toString();
if (type == "power") option += Power;
else if (type == "delayOn") option += DelayOn;
else if (type == "delayOff") option += DelayOff;
else if (type == "delayOnOff") option += DelayOnOff;
bounding_rect = bounding_rect.united(
drawContact(painter, option, elmt, i));
}
return bounding_rect;
}
/**
@brief CrossRefItem::drawContact
Draw one contact, the type of contact to draw is define in flags.
@param painter : painter to use
@param flags : define how to draw the contact (see enul CONTACTS)
@param elmt : the element to display text (the position of the contact)
@param elmt : the element to display text (the position of the contact).
It may be nullptr when the contact comes from a contact group the master
defines but no slave is linked to yet: no position text, no hover/click
support, and the terminal names then come from master_labels.
@param pole_index : which contact of the group is drawn (0 based), used
to pick the right pair of terminal names of a linked multi-pole contact.
@param master_labels : the terminal names the master declares for this
pole (sliced from ElementData::SlaveContactGroup::labels by the caller),
used when elmt is nullptr so an empty slot shows the numbers the master
declares, the same way a linked slave would show them.
@return The bounding rect of the draw (contact + text)
*/
QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, int pole_index)
QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, int pole_index, const QStringList &master_labels)
{
QString str = elementPositionText(elmt);
QString str = elmt ? elementPositionText(elmt) : QString();
// Collect terminal names from the element definition (.elmt)
// e.g. name="13" and name="14" on each terminal
@@ -791,12 +969,12 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
// For SW contacts with typed terminals (No/Nc/Common), filter by role.
QStringList terminal_names;
const bool is_power_ctc =
elmt->kindInformations()["type"].toString() == "power";
elmt && elmt->kindInformations()["type"].toString() == "power";
const bool is_sw = (flags & SW) && !(flags & NOC);
// Check if SW terminals have explicit No/Nc/Common types
bool sw_has_typed_terminals = false;
if (is_sw) {
if (is_sw && elmt) {
for (Terminal *t : elmt->terminals()) {
if (!t) continue;
if (t->terminalType() == TerminalData::No ||
@@ -808,11 +986,34 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
}
}
for (Terminal *t : elmt->terminals()) {
if (!t) continue;
const QString tname = t->name();
if (!tname.isEmpty())
terminal_names << tname;
if (elmt) {
for (Terminal *t : elmt->terminals()) {
if (!t) continue;
const QString tname = t->name();
if (!tname.isEmpty())
terminal_names << tname;
}
} else if (!master_labels.isEmpty()) {
//Empty slot of the contact comb: the slave is missing but the
//master already declares the terminal names, so the slot shows
//them instead of staying mute. master_labels contains exactly
//the terminals of this pole (the caller slices the declared
//terminal list over the declared poles), in the order a linked
//slave would receive them.
if (is_sw) {
//The labels are stored in terminal order (for a typical
//changeover contact: common, NC, NO, i.e. 11, 12, 14), while
//the symbol draws NC bottom-left, NO top-left and the common
//on the right: every stored entry goes to its own position.
//Entries the master doesn't declare (terminal count lower
//than three) simply stay empty instead of landing on the
//wrong contact half like the raw stored order would.
terminal_names << master_labels.value(1)
<< master_labels.value(2)
<< master_labels.value(0);
} else {
terminal_names = master_labels;
}
}
if (is_power_ctc) {
@@ -860,7 +1061,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
QRectF bounding_rect = QRectF(0, offset, 24, 10);
QPen pen = painter.pen();
m_hovered_contact == elmt ? pen.setColor(Qt::blue) :pen.setColor(Qt::black);
elmt && m_hovered_contact == elmt ? pen.setColor(Qt::blue) :pen.setColor(Qt::black);
painter.setPen(pen);
//Draw NO or NC contact
@@ -956,11 +1157,18 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
}
}
//The hit rect is registered even when the position text is
//empty: a linked contact had (and keeps) its hover/click entry
//in that case too, only the drawing is skipped. Free slots
//(elmt == nullptr) have nothing to click and stay out of the map.
QRectF text_rect = painter.boundingRect(QRectF(30, offset, 5, 10), Qt::AlignLeft | Qt::AlignVCenter, str);
painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str);
bounding_rect = bounding_rect.united(text_rect);
if (!str.isEmpty())
{
painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str);
bounding_rect = bounding_rect.united(text_rect);
}
if (m_update_map)
if (m_update_map && elmt)
m_hovered_contacts_map.insert(elmt, text_rect);
++m_drawed_contacts;
@@ -1038,12 +1246,16 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
QRectF(30, offset+4, 5, 10),
Qt::AlignLeft | Qt::AlignVCenter,
str);
painter.drawText(text_rect,
Qt::AlignLeft | Qt::AlignVCenter,
str);
bounding_rect = bounding_rect.united(text_rect);
if (m_update_map)
if (!str.isEmpty())
{
painter.drawText(text_rect,
Qt::AlignLeft | Qt::AlignVCenter,
str);
bounding_rect = bounding_rect.united(text_rect);
}
//Hit rect kept even for an empty position text (as before),
//free slots are not clickable.
if (m_update_map && elmt)
m_hovered_contacts_map.insert(elmt, text_rect);
//a switch contact take place of two normal contact
@@ -1067,15 +1279,19 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
//Draw position text
QRectF text_rect = painter.boundingRect(
QRectF(30, offset, 5, 10),
Qt::AlignLeft | Qt::AlignVCenter,
str);
painter.drawText(text_rect,
Qt::AlignLeft | Qt::AlignVCenter,
str);
bounding_rect = bounding_rect.united(text_rect);
if (m_update_map)
QRectF(30, offset, 5, 10),
Qt::AlignLeft | Qt::AlignVCenter,
str);
if (!str.isEmpty())
{
painter.drawText(text_rect,
Qt::AlignLeft | Qt::AlignVCenter,
str);
bounding_rect = bounding_rect.united(text_rect);
}
//Hit rect kept even for an empty position text (as before),
//free slots are not clickable.
if (m_update_map && elmt)
m_hovered_contacts_map.insert(elmt, text_rect);
++m_drawed_contacts;
}
+14 -1
View File
@@ -63,6 +63,13 @@ class CrossRefItem : public QGraphicsObject
enum { Type = UserType + 1009 };
int type() const override { return Type; }
/// Returns true when \a xrp asks the contact comb of \a elmt to show
/// every slave contact the master defines, even the ones no slave is
/// linked to yet. \a elmt must be a master element.
static bool showAllConfiguredSlaves(
const Element *elmt,
const XRefProperties &xrp);
/**
@brief The CONTACTS enum
*/
@@ -121,7 +128,13 @@ class CrossRefItem : public QGraphicsObject
void drawAsCross(QPainter &painter);
void drawAsContacts(QPainter &painter);
void drawAsPlcTable(QPainter &painter);
QRectF drawContact(QPainter &painter, int flags, Element *elmt, int pole_index = 0);
bool mustDrawAllConfiguredSlaves() const;
QRectF drawLinkedSlaveContacts(QPainter &painter, Element *elmt);
QRectF drawContact(QPainter &painter,
int flags,
Element *elmt,
int pole_index = 0,
const QStringList &master_labels = QStringList());
void fillCrossRef(QPainter &painter);
void AddExtraInfo(QPainter &painter, const QString&);
QList<Element *> NOElements() const;
+17 -3
View File
@@ -890,7 +890,17 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
*/
void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods)
{
const QPointF pivotScene = pos() + m_transform.pivot;
// mapToScene(), not the pos() + m_transform.pivot shortcut: that
// shortcut only holds because the pivot is a fixed point of
// m_transform.toMatrix() -- true exactly as long as m_transform is
// the item's ONLY transform. It silently breaks the moment anything
// else is composed on top (QGraphicsItem's own scale()/rotation(),
// or a future parent-group transform), giving a scenePivot that
// doesn't match where the pivot handle is actually drawn (rebuildHandles()
// already uses mapToScene() for that, which is why the handle looks
// right even when a drag computed from this shortcut doesn't).
// mapToScene() is correct regardless of what else is composed in.
const QPointF pivotScene = mapToScene(m_transform.pivot);
QTransform undoRotation;
undoRotation.rotate(-m_transform.rotation);
const QPointF postScaleOffset = undoRotation.map(mapToScene(localPos) - pivotScene);
@@ -961,7 +971,9 @@ void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::Keyboa
*/
void DiagramImageItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
{
const QPointF scenePivot = pos() + m_transform.pivot;
// mapToScene(), not pos() + m_transform.pivot: see dragResize()'s
// identical comment for why.
const QPointF scenePivot = mapToScene(m_transform.pivot);
const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x()));
const QPointF reference = scaleAndShearOffset(cornerPosition(cornerIndex, pixmap_.width(), pixmap_.height()));
@@ -995,7 +1007,9 @@ void DiagramImageItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt
const qreal rad = qDegreesToRadians(m_transform.rotation);
const qreal c = qCos(rad), s = qSin(rad);
const QPointF targetRel = scenePos - pos() - pivot;
// mapToScene(pivot), not pos() + pivot: see dragResize()'s identical
// comment for why.
const QPointF targetRel = scenePos - mapToScene(pivot);
const QPointF M(targetRel.x() * c + targetRel.y() * s,
-targetRel.x() * s + targetRel.y() * c);
@@ -810,8 +810,12 @@ QVariant DynamicElementTextItem::itemChange(QGraphicsItem::GraphicsItemChange ch
connect(m_parent_element.data(), &Element::linkedElementChanged, this, &DynamicElementTextItem::updateXref);
if(m_parent_element.data()->diagram())
connect(m_parent_element.data()->diagram()->project(), &QETProject::XRefPropertiesChanged, this, &DynamicElementTextItem::updateXref);
if(!m_parent_element.data()->linkedElements().isEmpty())
updateXref();
//Also call updateXref for a master without any linked slave:
//when the contact comb must show every contact group the master
//defines, the cross ref is expected the moment the element lands
//on the diagram, not only after the first link or the first
//settings change.
updateXref();
}
m_first_scene_change = false;
@@ -1582,8 +1586,10 @@ void DynamicElementTextItem::updateXref()
if(m_text_from == DynamicElementTextItem::ElementInfo &&
m_info_name == "label" &&
!m_parent_element.data()->linkedElements().isEmpty() &&
xrp.snapTo() == XRefProperties::Label)
xrp.snapTo() == XRefProperties::Label &&
(!m_parent_element.data()->linkedElements().isEmpty()
|| CrossRefItem::showAllConfiguredSlaves(
m_parent_element.data(), xrp)))
{
//For add a Xref, this text must not be in a group
if(!parentGroup())
@@ -60,12 +60,48 @@ ElementTextItemGroup::ElementTextItemGroup(const QString &name,
this,
&ElementTextItemGroup::updateXref);
if(parent->diagram())
connect(parent->diagram()->project(),
m_project_xref_connection = connect(
parent->diagram()->project(),
&QETProject::XRefPropertiesChanged,
this,
&ElementTextItemGroup::updateXref);
}
/**
@brief ElementTextItemGroup::itemChange
The group is very often built while its element is not on a scene yet
(project or element loading): the connection to the project was then
impossible and the first updateXref() ran without a diagram, so the
cross ref of a master waiting for its slaves stayed invisible until an
unrelated settings change happened to refresh it. Do both here, the
moment the group really reaches the scene.
@param change
@param value
@return
*/
QVariant ElementTextItemGroup::itemChange(
QGraphicsItem::GraphicsItemChange change,
const QVariant &value)
{
if (change == QGraphicsItem::ItemSceneHasChanged)
{
if (m_parent_element
&& m_parent_element->diagram()
&& m_parent_element->diagram()->project())
{
QETProject *project = m_parent_element->diagram()->project();
if (!m_project_xref_connection)
m_project_xref_connection = connect(
project,
&QETProject::XRefPropertiesChanged,
this,
&ElementTextItemGroup::updateXref);
updateXref();
}
}
return QGraphicsItemGroup::itemChange(change, value);
}
ElementTextItemGroup::~ElementTextItemGroup()
{}
@@ -772,13 +808,13 @@ void ElementTextItemGroup::updateXref()
{
QETProject *project = m_parent_element->diagram()->project();
if(m_parent_element->linkType() == Element::Master &&
!m_parent_element->linkedElements().isEmpty())
if(m_parent_element->linkType() == Element::Master)
{
XRefProperties xrp = project->defaultXRefProperties(m_parent_element->kindInformations()["type"].toString());
if(xrp.snapTo() == XRefProperties::Label)
if(xrp.snapTo() == XRefProperties::Label &&
(!m_parent_element->linkedElements().isEmpty()
|| CrossRefItem::showAllConfiguredSlaves(m_parent_element, xrp)))
{
//At least one text owned by this group must be set with
//textFrom -> element info and element info name -> label
@@ -101,6 +101,8 @@ class ElementTextItemGroup : public QObject, public QGraphicsItemGroup
void keyPressEvent(QKeyEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;
QVariant itemChange(GraphicsItemChange change,
const QVariant &value) override;
private:
void updateXref();
@@ -120,6 +122,7 @@ class ElementTextItemGroup : public QObject, public QGraphicsItemGroup
Element *m_parent_element = nullptr;
QList<QMetaObject::Connection> m_update_slave_Xref_connection;
QGraphicsTextItem *m_slave_Xref_item = nullptr;
QMetaObject::Connection m_project_xref_connection;
QMetaObject::Connection m_XrefChanged_timer,
m_linked_changed_timer;
};
+33
View File
@@ -167,6 +167,14 @@ QVariant MasterElement::itemChange(QGraphicsItem::GraphicsItemChange change, con
m_Xref_item = new CrossRefItem(this);
m_Xref_item->updateLabel();
}
// Same idea for a master whose contact comb must show every
// contact group it defines: the cross ref is expected even
// before any slave is linked to it.
else if (!m_Xref_item && mustShowXrefWithoutSlave())
{
m_Xref_item = new CrossRefItem(this);
m_Xref_item->updateLabel();
}
}
return Element::itemChange(change, value);
}
@@ -193,6 +201,26 @@ void MasterElement::xrefPropertiesChanged()
aboutDeleteXref();
}
/**
@brief MasterElement::mustShowXrefWithoutSlave
@return true when the cross ref of this master has to be shown even
though no slave is linked to it yet: the user asks the contact comb to
display every contact group the master defines, the comb (contacts)
display is the one in use, and the cross ref is owned by the element
itself (snap to bottom).
*/
bool MasterElement::mustShowXrefWithoutSlave() const
{
if (!diagram() || !diagram()->project())
return false;
const XRefProperties xrp = diagram()->project()->defaultXRefProperties(
kindInformations()["type"].toString());
return xrp.snapTo() == XRefProperties::Bottom
&& CrossRefItem::showAllConfiguredSlaves(this, xrp);
}
/**
@brief MasterElement::aboutDeleteXref
Check if Xref item must be displayed, if not, delete it.
@@ -220,6 +248,11 @@ void MasterElement::aboutDeleteXref()
return;
}
// The contact comb shows the contact groups the master defines, linked
// or not: keep the item even when it draws nothing so far.
if (mustShowXrefWithoutSlave())
return;
if (m_Xref_item->boundingRect().isNull())
{
delete m_Xref_item;
+1
View File
@@ -60,6 +60,7 @@ class MasterElement : public Element
private:
void xrefPropertiesChanged();
void aboutDeleteXref ();
bool mustShowXrefWithoutSlave() const;
void connectSlavePositionUpdates(Element *slave);
void disconnectSlavePositionUpdates(Element *slave);
+1 -1
View File
@@ -2385,7 +2385,7 @@ void QetShapeItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardMo
: QetGraphicsHandlerUtility::rectForPosAtIndex(localRect(), localPos, index);
if (mods & Qt::ShiftModifier)
newRect = lockAspectRatio(localRect(), newRect, index, mirrored);
newRect = lockAspectRatio(QRectF(m_old_P1, m_old_P2).normalized(), newRect, index, mirrored);
setRect(newRect.normalized());
}
+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) {
+23
View File
@@ -38,6 +38,7 @@ XRefPropertiesWidget::XRefPropertiesWidget(QHash <QString, XRefProperties> prope
ui->setupUi(this);
buildUi();
connect(ui->m_display_has_cross_rb, &QRadioButton::toggled, ui->m_cross_properties_gb, &QWidget::setEnabled);
connect(ui->m_display_has_contacts_rb, &QRadioButton::toggled, ui->m_show_all_slaves_cb, &QWidget::setEnabled);
connect(ui->m_type_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::typeChanged);
connect(ui->m_snap_to_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::enableOffsetSB);
updateDisplay();
@@ -50,6 +51,7 @@ XRefPropertiesWidget::XRefPropertiesWidget(QHash <QString, XRefProperties> prope
XRefPropertiesWidget::~XRefPropertiesWidget()
{
disconnect(ui->m_display_has_cross_rb, &QRadioButton::toggled, ui->m_cross_properties_gb, &QWidget::setEnabled);
disconnect(ui->m_display_has_contacts_rb, &QRadioButton::toggled, ui->m_show_all_slaves_cb, &QWidget::setEnabled);
disconnect(ui->m_type_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::typeChanged);
disconnect(ui->m_snap_to_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::enableOffsetSB);
delete ui;
@@ -143,6 +145,7 @@ void XRefPropertiesWidget::saveProperties(int index) {
else if(ui->m_xrefpos_cb->itemData(ui->m_xrefpos_cb->currentIndex()).toString() == "text_field") xrp.setXrefPos(Qt::AlignHCenter);
xrp.setShowPowerContac(ui->m_show_power_cb->isChecked());
xrp.setShowTerminalName(ui->m_show_terminal_name_cb->isChecked());
xrp.setShowAllConfiguredSlaves(ui->m_show_all_slaves_cb->isChecked());
xrp.setPrefix("power", ui->m_power_prefix_le->text());
xrp.setPrefix("delay", ui->m_delay_prefix_le->text());
xrp.setPrefix("switch", ui->m_switch_prefix_le->text());
@@ -200,10 +203,30 @@ void XRefPropertiesWidget::updateDisplay()
else if(xrp.getXrefPos() == Qt::AlignHCenter) ui->m_xrefpos_cb->setCurrentIndex(ui->m_xrefpos_cb->findData("text_field"));
ui->m_show_power_cb->setChecked(xrp.showPowerContact());
ui->m_show_terminal_name_cb->setChecked(xrp.showTerminalName());
ui->m_show_all_slaves_cb->setChecked(xrp.showAllConfiguredSlaves());
//The radio button only emits toggled() when it really changes: loading
//a type whose display did not change left the checkbox with the enabled
//state of the previously displayed type (it stayed clickable although
//the cross display was selected). Set the state explicitly here.
ui->m_show_all_slaves_cb->setEnabled(
ui->m_display_has_contacts_rb->isChecked());
ui->m_power_prefix_le-> setText(xrp.prefix("power"));
ui->m_delay_prefix_le-> setText(xrp.prefix("delay"));
ui->m_switch_prefix_le->setText(xrp.prefix("switch"));
ui->m_cross_properties_gb->setDisabled(!ui->m_display_has_cross_rb->isChecked());
//The cross ref of a PLC master is always drawn as its IO table, and
//the slaves are referenced directly into that table: the contacts/
//cross choice, the two display checkboxes and the cross options below
//have no effect at all for this type, so they are hidden instead of
//being offered for nothing. The positioning settings and the labels
//(the table really uses them) stay available.
const bool is_plc = type == QLatin1String("plc");
ui->m_display_has_contacts_rb->setVisible(!is_plc);
ui->m_display_has_cross_rb->setVisible(!is_plc);
ui->m_show_terminal_name_cb->setVisible(!is_plc);
ui->m_show_all_slaves_cb->setVisible(!is_plc);
ui->m_cross_properties_gb->setVisible(!is_plc);
}
/**
+11
View File
@@ -151,6 +151,16 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="m_show_all_slaves_cb">
<property name="toolTip">
<string>Afficher dans le peigne de contacts tous les contacts esclaves définis par le maître, même ceux qui ne sont pas encore reliés, dans l'ordre défini par le maître</string>
</property>
<property name="text">
<string>Afficher tous les esclaves définis par le maître</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="m_display_has_contacts_rb">
<property name="text">
@@ -327,6 +337,7 @@
<tabstop>m_slave_le</tabstop>
<tabstop>m_show_power_cb</tabstop>
<tabstop>m_show_terminal_name_cb</tabstop>
<tabstop>m_show_all_slaves_cb</tabstop>
<tabstop>m_power_prefix_le</tabstop>
<tabstop>m_delay_prefix_le</tabstop>
<tabstop>m_switch_prefix_le</tabstop>
+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"