Compare commits

..

35 Commits

Author SHA1 Message Date
Laurent Trinques 1793dcb9a8 Revert "Feature: Terminal potential grouping" 2026-09-07 15:44:12 +02:00
Laurent Trinques cc7aaf3538 Merge pull request #804 from IBSYSLevi/feature/terminal-potential-grouping
Feature: Terminal potential grouping
2026-09-07 15:08:00 +02:00
Laurent Trinques a6285c7ef7 Merge pull request #815 from ispyisail/test/diagramsortkeys-unit-test
Add unit test locking in Diagram::toXml() coordinate sort order
2026-09-07 14:31:26 +02:00
Laurent Trinques f87381a910 Merge pull request #817 from arummler/feature-graphics-part-2
Fixes and embelishment for some of the graphics and image features
2026-09-07 14:29:24 +02:00
ispyisail bd5cff4211 Add a unit test locking in Diagram::toXml()'s coordinate sort order
Follow-up to the positionKey() fix merged directly in #779
(b2f4ef5d2): per review request, add a small regression test so this
class of bug (fixed-precision "%.4f" formatting compares out of
numeric order once the integer part's digit count differs) can't
silently reappear.

positionKey()/coordinateKey() move out of diagram.cpp's anonymous
namespace into a small header-only diagramsortkeys.h so the test can
link against the exact same code Diagram::toXml() uses, instead of
duplicating the algorithm. Behavior is unchanged.

tst_diagramsortkeys covers: single- vs double-digit, double- vs
triple-digit, negative-vs-negative, negative-vs-positive, and
negative-vs-zero coordinate pairs, plus sub-precision deltas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:54:18 +12:00
Andre Rummler 296f142036 Adding icons to graphs and image operations. 2026-09-07 10:44:30 +02:00
Andre Rummler 4d991d0419 Conversion of ellipses and rounded rectangles to bezier curves instead of polylines. 2026-09-07 10:44:30 +02:00
Andre Rummler a85bd6a49b Fixing undo operations of graphs and images. Changing undo names. 2026-09-07 10:44:30 +02:00
Andre Rummler ef8e41a5c3 Fix transparency export (not possible for jpeg). 2026-09-07 10:44:30 +02:00
Andre Rummler 6f80da66d5 Connected to previous image handling updates: images were not exported at all to dxf. In this
update instead of the image a placeholder box is drawn and a warning is given during export.

Current dxf version is extremely old and does not support any image embedding. Future possible
update is the introduction of a newer dxf version export option which would allow to embed a link to an external
image file.
2026-09-07 10:44:30 +02:00
Andre Rummler a065867502 Fix issue that made it impossible to remove all transparency colours defined in a previously opened dialog. On the last color the OK button was greyed out. 2026-09-07 10:44:29 +02:00
Laurent Trinques b98d41357a Merge pull request #814 from ispyisail/fix/diagram-toxml-deterministic-order
Fix: positionKey() didn't sort coordinates numerically
2026-09-07 10:30:48 +02:00
ispyisail b2f4ef5d25 Fix: positionKey() didn't sort coordinates numerically
Plain fixed-precision formatting ("%.4f") produces strings that don't
compare in numeric order once the integer part has a different digit
count -- e.g. "15.0000" sorts before "5.0000" as text, even though
15 > 5. That silently broke the determinism goal of this branch for
any diagram with coordinates spanning more than one digit width.

Shift into a non-negative range and zero-pad to a fixed width instead,
so the formatted string sorts the same way the number does, including
negative values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:13:05 +12:00
Laurent Trinques bd8470afa8 Merge pull request #787 from ispyisail/fix/removediagram-cascade-delete
Cascade-delete a diagram's element/terminal/conductor rows on removal
2026-09-07 10:05:27 +02:00
ispyisail 7c5f8c6a4b Fix: roll back removeDiagram cascade delete on query failure
Each cascade delete (element_info/terminal/conductor/element) only
logged its error and fell through to the next query regardless, so a
mid-cascade failure (e.g. a locked DB) still let the diagram row get
deleted while its child rows survived -- the same inconsistency this
branch set out to fix, just via a different failure path. Wrap the
cascade in a transaction and roll back + bail on the first failed
exec(), matching the existing transaction pattern in updateDB().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:02:38 +12:00
Laurent Trinques 6b27043f33 Merge pull request #779 from ispyisail/fix/diagram-toxml-deterministic-order
Make Diagram::toXml() serialize elements and conductors deterministically
2026-09-07 09:38:28 +02:00
Laurent Trinques ed5a510592 Merge pull request #778 from ispyisail/fix/qpropertyundocommand-undo-symmetry
Give QPropertyUndoCommand::undo() its own first-time grace period
2026-09-07 01:09:44 +02:00
Laurent Trinques 3abf187688 Merge pull request #813 from jp2images/fix-element-editor-mirror-in-place
Element editor: mirror and flip the selection in place instead of across the origin
2026-09-06 14:38:15 +02:00
Jeff Patterson 825a1a594d Fix element editor Mirror and Flip to reflect the selection in place
Mirror (M) and Flip (F) in the element editor reflected the selected
parts across the element origin: every part's mirror() and flip()
negated the scene x or y coordinate. A part drawn to the right of the
origin landed the same distance to the left, so the selection jumped
to the other side of the canvas instead of turning around.

MirrorElementsCommand and FlipElementsCommand now compute the united
scene bounding rectangle of the selected items and reflect across the
vertical or horizontal line through its center. The center is snapped
to the nearest half of the diagram grid, so points that were on the
grid stay on the grid after the reflection. Terminals in particular
keep their grid alignment.

Each part's mirror() and flip() takes the axis coordinate as a
parameter with a default of 0, so the previous behavior remains
available to any other caller. The command stores the axis when it is
created and undo reapplies the same reflection, which is its own
inverse, so the existing undo path is unchanged.

Parts covered: PartArc, PartDynamicTextField, PartEllipse, PartLine,
PartPolygon, PartRectangle, PartTerminal, PartText.

Fixes #812
2026-09-06 06:48:45 -05:00
Laurent Trinques 38e1b3fe49 Merge pull request #811 from Kellermorph/pdf-import-fix
Enable PDF import in Flatpak by adding QtWebEngine BaseApp
2026-09-06 11:42:51 +02:00
Laurent Trinques fd3e213222 Update pt_BR translations files, Co-authored-by: Claude 2026-09-06 10:12:20 +02:00
Laurent Trinques ef4e186e42 Update FR EN translations files 2026-09-06 09:48:02 +02:00
Laurent Trinques 975ab31cc1 Update translations files 2026-09-06 09:44:51 +02:00
Kellermorph 2699a0aec8 Enable PDF import in Flatpak by adding QtWebEngine BaseApp 2026-09-06 09:06:34 +02:00
Laurent Trinques 76ff5c4dd9 Merge pull request #810 from arummler/feature-graphics-primitives-squashed-additional-commits
Image speed improvement and additional transparency feature
2026-09-05 21:36:33 +02:00
Andre Rummler b395527915 Add transparency to each color for image transparency. 2026-09-05 20:54:19 +02:00
Andre Rummler 7741bb33b2 Use downscaled image for image transparency window to improve resposiveness. 2026-09-05 20:54:09 +02:00
Laurent Trinques f02b576cb8 Update FR translations files 2026-09-05 20:47:59 +02:00
Laurent Trinques e0ef24fe68 Update translations files 2026-09-05 20:43:54 +02:00
Levi Jetzer 1e1039dd32 Address review
1. Drop dead prepareGeometryChange()/update() calls in
setPotential(), split unrelated terminaleditor.h re-indent into its
own change

2. Removed: setPotential() had prepareGeometryChange()/update() left over from
the label-display mechanism that was dropped before this PR
2026-09-04 13:12:21 +02:00
IBSYSLevi e410896f5d Merge branch 'qelectrotech:master' into feature/terminal-potential-grouping 2026-09-02 20:12:14 +02:00
Levi Jetzer 10bf0411bb Add "Potentiel" field to the terminal editor (element editor)
New QLineEdit (m_potential_le) between Type and Nom: an optional,
symbol-author-chosen grouping identifier shared by terminals that
belong to the same physical terminal within a multi-terminal block.

Stored as TerminalData::m_potential (new field, persisted as the
"potential" XML attribute, empty by default).
Used by relatedPotentialTerminal() in terminal.cpp: when
potential_isolating is enabled on a Terminal-type element, terminals
sharing a non-empty, matching potential value now stay electrically
linked to each other instead of every terminal in the block being
isolated from every other one.

Tooltip on the field for explanation
2026-09-02 19:30:26 +02:00
ispyisail e3cb994e0e Cascade-delete a diagram's element/element_info/terminal/conductor rows
removeDiagram() only ever deleted the diagram's own row. No foreign key in
this schema is declared ON DELETE CASCADE (and SQLite foreign-key
enforcement is never turned on for this connection anyway), so removing a
diagram left every element, element_info, terminal and conductor row that
belonged to it behind in the database -- silently, since nothing reads them
until the next full updateDB() rebuild papers over it.

Traced why this had never crashed anything: Diagram::~Diagram() explicitly
walks and deletes its top-level items through removeItem() (which does call
dataBase()->removeElement() correctly), but deliberately skips conductors --
because a conductor's destructor touches both of its terminals
(terminal1->removeConductor(this)), and those terminals may belong to an
element already destroyed earlier in the same sweep. Conductors are instead
destroyed as a side effect of Terminal::~Terminal()'s qDeleteAll() on its own
conductor list, which is a plain C++ delete that never goes through
Diagram::removeItem() and therefore never calls dataBase()->removeConductor()
at all. So the object graph is torn down safely, but the database is never
told about the conductors or their terminals.

Fixed by adding the missing bulk deletes to projectDataBase::removeDiagram()
itself, run while the diagram (and its live scene) still exist -- verified
that QETProject::detachDiagram() emits diagramRemoved() (which this class's
constructor connects to this slot) synchronously, before the Diagram object
is scheduled for destruction via deleteLater(), so nothing here races the
C++ teardown described above. Order matters: element_info and terminal have
no diagram_uuid column of their own, so both are scoped through a subquery
on element and must run before element itself is deleted.

Verified against examples/industrial.qet (50 diagrams) by calling
projectDataBase::removeDiagram() directly and comparing table counts before
and after, with no intervening updateDB() call to mask a gap:

  element=354->335 element_info=354->335 terminal=1087->1033 conductor=671->626 diagram=50->49

Every delta matches a direct SQL count for that diagram's own rows exactly
(19 elements, 54 terminals), and both "orphan rows still referencing the
removed diagram" checks read 0 afterward -- so the cascade is complete and,
just as importantly, scoped: nothing belonging to the other 49 diagrams
moved.

Separate finding, not fixed here: QETProject::removeDiagram(Diagram*) (the
synchronous, non-undoable variant, not the usual GUI
ProjectView::removeDiagram() path) segfaults if the enclosing QETProject is
destroyed before an event loop iteration lets its pending deleteLater() run
-- reproduces identically on unmodified master, so it predates and is
unrelated to this change. Worth its own report; a headless caller is the
only realistic way to hit it, which is how this surfaced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 22:51:39 +12:00
ispyisail 0d08a4e265 Make Diagram::toXml() serialize elements and conductors deterministically
Saving an unmodified project produced a different byte stream on
every run: QGraphicsScene::items() returns items in stacking order,
and ties between same-Z items follow the scene's internal index --
not any content-derived order -- so it isn't reproducible across
process runs. The legacy terminal-id table inherits the same
instability, since ids are assigned sequentially in element order.

Sort list_elements and list_conductors into a deterministic order
before serializing, using a key built from data that's actually
stable across loads (position), not Element::uuid()/Conductor::uuid():
for an item with no persisted uuid attribute, fromXml() invents a
fresh random one on every load, so sorting by uuid would still be
non-deterministic across process runs for any legacy file -- which
this corpus has plenty of.

Also fixes a second, related source of byte-level non-determinism
found while verifying the above: Conductor::toXml() unconditionally
wrote m_uuid back out, including the synthetic value fromXml() just
invented for a conductor with no uuid attribute in the file. Every
conductor in every example project checked has no persisted uuid at
all, so this alone meant no project with conductors could ever
resave identically, regardless of ordering. Conductor gets a
m_persist_uuid flag, false only when the uuid it's holding was
synthesized rather than loaded, so toXml() stops writing a value
that was never meant to be permanent.

Deliberately NOT applying the same uuid-persistence fix to Element:
element uuids are cross-referenced by other elements' <links_uuids>
blocks for master/slave/report linking (element.cpp, tmp_uuids_link,
matched by elmt->uuid() == stored uuid on load). Making an element's
own uuid non-persistent would silently break that match for any
linked element without one already -- a real regression, not a
theoretical one. Left as a smaller, separate residual: 1-6 elements
per project across the corpus (a few tenths of a percent) still get
a fresh uuid on each load, same class of bug, needs the link-aware
version of this fix instead of this one.

Verified against 8 example projects (the ones with conductors, plus
the two zero-conductor control cases from FINDINGS.md F002), 5
resaves each in isolated HOME/XDG environments:
- Element and conductor ORDER: 0 churning sections across the whole
  corpus (previously the majority of diagrams in industrial.qet,
  m_000.qet and tremie_vibrante.qet churned on every run).
- Conductor uuid VALUES: 0 churn (previously every conductor in
  every project, since none have a persisted uuid).
- 6 of 8 projects are now byte-for-byte identical (md5) across all 5
  runs. The remaining 2 (industrial.qet, m_000.qet) differ only in
  the handful of element uuids covered by the known Element residual
  above -- confirmed by checking those uuids specifically, not
  inferred.
- Element/conductor counts before and after resave match exactly on
  every project (no data loss from the sort).

Fixes #754.
2026-08-24 20:41:01 +12:00
ispyisail ae6b992ae4 Give QPropertyUndoCommand::undo() its own first-time grace period
redo() has a direct-write path guarded by m_first_time: on the very
first call it writes the property immediately, and only animates on
calls after that (per setAnimated()'s documented contract). undo()
had no equivalent -- it always animated, so undo() both returned
before the property was restored (stale state visible to anything
sharing the call stack) and, with no running event loop, never
restored it at all.

The obvious fix -- reuse m_first_time in undo()'s guard too -- turns
out not to work, and I verified this with a standalone build before
picking an approach: QUndoStack::push() always calls redo() once
before any undo() can run, and redo()'s direct-write branch sets
m_first_time = true as it completes. So by the time undo() is ever
called, m_first_time has already flipped, and reusing it would make
undo() take the animate branch on every call, unconditionally --
syntactically symmetric with redo(), but behaviourally unchanged for
the exact scenario reported.

Instead, undo() gets its own m_undo_first_time flag, seeded from the
same first_time argument setAnimated() already takes, and set true by
undo()'s own direct-write branch the same way m_first_time is set by
redo()'s. That gives undo() a real, reachable direct-write path on its
own first call, independent of how many times redo() has already run.

Verified against a standalone build of just this class (as the issue's
own repro does): first redo and first undo are both now synchronous
with no event loop running; with an event loop present, both settle to
the correct value once "broken in"; behaviour for every other caller
of QPropertyUndoCommand -- everywhere that calls plain enableAnimation()
or the bare setAnimated() (first_time defaulting true) -- is provably
unchanged, since m_undo_first_time starts true either way and the
animate branch never modifies it.

Fixes #755.
2026-08-24 20:10:32 +12:00
83 changed files with 39448 additions and 17793 deletions
@@ -3,6 +3,8 @@
"runtime": "org.kde.Platform",
"runtime-version": "6.11",
"sdk": "org.kde.Sdk",
"base": "io.qt.qtwebengine.BaseApp",
"base-version": "6.11",
"command": "qelectrotech",
"rename-desktop-file": "org.qelectrotech.qelectrotech.desktop",
"rename-appdata-file": "qelectrotech.appdata.xml",
@@ -25,13 +27,14 @@
"*.la",
"*.a"
],
"cleanup-commands": [
"/app/cleanup-BaseApp.sh"
],
"modules": [
"tkinter.json",
"pypi-dependencies.json",
{
"name": "qelectrotech",
"//qt6-private-headers-note": "qt6-base-private-dev has no Flatpak build-depends equivalent — private Qt6 headers ship inside org.kde.Sdk itself. If the cmake build fails looking for QtCore/private/*.h, the SDK/runtime branch is mismatched with the source tree, not a missing package.",
"//sqlite-driver-note": "libqt6sql6-sqlite / libsqlite3-dev have no Flatpak build-depends equivalent either. QET_EXPORT_PROJECT_DB=ON assumes the KDE runtime ships the Qt6 SQLite plugin (libqsqlite.so) already built into QtSql — verified at Debian packaging time this needed an explicit runtime dep there (see debian/control). Test the in-app database export after building; if it silently fails, the runtime's QtSql plugin set needs checking, not a package to add here.",
"buildsystem": "cmake",
"config-opts": [
"-DCMAKE_INSTALL_PREFIX=/app",
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
<g transform="translate(1,1)">
<g class="ColorScheme-Text" fill="currentColor">
<path d="m6 14c0 2.77 2.23 5 5 5s5-2.23 5-5z" fill-rule="evenodd" opacity=".66"/>
<path d="m7.482 10c-.34.586-.65 1.148-.886 1.676a4.958 4.958 0 0 0 -.596 2.324h10c0-.845-.228-1.627-.596-2.324a18.518 18.518 0 0 0 -.886-1.676z" fill-rule="evenodd" opacity=".33"/>
<path d="m11 3c-.973 3.405-3.354 6.332-4.404 8.676a4.958 4.958 0 0 0 -.596 2.324c0 2.77 2.23 5 5 5s5-2.23 5-5c0-.845-.228-1.627-.596-2.324-1.05-2.344-3.431-5.271-4.404-8.676zm0 2.393c1.194 2.668 2.747 5.027 3.492 6.691l.012.03.016.029a3.94 3.94 0 0 1 .48 1.857c0 2.233-1.767 4-4 4a3.97 3.97 0 0 1 -4-4c0-.665.177-1.283.48-1.857l.016-.03.012-.029c.745-1.664 2.298-4.023 3.492-6.691z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 983 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<path style="fill:currentColor;fill-opacity:1;stroke:none"
d="M 4 2 L 4 3 L 5 3 L 5 2 L 4 2 z M 11 2 L 11 3 L 12 3 L 12 2 L 11 2 z M 2 4 L 2 12 L 7 12 L 7 10 L 9 10 L 9 12 L 14 12 L 14 4 L 9 4 L 9 6 L 7 6 L 7 4 L 2 4 z M 3 5 L 6 5 L 6 11 L 3 11 L 3 5 z M 10 5 L 13 5 L 13 11 L 10 11 L 10 5 z M 11 6 L 11 10 L 12 10 L 12 6 L 11 6 z M 7 7 L 9 7 L 9 9 L 7 9 L 7 7 z M 4 13 L 4 14 L 5 14 L 5 13 L 4 13 z M 11 13 L 11 14 L 12 14 L 12 13 L 11 13 z "
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<path style="fill:currentColor;fill-opacity:1;stroke:none"
d="M 4 2 L 4 7 L 6 7 L 6 9 L 4 9 L 4 14 L 12 14 L 12 9 L 10 9 L 10 7 L 12 7 L 12 2 L 4 2 z M 5 3 L 11 3 L 11 6 L 5 6 L 5 3 z M 2 4 L 2 5 L 3 5 L 3 4 L 2 4 z M 13 4 L 13 5 L 14 5 L 14 4 L 13 4 z M 7 7 L 9 7 L 9 9 L 7 9 L 7 7 z M 5 10 L 11 10 L 11 13 L 5 13 L 5 10 z M 2 11 L 2 12 L 3 12 L 3 11 L 2 11 z M 6 11 L 6 12 L 10 12 L 10 11 L 6 11 z M 13 11 L 13 12 L 14 12 L 14 11 L 13 11 z "
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 3 3 L 3 5 L 3 6 L 6 6 L 6 5 L 6 3 L 4 3 L 3 3 z M 16 3 L 16 4 L 16 5 L 16 6 L 17 6 L 18 6 L 19 6 L 19 5 L 19 4 L 19 3 L 16 3 z M 4 4 L 5 4 L 5 5 L 4 5 L 4 4 z M 17 4 L 18 4 L 18 5 L 17 5 L 17 4 z M 7 5 L 7 6 L 9 6 L 9 5 L 7 5 z M 10 5 L 10 6 L 12 6 L 12 5 L 10 5 z M 13 5 L 13 6 L 15 6 L 15 5 L 13 5 z M 5 7 L 5 9 L 6 9 L 6 7 L 5 7 z M 16 7 L 16 9 L 17 9 L 17 7 L 16 7 z M 5 10 L 5 12 L 6 12 L 6 10 L 5 10 z M 16 10 L 16 12 L 17 12 L 17 10 L 16 10 z M 5 13 L 5 15 L 6 15 L 6 13 L 5 13 z M 16 13 L 16 15 L 17 15 L 17 13 L 16 13 z M 3 16 L 3 17 L 3 19 L 4 19 L 5 19 L 6 19 L 6 16 L 3 16 z M 7 16 L 7 17 L 9 17 L 9 16 L 7 16 z M 10 16 L 10 17 L 12 17 L 12 16 L 10 16 z M 13 16 L 13 17 L 15 17 L 15 16 L 13 16 z M 16 16 L 16 17 L 16 18 L 16 19 L 17 19 L 18 19 L 19 19 L 19 18 L 19 17 L 19 16 L 17 16 L 16 16 z M 4 17 L 5 17 L 5 18 L 4 18 L 4 17 z M 17 17 L 18 17 L 18 18 L 17 18 L 17 17 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1214 -553
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+1245 -574
View File
File diff suppressed because it is too large Load Diff
+1229 -567
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+1227 -565
View File
File diff suppressed because it is too large Load Diff
+1225 -563
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1231 -569
View File
File diff suppressed because it is too large Load Diff
+1227 -565
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1229 -567
View File
File diff suppressed because it is too large Load Diff
+1227 -565
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1223 -561
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1225 -563
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+1231 -569
View File
File diff suppressed because it is too large Load Diff
+1227 -565
View File
File diff suppressed because it is too large Load Diff
+1223 -561
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1223 -561
View File
File diff suppressed because it is too large Load Diff
+1223 -561
View File
File diff suppressed because it is too large Load Diff
+1223 -561
View File
File diff suppressed because it is too large Load Diff
+1227 -565
View File
File diff suppressed because it is too large Load Diff
+1228 -566
View File
File diff suppressed because it is too large Load Diff
+1227 -565
View File
File diff suppressed because it is too large Load Diff
+1229 -567
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -549,6 +549,13 @@
<file>ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-project.svgz</file>
<file>ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-titleblock.svgz</file>
<file>ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg</file>
<file>ico/breeze-icons/scalable/apps/hidef/transform-crop.svg</file>
<file>ico/breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg</file>
<file>ico/breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg</file>
<file>ico/breeze-icons/scalable/apps/hidef/edit-opacity.svg</file>
<file>ico/generated/rect-to-polyline.svg</file>
<file>ico/generated/rect-to-bezier.svg</file>
<file>ico/generated/ellipse-to-bezier.svg</file>
<file>ico/16x16/object-group.png</file>
<file>ico/mac_icon/elmt.icns</file>
<file>ico/mac_icon/qelectrotech.icns</file>
@@ -72,6 +72,7 @@ QPropertyUndoCommand::QPropertyUndoCommand(const QPropertyUndoCommand *other)
m_new_value = other->m_new_value;
m_animate = other->m_animate;
m_first_time = other->m_first_time;
m_undo_first_time = other->m_undo_first_time;
setText(other->text());
}
@@ -99,11 +100,20 @@ void QPropertyUndoCommand::enableAnimation (bool animate) {
@param first_time = if true,
the first animation is done at the first call of redo if false,
the first animation is done at the second call of redo.
The same rule applies to undo, tracked independently: redo() always
runs before the first undo() (QUndoStack::push() calls redo()
immediately), so by the time undo() can run at all, redo()'s own
m_first_time has already flipped true. Sharing that flag would leave
undo() with no instant path ever reachable in practice -- reusing it
is not actually symmetric, it just looks like it is. m_undo_first_time
gives undo() the same one-time grace period redo() has, on its own
first call instead of redo's.
*/
void QPropertyUndoCommand::setAnimated(bool animate, bool first_time)
{
m_animate = animate;
m_first_time = first_time;
m_undo_first_time = first_time;
}
/**
@@ -118,6 +128,18 @@ bool QPropertyUndoCommand::mergeWith(const QUndoCommand *other)
QPropertyUndoCommand const *undo = static_cast<const QPropertyUndoCommand *>(other);
if (m_object != undo->m_object
|| m_property_name != undo->m_property_name) return false;
// Same object and property name alone isn't enough: two entirely
// separate, deliberate actions (say, cropping an image and then
// mirroring it) both go through the same "pixmap" property and
// would otherwise silently coalesce into one undo entry, carrying
// only the first action's label -- the second vanishes from the
// undo list with no way to undo just it. Legitimate merging (a
// slider or spinbox pushing one command per tick while being
// dragged, e.g. ArcEditor's angle editors) always reuses the exact
// same text() across the whole sequence, so requiring a match here
// keeps that working unchanged while refusing to merge anything
// that isn't actually a continuation of the same action.
if (text() != other->text()) return false;
m_new_value = undo->m_new_value;
return true;
}
@@ -155,7 +177,7 @@ void QPropertyUndoCommand::undo()
{
if (m_object->property(m_property_name) != m_old_value)
{
if (m_animate)
if (m_animate && m_undo_first_time)
{
QPropertyAnimation *animation = new QPropertyAnimation(m_object, m_property_name);
animation->setStartValue(m_new_value);
@@ -163,7 +185,10 @@ void QPropertyUndoCommand::undo()
animation->start(QAbstractAnimation::DeleteWhenStopped);
}
else
{
m_object->setProperty(m_property_name, m_old_value);
m_undo_first_time = true;
}
}
QUndoCommand::undo();
@@ -58,7 +58,8 @@ class QPropertyUndoCommand : public QUndoCommand
const char *m_property_name;
QVariant m_old_value, m_new_value;
bool m_animate = false,
m_first_time = true;
m_first_time = true,
m_undo_first_time = true;
};
#endif // QPROPERTYUNDOCOMMAND_H
+69 -4
View File
@@ -225,12 +225,57 @@ void projectDataBase::addDiagram(Diagram *diagram)
void projectDataBase::removeDiagram(Diagram *diagram)
{
m_remove_diagram_query.bindValue(":uuid", diagram->uuid().toString());
const QString uuid_str = diagram->uuid().toString();
//Order matters: element_info and terminal are scoped through a
//subquery on element, so they must run before element itself is
//deleted below. The whole cascade runs in one transaction and is
//rolled back on the first error, so a mid-cascade failure (e.g. a
//locked DB) can't leave the diagram row deleted while its
//element/terminal/element_info/conductor rows survive.
m_data_base.transaction();
m_cascade_remove_element_info_query.bindValue(":uuid", uuid_str);
if (!m_cascade_remove_element_info_query.exec()) {
qDebug() << "projectDataBase::removeDiagram element_info cascade error : "
<< m_cascade_remove_element_info_query.lastError();
m_data_base.rollback();
return;
}
m_cascade_remove_terminal_query.bindValue(":uuid", uuid_str);
if (!m_cascade_remove_terminal_query.exec()) {
qDebug() << "projectDataBase::removeDiagram terminal cascade error : "
<< m_cascade_remove_terminal_query.lastError();
m_data_base.rollback();
return;
}
m_cascade_remove_conductor_query.bindValue(":uuid", uuid_str);
if (!m_cascade_remove_conductor_query.exec()) {
qDebug() << "projectDataBase::removeDiagram conductor cascade error : "
<< m_cascade_remove_conductor_query.lastError();
m_data_base.rollback();
return;
}
m_cascade_remove_element_query.bindValue(":uuid", uuid_str);
if (!m_cascade_remove_element_query.exec()) {
qDebug() << "projectDataBase::removeDiagram element cascade error : "
<< m_cascade_remove_element_query.lastError();
m_data_base.rollback();
return;
}
m_remove_diagram_query.bindValue(":uuid", uuid_str);
if (!m_remove_diagram_query.exec()) {
qDebug() << "projectDataBase::removeDiagram delete error : " << m_remove_diagram_query.lastError();
} else {
emit dataBaseUpdated();
m_data_base.rollback();
return;
}
m_data_base.commit();
emit dataBaseUpdated();
}
void projectDataBase::diagramInfoChanged(Diagram *diagram)
@@ -772,7 +817,27 @@ void projectDataBase::prepareQuery()
m_insert_diagram_query = QSqlQuery(m_data_base);
m_insert_diagram_query.prepare("INSERT INTO diagram (uuid, pos) VALUES (:uuid, :pos)");
//REMOVE DIAGRAM
//REMOVE DIAGRAM (cascade first: element_info and terminal have no
//diagram_uuid column of their own, so both are scoped through
//element while the element rows for this diagram still exist).
m_cascade_remove_element_info_query = QSqlQuery(m_data_base);
m_cascade_remove_element_info_query.prepare(
"DELETE FROM element_info WHERE element_uuid IN "
"(SELECT uuid FROM element WHERE diagram_uuid = :uuid)");
m_cascade_remove_terminal_query = QSqlQuery(m_data_base);
m_cascade_remove_terminal_query.prepare(
"DELETE FROM terminal WHERE element_uuid IN "
"(SELECT uuid FROM element WHERE diagram_uuid = :uuid)");
m_cascade_remove_conductor_query = QSqlQuery(m_data_base);
m_cascade_remove_conductor_query.prepare(
"DELETE FROM conductor WHERE diagram_uuid = :uuid");
m_cascade_remove_element_query = QSqlQuery(m_data_base);
m_cascade_remove_element_query.prepare(
"DELETE FROM element WHERE diagram_uuid = :uuid");
m_remove_diagram_query = QSqlQuery(m_data_base);
m_remove_diagram_query.prepare("DELETE FROM diagram WHERE uuid=:uuid");
+5 -1
View File
@@ -106,7 +106,11 @@ class projectDataBase : public QObject
m_insert_terminal_query,
m_insert_conductor_query,
m_update_conductor_query,
m_remove_conductor_query;
m_remove_conductor_query,
m_cascade_remove_element_info_query,
m_cascade_remove_terminal_query,
m_cascade_remove_conductor_query,
m_cascade_remove_element_query;
#ifdef QET_EXPORT_PROJECT_DB
public:
+58
View File
@@ -40,9 +40,53 @@
#include "undocommand/addelementtextcommand.h"
#include "qetinformation.h"
#include "qetproject.h"
#include "diagramsortkeys.h"
#include <algorithm>
#include <cassert>
#include <math.h>
namespace {
using DiagramSortKeys::positionKey;
/// Sort key for Diagram::toXml()'s <elements> block: the element's own
/// diagram-local position, exactly what it's already saved as (x/y),
/// never invented or regenerated. uuid() is deliberately NOT used here:
/// for an element with no persisted uuid attribute, fromXml() invents a
/// fresh random one on every load, so sorting by uuid would still be
/// non-deterministic across process runs for any legacy file.
QString elementSortKey(Element *elmt)
{
return positionKey(elmt->pos());
}
/// Sort key for a terminal: its parent element's position, then the
/// terminal's own position local to that element (from the .elmt
/// definition, fixed regardless of where the element is placed).
QString terminalSortKey(Terminal *terminal)
{
if (!terminal)
return QString();
Element *parent = terminal->parentElement();
return (parent ? positionKey(parent->pos()) : QStringLiteral("?"))
+ QLatin1Char(':') + positionKey(terminal->pos());
}
/// Sort key for Diagram::toXml()'s <conductors> block. Built from both
/// endpoints' terminalSortKey(), not Conductor::uuid(): in every example
/// project checked, conductors have no persisted uuid attribute at all,
/// so uuid() is a freshly-minted random value on every load -- exactly
/// as unusable for cross-run determinism as the element case above, just
/// with no persisted fallback to reach for instead. Canonicalised
/// (smaller key first) since a conductor's two ends are unordered for
/// this purpose.
QString conductorSortKey(Conductor *cond)
{
QString a = terminalSortKey(cond->terminal1);
QString b = terminalSortKey(cond->terminal2);
return (a <= b) ? (a + QLatin1Char('>') + b) : (b + QLatin1Char('>') + a);
}
}
int Diagram::xGrid = 10;
int Diagram::yGrid = 10;
int Diagram::xKeyGrid = 10;
@@ -1014,6 +1058,20 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) {
}
}
// items() returns items in stacking order, which is not guaranteed
// reproducible across processes (ties between same-Z items follow
// the scene's internal index, not any content-derived order) -- so
// without this, saving an unmodified project produces a different
// byte stream on every run. Elements and conductors are the two
// blocks observed to actually churn across the example corpus;
// sort them into a deterministic, content-derived order before
// serializing. This also fixes the legacy terminal-id churn below,
// since those ids are assigned sequentially in element order.
std::stable_sort(list_elements.begin(), list_elements.end(),
[](Element *a, Element *b) { return elementSortKey(a) < elementSortKey(b); });
std::stable_sort(list_conductors.begin(), list_conductors.end(),
[](Conductor *a, Conductor *b) { return conductorSortKey(a) < conductorSortKey(b); });
// correspondence table between the addresses of the terminals and their ids
// table de correspondance entre les adresses des bornes et leurs ids
QHash<Terminal *, int> table_adr_id;
+53
View File
@@ -0,0 +1,53 @@
/*
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 DIAGRAMSORTKEYS_H
#define DIAGRAMSORTKEYS_H
#include <QPointF>
#include <QString>
#include <QtGlobal>
/// Sort-key helpers used by Diagram::toXml() to serialize elements and
/// conductors in a deterministic order. Pulled into their own header
/// (instead of an anonymous namespace in diagram.cpp) so they can be
/// unit-tested directly -- see tests/qttest/tst_diagramsortkeys.cpp.
namespace DiagramSortKeys {
/// Format a coordinate as a string that sorts the same way the number
/// does. Plain fixed-precision formatting ("%.4f") does NOT do this --
/// e.g. "15.0000" sorts before "5.0000" as text even though 15 > 5 --
/// so shift into a non-negative range and zero-pad to a fixed width
/// before comparing.
inline QString coordinateKey(double v)
{
//Diagram coordinates are nowhere near this range; the offset and
//width just need to be big enough that shifted values are always
//non-negative and always the same digit count.
constexpr double offset = 1e9;
qint64 scaled = qint64(qRound64((v + offset) * 10000.0));
return QStringLiteral("%1").arg(scaled, 20, 10, QLatin1Char('0'));
}
inline QString positionKey(const QPointF &pos)
{
return coordinateKey(pos.x()) + QLatin1Char('|') + coordinateKey(pos.y());
}
}
#endif
+38 -8
View File
@@ -200,18 +200,35 @@ void DxfPaintEngine::drawTextItem(const QPointF &p, const QTextItem &textItem)
/**
@brief DxfPaintEngine::drawPixmap
Not implemented in v1 - out of scope per the design note in the
header (CrossRefItem, the only item exported through this engine so
far, never draws a pixmap). qWarning() rather than a hard failure, so
an item that does call this in the future degrades to "one entity
missing" instead of crashing the whole export.
No DXF dialect this old (AC1006, AutoCAD R10 from 1988) has any
raster image representation at all -- IMAGE/IMAGEDEF wasn't
introduced until R2000, over a decade later, and even there the
pixels are never embedded, only referenced by external file path.
Actually supporting images means upgrading the DXF version target
and managing a second file alongside the DXF; until then, this
draws a placeholder rectangle outline (the item's own destination
rect, mapped through m_world_transform exactly like drawRects()
does) instead of silently dropping the item -- so its position,
size, rotation, and skew all survive the export even though the
picture itself can't yet. A fixed, plain pen, not m_pen: an image
item has no meaningful pen of its own for this engine to have
picked up from a prior updateState().
*/
void DxfPaintEngine::drawPixmap(const QRectF &r, const QPixmap &pm, const QRectF &sr)
{
Q_UNUSED(r)
Q_UNUSED(pm)
Q_UNUSED(sr)
qWarning() << "DxfPaintEngine::drawPixmap: not supported, entity skipped";
QPolygonF corners;
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
QPolygonF mapped;
mapped.reserve(corners.size() + 1);
for (const QPointF &p : corners)
mapped << toDxf(p);
mapped << mapped.first();
Createdxf::drawPolyline(m_filepath, mapped, Createdxf::dxfColor(QPen(Qt::black)));
}
/**
@@ -261,8 +278,21 @@ int DxfPaintDevice::metric(PaintDeviceMetric metric) const
case PdmDepth:
return 24;
case PdmDevicePixelRatio:
case PdmDevicePixelRatioScaled:
return 1;
case PdmDevicePixelRatioScaled:
// Not just 1: Qt's own convention (see QPaintDevice's docs)
// is that this metric equals PdmDevicePixelRatio scaled by
// devicePixelRatioFScale() -- a large constant (65536)
// used internally for sub-integer precision. Returning the
// plain, unscaled ratio here (as this used to) meant
// QPainter's own transform bookkeeping divided by it
// expecting the scaled value, silently shrinking every
// drawPixmap() destination rect by a factor of 65536 --
// this is what was actually behind the placeholder
// rectangle rendering at a barely-visible fraction of its
// real size instead of the size the item's own
// sceneTransform() correctly specified.
return int(1 * QPaintDevice::devicePixelRatioFScale());
default:
return 0;
}
+12 -5
View File
@@ -56,11 +56,18 @@
- fillPath -> same outline-only handling as drawRects; no HATCH
support in v1 (see design note in the PR)
Anything outside this list (images, gradients, etc.) is intentionally
unimplemented and asserts in debug builds rather than silently
producing an incomplete drawing - callers should know immediately if
an item they're exporting uses something this engine doesn't cover
yet, rather than getting a DXF file quietly missing content.
- drawPixmap -> no raster image entity exists in this DXF
dialect at all (see drawPixmap()'s own
comment) -- draws a placeholder rectangle
outline instead, preserving the item's
position/size/rotation/skew even though the
picture itself can't be included yet
Anything else outside this list (gradients, etc.) is intentionally
unimplemented and logs a warning rather than silently producing an
incomplete drawing - callers should know immediately if an item
they're exporting uses something this engine doesn't cover yet,
rather than getting a DXF file quietly missing content.
*/
class DxfPaintEngine : public QPaintEngine
{
+40 -16
View File
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "editorcommands.h"
#include "../diagram.h"
/**
@brief ElementEditionCommand::ElementEditionCommand
@@ -666,10 +667,32 @@ void RotateFineElementsCommand::redo()
}
/**
@brief selectionCenter
@param items
@return the center of the united scene bounding rect of items,
snapped to the nearest half of the diagram grid. Mirroring across a
half-grid line keeps points that were on the grid on the grid.
*/
static QPointF selectionCenter(const QList<QGraphicsItem *> &items)
{
QRectF bounding;
for (auto *item : items) {
bounding = bounding.united(item->sceneBoundingRect());
}
QPointF center = bounding.center();
const qreal half_x = Diagram::xGrid / 2.0;
const qreal half_y = Diagram::yGrid / 2.0;
center.setX(qRound(center.x() / half_x) * half_x);
center.setY(qRound(center.y() / half_y) * half_y);
return center;
}
MirrorElementsCommand::MirrorElementsCommand(ElementScene *scene, QUndoCommand *parent) :
ElementEditionCommand(QObject::tr("Miroir de sélection", "undo caption"), scene, nullptr, parent)
{
m_items = scene->selectedItems();
m_axis_x = selectionCenter(m_items).x();
}
/**
@@ -680,28 +703,28 @@ void MirrorElementsCommand::redo()
foreach (auto *item, m_items) {
if (item->type() == PartText::Type) {
PartText* staticText = qgraphicsitem_cast<PartText*>(item);
staticText->mirror();
staticText->mirror(m_axis_x);
} else if (item->type() == PartDynamicTextField::Type) {
PartDynamicTextField* dyntext = qgraphicsitem_cast<PartDynamicTextField*>(item);
dyntext->mirror();
dyntext->mirror(m_axis_x);
} else if (item->type() == PartArc::Type) {
PartArc* arc = qgraphicsitem_cast<PartArc*>(item);
arc->mirror();
arc->mirror(m_axis_x);
} else if (item->type() == PartEllipse::Type) {
PartEllipse* ellipse = qgraphicsitem_cast<PartEllipse*>(item);
ellipse->mirror();
ellipse->mirror(m_axis_x);
} else if (item->type() == PartLine::Type) {
PartLine* line = qgraphicsitem_cast<PartLine*>(item);
line->mirror();
line->mirror(m_axis_x);
} else if (item->type() == PartPolygon::Type) {
PartPolygon* poly = qgraphicsitem_cast<PartPolygon*>(item);
poly->mirror();
poly->mirror(m_axis_x);
} else if (item->type() == PartRectangle::Type) {
PartRectangle* rect = qgraphicsitem_cast<PartRectangle*>(item);
rect->mirror();
rect->mirror(m_axis_x);
} else if (item->type() == PartTerminal::Type) {
PartTerminal* term = qgraphicsitem_cast<PartTerminal*>(item);
term->mirror();
term->mirror(m_axis_x);
}
}
}
@@ -718,6 +741,7 @@ FlipElementsCommand::FlipElementsCommand(ElementScene *scene, QUndoCommand *pare
ElementEditionCommand(QObject::tr("Retourner la sélection", "undo caption"), scene, nullptr, parent)
{
m_items = scene->selectedItems();
m_axis_y = selectionCenter(m_items).y();
}
/**
@@ -728,28 +752,28 @@ void FlipElementsCommand::redo()
foreach (auto *item, m_items) {
if (item->type() == PartText::Type) {
PartText* staticText = qgraphicsitem_cast<PartText*>(item);
staticText->flip();
staticText->flip(m_axis_y);
} else if (item->type() == PartDynamicTextField::Type) {
PartDynamicTextField* dyntext = qgraphicsitem_cast<PartDynamicTextField*>(item);
dyntext->flip();
dyntext->flip(m_axis_y);
} else if (item->type() == PartArc::Type) {
PartArc* arc = qgraphicsitem_cast<PartArc*>(item);
arc->flip();
arc->flip(m_axis_y);
} else if (item->type() == PartEllipse::Type) {
PartEllipse* ellipse = qgraphicsitem_cast<PartEllipse*>(item);
ellipse->flip();
ellipse->flip(m_axis_y);
} else if (item->type() == PartLine::Type) {
PartLine* line = qgraphicsitem_cast<PartLine*>(item);
line->flip();
line->flip(m_axis_y);
} else if (item->type() == PartPolygon::Type) {
PartPolygon* poly = qgraphicsitem_cast<PartPolygon*>(item);
poly->flip();
poly->flip(m_axis_y);
} else if (item->type() == PartRectangle::Type) {
PartRectangle* rect = qgraphicsitem_cast<PartRectangle*>(item);
rect->flip();
rect->flip(m_axis_y);
} else if (item->type() == PartTerminal::Type) {
PartTerminal* term = qgraphicsitem_cast<PartTerminal*>(item);
term->flip();
term->flip(m_axis_y);
}
}
}
+14
View File
@@ -279,6 +279,12 @@ private:
};
/**
@brief The MirrorElementsCommand class
Mirror the selected parts horizontally (left <-> right) across the
vertical line through the center of the selection, so the selection
keeps its place in the scene.
*/
class MirrorElementsCommand : public ElementEditionCommand
{
public:
@@ -288,8 +294,15 @@ public:
private:
ElementScene *m_scene =nullptr;
QList<QGraphicsItem*> m_items;
qreal m_axis_x = 0;
};
/**
@brief The FlipElementsCommand class
Flip the selected parts vertically (top <-> bottom) across the
horizontal line through the center of the selection, so the selection
keeps its place in the scene.
*/
class FlipElementsCommand : public ElementEditionCommand
{
public:
@@ -299,6 +312,7 @@ public:
private:
ElementScene *m_scene =nullptr;
QList<QGraphicsItem*> m_items;
qreal m_axis_y = 0;
};
#endif
+4 -4
View File
@@ -197,13 +197,13 @@ qreal PartArc::rotation() const {
return qRound(m_rot * 100.0) / 100.0;
}
void PartArc::flip() {
void PartArc::flip(qreal axis_y) {
m_start_angle = (-1) * m_start_angle;
m_span_angle = (-1) * m_span_angle;
while (m_start_angle < 0) { m_start_angle += (360*16); }
while (m_start_angle >= (360*16)) { m_start_angle -= (360*16); }
auto p1 = mapToScene(m_rect.x(),m_rect.y());
p1.setY(((-1.0) * p1.y()) - m_rect.height());
p1.setY(2 * axis_y - p1.y() - m_rect.height());
p1 = mapFromScene(p1.x(),p1.y());
m_rect = QRectF(m_rect.x(), p1.y(), m_rect.width(), m_rect.height());
prepareGeometryChange();
@@ -211,13 +211,13 @@ void PartArc::flip() {
emit rectChanged();
}
void PartArc::mirror() {
void PartArc::mirror(qreal axis_x) {
m_start_angle = (180.0 * 16) - m_start_angle;
m_span_angle = (-1) * m_span_angle;
while (m_start_angle < 0) { m_start_angle += (360*16); }
while (m_start_angle >= (360*16)) { m_start_angle -= (360*16); }
auto p1 = mapToScene(m_rect.x(),m_rect.y());
p1.setX(((-1.0) * p1.x()) - m_rect.width());
p1.setX(2 * axis_x - p1.x() - m_rect.width());
p1 = mapFromScene(p1.x(), p1.y());
m_rect = QRectF(p1.x(), m_rect.y(), m_rect.width(), m_rect.height());
prepareGeometryChange();
+2 -2
View File
@@ -62,8 +62,8 @@ class PartArc : public AbstractPartEllipse
QRectF sceneGeometricRect() const override;
void setRotation(qreal angle);
qreal rotation() const;
void flip();
void mirror();
void flip(qreal axis_y = 0);
void mirror(qreal axis_x = 0);
void addHandler() override;
void removeHandler() override;
@@ -74,19 +74,19 @@ void PartDynamicTextField::setRotation(qreal angle) {
setPos(QTransform().rotate(diffAngle).map(pos()));
}
void PartDynamicTextField::mirror() {
void PartDynamicTextField::mirror(qreal axis_x) {
// at first: rotate the text:
QGraphicsObject::setRotation(QET::correctAngle(360-rotation(), true));
// then see, where we need to re-position depending on the angle!
qreal rot = qRound(QET::correctAngle(rotation(), true));
qreal c = qCos(qDegreesToRadians(rot));
qreal s = qSin(qDegreesToRadians(rot));
qreal x = (-1) * pos().x() - c * boundingRect().width();
qreal x = 2 * axis_x - pos().x() - c * boundingRect().width();
qreal y = pos().y() - s * boundingRect().width();
setPos(x, y);
}
void PartDynamicTextField::flip() {
void PartDynamicTextField::flip(qreal axis_y) {
// at first: rotate the text:
QGraphicsObject::setRotation(QET::correctAngle(360-rotation(), true));
// then see, where we need to re-position depending on the angle!
@@ -94,7 +94,7 @@ void PartDynamicTextField::flip() {
qreal c = qCos(qDegreesToRadians(rot));
qreal s = qSin(qDegreesToRadians(rot));
qreal x = pos().x() + s * boundingRect().height();
qreal y = (-1) * pos().y() - c * boundingRect().height();
qreal y = 2 * axis_y - pos().y() - c * boundingRect().height();
setPos(x, y);
}
@@ -106,8 +106,8 @@ class PartDynamicTextField : public QGraphicsTextItem, public CustomElementPart
bool rotationPointCenter() const;
void setRotation(qreal angle);
void mirror();
void flip();
void mirror(qreal axis_x = 0);
void flip(qreal axis_y = 0);
protected:
+4 -4
View File
@@ -256,9 +256,9 @@ qreal PartEllipse::rotation() const {
return qRound(m_rot * 100.0) / 100.0;
}
void PartEllipse::flip() {
void PartEllipse::flip(qreal axis_y) {
auto p1 = mapToScene(m_rect.x(), m_rect.y());
p1.setY(((-1.0) * p1.y()) - m_rect.height());
p1.setY(2 * axis_y - p1.y() - m_rect.height());
p1 = mapFromScene(p1.x(), p1.y());
m_rect = QRectF(p1.x(), p1.y(), m_rect.width(), m_rect.height());
prepareGeometryChange();
@@ -266,9 +266,9 @@ void PartEllipse::flip() {
emit rectChanged();
}
void PartEllipse::mirror() {
void PartEllipse::mirror(qreal axis_x) {
auto p1 = mapToScene(m_rect.x(), m_rect.y());
p1.setX(((-1.0) * p1.x()) - m_rect.width());
p1.setX(2 * axis_x - p1.x() - m_rect.width());
p1 = mapFromScene(p1.x(), p1.y());
m_rect = QRectF(p1.x(), p1.y(), m_rect.width(), m_rect.height());
prepareGeometryChange();
+2 -2
View File
@@ -62,8 +62,8 @@ class PartEllipse : public AbstractPartEllipse
void setRect(const QRectF &rect) override {AbstractPartEllipse::setRect(rect); adjustHandlerPos();}
void setRotation(qreal angle);
qreal rotation() const;
void flip();
void mirror();
void flip(qreal axis_y = 0);
void mirror(qreal axis_x = 0);
void addHandler() override;
void removeHandler() override;
+6 -6
View File
@@ -590,11 +590,11 @@ qreal PartLine::rotation() const {
return qRound(m_rot * 100.0) / 100.0;
}
void PartLine::flip() {
void PartLine::flip(qreal axis_y) {
auto p1 = mapToScene(m_line.p1());
auto p2 = mapToScene(m_line.p2());
p1 = QPointF(p1.x(), (-1) * p1.y());
p2 = QPointF(p2.x(), (-1) * p2.y());
p1 = QPointF(p1.x(), 2 * axis_y - p1.y());
p2 = QPointF(p2.x(), 2 * axis_y - p2.y());
m_line.setP1(mapFromScene(p1));
m_line.setP2(mapFromScene(p2));
setLine(m_line);
@@ -603,11 +603,11 @@ void PartLine::flip() {
emit lineChanged();
}
void PartLine::mirror() {
void PartLine::mirror(qreal axis_x) {
auto p1 = mapToScene(m_line.p1());
auto p2 = mapToScene(m_line.p2());
p1 = QPointF((-1) * p1.x(), p1.y());
p2 = QPointF((-1) * p2.x(), p2.y());
p1 = QPointF(2 * axis_x - p1.x(), p1.y());
p2 = QPointF(2 * axis_x - p2.x(), p2.y());
m_line.setP1(mapFromScene(p1));
m_line.setP2(mapFromScene(p2));
setLine(m_line);
+2 -2
View File
@@ -96,8 +96,8 @@ class PartLine : public CustomElementGraphicPart
void setSecondEndLength(const qreal &l);
void setRotation(qreal angle);
qreal rotation() const;
void flip();
void mirror();
void flip(qreal axis_y = 0);
void mirror(qreal axis_x = 0);
void addHandler() override;
void removeHandler() override;
+4 -4
View File
@@ -307,10 +307,10 @@ qreal PartPolygon::rotation() const {
return qRound(m_rot * 100.0) / 100.0;
}
void PartPolygon::flip() {
void PartPolygon::flip(qreal axis_y) {
for (auto &pt : m_polygon) {
pt = mapToScene(pt.x(), pt.y());
pt = QPointF(pt.x(), (-1) * pt.y());
pt = QPointF(pt.x(), 2 * axis_y - pt.y());
pt = mapFromScene(pt.x(), pt.y());
}
setPolygon(m_polygon);
@@ -319,10 +319,10 @@ void PartPolygon::flip() {
emit polygonChanged();
}
void PartPolygon::mirror() {
void PartPolygon::mirror(qreal axis_x) {
for (auto &pt : m_polygon) {
pt = mapToScene(pt.x(), pt.y());
pt = QPointF((-1) * pt.x(), pt.y());
pt = QPointF(2 * axis_x - pt.x(), pt.y());
pt = mapFromScene(pt.x(), pt.y());
}
setPolygon(m_polygon);
+2 -2
View File
@@ -89,8 +89,8 @@ class PartPolygon : public CustomElementGraphicPart
void setRotation (qreal angle);
qreal rotation () const;
void flip();
void mirror();
void flip(qreal axis_y = 0);
void mirror(qreal axis_x = 0);
void addHandler() override;
void removeHandler() override;
@@ -187,11 +187,11 @@ qreal PartRectangle::rotation() const {
return qRound(m_rot * 100.0) / 100.0;
}
void PartRectangle::flip() {
void PartRectangle::flip(qreal axis_y) {
auto height = m_rect.height();
auto p1 = mapToScene(m_rect.x(),m_rect.y());
qreal x = p1.x();
qreal y = ((-1.0) * p1.y()) - height;
qreal y = 2 * axis_y - p1.y() - height;
p1 = mapFromScene(x, y);
m_rect.setX(p1.x());
m_rect.setY(p1.y());
@@ -201,10 +201,10 @@ void PartRectangle::flip() {
emit rectChanged();
}
void PartRectangle::mirror() {
void PartRectangle::mirror(qreal axis_x) {
auto width = m_rect.width();
auto p1 = mapToScene(m_rect.x(),m_rect.y());
qreal x = ((-1.0) * p1.x()) - width;
qreal x = 2 * axis_x - p1.x() - width;
qreal y = p1.y();
p1 = mapFromScene(x, y);
m_rect.setX(p1.x());
+2 -2
View File
@@ -73,8 +73,8 @@ class PartRectangle : public CustomElementGraphicPart
void setYRadius(qreal Y);
void setRotation(qreal angle);
qreal rotation() const;
void flip();
void mirror();
void flip(qreal axis_y = 0);
void mirror(qreal axis_x = 0);
QRectF sceneGeometricRect() const override;
virtual QPointF sceneTopLeft() const;
+4 -4
View File
@@ -293,9 +293,9 @@ qreal PartTerminal::rotation() const {
@brief PartTerminal::flip
turn part upside down
*/
void PartTerminal::flip() {
void PartTerminal::flip(qreal axis_y) {
d->m_pos.setX( pos().x());
d->m_pos.setY((-1.0) * pos().y());
d->m_pos.setY(2 * axis_y - pos().y());
switch (d->m_orientation) {
case Qet::North : setOrientation(Qet::South);
break;
@@ -314,8 +314,8 @@ void PartTerminal::flip() {
@brief PartTerminal::mirror
turn part from left to right
*/
void PartTerminal::mirror() {
d->m_pos.setX((-1.0) * pos().x());
void PartTerminal::mirror(qreal axis_x) {
d->m_pos.setX(2 * axis_x - pos().x());
d->m_pos.setY( pos().y());
switch (d->m_orientation) {
case Qet::North : break;
+2 -2
View File
@@ -96,8 +96,8 @@ class PartTerminal : public CustomElementGraphicPart
void setRotation(qreal angle);
qreal rotation() const;
void flip();
void mirror();
void flip(qreal axis_y = 0);
void mirror(qreal axis_x = 0);
void nextOrientation();
void previousOrientation();
+4 -4
View File
@@ -75,7 +75,7 @@ void PartText::setRotation(qreal angle) {
setPos(QTransform().rotate(diffAngle).map(pos()));
}
void PartText::mirror() {
void PartText::mirror(qreal axis_x) {
// at first: rotate the text:
QGraphicsObject::setRotation(QET::correctAngle((360-rotation()), true));
// then see, where we need to re-position depending on text, font ...
@@ -86,12 +86,12 @@ void PartText::mirror() {
qreal c = qCos(qDegreesToRadians(rot));
qreal s = qSin(qDegreesToRadians(rot));
// Now: Move!
qreal x = (-1) * pos().x() - c * (textwidth);
qreal x = 2 * axis_x - pos().x() - c * (textwidth);
qreal y = pos().y() - s * (textwidth);
setPos(x, y);
}
void PartText::flip() {
void PartText::flip(qreal axis_y) {
// at first: rotate the text:
QGraphicsObject::setRotation(QET::correctAngle((360-rotation()), true));
// then see, where we need to re-position depending on text, font ...
@@ -103,7 +103,7 @@ void PartText::flip() {
qreal s = qSin(qDegreesToRadians(rot));
// Now: Move!
qreal x = pos().x() - s * (textheight);
qreal y = (-1) * pos().y() + c * (textheight);
qreal y = 2 * axis_y - pos().y() + c * (textheight);
setPos(x, y);
}
+2 -2
View File
@@ -64,8 +64,8 @@ class PartText : public QGraphicsTextItem, public CustomElementPart {
void fromXml(const QDomElement &) override;
const QDomElement toXml(QDomDocument &) const override;
void setRotation(qreal angle);
void mirror();
void flip();
void mirror(qreal axis_x = 0);
void flip(qreal axis_y = 0);
bool isUseless() const override;
QRectF sceneGeometricRect() const override;
void startUserTransformation(const QRectF &) override;
+55
View File
@@ -744,6 +744,25 @@ void ExportDialog::generateDxf(
painter.end();
}
//Draw images -- collected above (list_images) but never actually
//drawn until now, an existing gap this reuses the same paint()
//-replay approach to fix: DiagramImageItem::paint() has no
//viewport-dependent logic (unlike CrossRefItem, which needs its own
//paintForExport() for that reason), so it's called directly with a
//default QStyleOptionGraphicsItem rather than needing an export-
//specific variant of its own. DxfPaintEngine::drawPixmap() is what
//actually turns the drawPixmap() call inside paint() into a
//placeholder outline, since this DXF dialect has no raster image
//entity to draw instead.
for (DiagramImageItem *image : std::as_const(list_images))
{
DxfPaintDevice dxf_device(file_path);
QPainter painter(&dxf_device);
painter.setWorldTransform(image->sceneTransform());
image->paintForExport(&painter);
painter.end();
}
Createdxf::dxfEnd(file_path);
saveReloadDiagramParameters(diagram, false);
@@ -818,6 +837,42 @@ void ExportDialog::slot_export()
return;
}
// Warn once, up front, rather than per-diagram: this DXF dialect
// (AC1006, AutoCAD R10) has no raster image representation at all
// (IMAGE/IMAGEDEF wasn't introduced until R2000, over a decade
// later, and even there the picture is never embedded, only
// referenced by external file path) -- so any image ends up as a
// placeholder rectangle outline instead (see DxfPaintEngine::
// drawPixmap()), with its position/size/rotation/skew preserved but
// not its actual content.
if (epw -> exportProperties().format.compare(QLatin1String("dxf"), Qt::CaseInsensitive) == 0)
{
bool any_images = false;
for (ExportDiagramLine *diagram_line : std::as_const(diagrams_to_export))
{
for (QGraphicsItem *item : diagram_line->diagram->items())
{
if (qgraphicsitem_cast<DiagramImageItem *>(item)) {
any_images = true;
break;
}
}
if (any_images) break;
}
if (any_images)
{
QET::QetMessageBox::warning(
this,
tr("Images non incluses dans l'export DXF", "message box title"),
tr("Le format DXF utilisé ici (AC1006) ne permet pas d'inclure d'image. "
"Les images seront représentées uniquement par un rectangle de contour "
"(position, taille, rotation et inclinaison conservées), sans le contenu de l'image.",
"message box content")
);
}
}
// exporte chaque schema a exporter
foreach(ExportDiagramLine *diagram_line, diagrams_to_export) {
exportDiagram(diagram_line);
+13 -5
View File
@@ -297,11 +297,19 @@ QString QET::ElementsAndConductorsSentence(
if (images_count) {
if (!text.isEmpty()) text += ", ";
text += QObject::tr(
"%n image(s)",
"part of a sentence listing the content of a diagram",
images_count
);
// Qt's %n only selects a grammatical singular/plural form (the
// "(s)" convention used by every other count here) -- it never
// spells the number out as a word, so getting "une image"
// instead of the literal "1 image" for the single-item case
// means handling that count outside %n entirely, with its own
// fixed string.
text += images_count == 1
? QObject::tr("une image", "part of a sentence listing the content of a diagram")
: QObject::tr(
"%n images",
"part of a sentence listing the content of a diagram",
images_count
);
}
if (shapes_count) {
+7 -2
View File
@@ -1011,10 +1011,14 @@ bool Conductor::fromXml(QDomElement &dom_element)
//that field was introduced (see terminal1/terminal2 handling in
//toXml() below).
m_uuid = QUuid(dom_element.attribute(QStringLiteral("uuid")));
m_persist_uuid = !m_uuid.isNull();
if (m_uuid.isNull()) {
//Absent, empty or malformed: mint one. A null uuid is not a usable
//identity -- every conductor carrying one would collide with every
//other on the conductor table's primary key.
//other on the conductor table's primary key. It's runtime-only,
//though: toXml() must not write it back out, or a legacy file
//with no conductor uuids gets a freshly different one on every
//single load-and-resave (see #754).
m_uuid = QUuid::createUuid();
}
@@ -1056,7 +1060,8 @@ QDomElement Conductor::toXml(QDomDocument &dom_document,
{
QDomElement dom_element = dom_document.createElement("conductor");
dom_element.setAttribute("uuid", m_uuid.toString());
if (m_persist_uuid)
dom_element.setAttribute("uuid", m_uuid.toString());
dom_element.setAttribute("x", QString::number(pos().x()));
dom_element.setAttribute("y", QString::number(pos().y()));
+5 -1
View File
@@ -79,7 +79,7 @@ class Conductor : public QGraphicsObject
Diagram *diagram() const;
ConductorTextItem *textItem() const;
QUuid uuid() const {return m_uuid;}
void newUuid() {m_uuid = QUuid::createUuid();} //create new uuid for this conductor
void newUuid() {m_uuid = QUuid::createUuid(); m_persist_uuid = true;} //create new uuid for this conductor
void updatePath(const QRectF & = QRectF());
//This method do nothing, it's only made to be used with Q_PROPERTY
@@ -209,6 +209,10 @@ class Conductor : public QGraphicsObject
bool m_valid;
bool m_freeze_label = false;
QUuid m_uuid;
/// false when m_uuid was synthesized by fromXml() because the
/// file had none -- toXml() must not persist that value, or
/// every reload mints and saves a new random one (see #754).
bool m_persist_uuid = true;
/// QPen et QBrush objects used to draw conductors
static QPen conductor_pen;
+273 -27
View File
@@ -24,6 +24,7 @@
#include "../qet.h"
#include "../qetapp.h"
#include "../qetdiagrameditor.h"
#include "../qeticons.h"
#include "../ui/imagepropertieswidget.h"
#include "../ui/imagecropdialog.h"
#include "../ui/imagetransparentcolordialog.h"
@@ -31,10 +32,14 @@
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
#include <QAction>
#include <QBuffer>
#include <QFileDialog>
#include <QFileInfo>
#include <QGraphicsSceneContextMenuEvent>
#include <QImageWriter>
#include <QMenu>
#include <QMessageBox>
#include <QTextStream>
/**
@brief DiagramImageItem::DiagramImageItem
@@ -811,11 +816,16 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
else
undo = new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY);
}
if (undo)
undo->setText(tr("Redimensionner une image"));
break;
case HandleRole::Rotate:
if (!qFuzzyCompare(m_transform.rotation, m_original_transform.rotation))
{
undo = new QPropertyUndoCommand(this, "rotationAngle", m_original_transform.rotation, m_transform.rotation);
undo->setText(tr("Faire pivoter une image"));
}
break;
case HandleRole::SkewEdge:
@@ -823,6 +833,8 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
undo = new QPropertyUndoCommand(this, "skewX", m_original_transform.skewX, m_transform.skewX);
else if (!qFuzzyCompare(m_transform.skewY, m_original_transform.skewY))
undo = new QPropertyUndoCommand(this, "skewY", m_original_transform.skewY, m_transform.skewY);
if (undo)
undo->setText(tr("Incliner une image"));
break;
case HandleRole::Pivot:
@@ -837,8 +849,28 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
if (undo)
{
// Defensive fallback only -- every role above now sets its
// own, distinct label directly (Resize/Rotate/SkewEdge used
// to all fall through to this same generic text, making the
// undo list unable to tell three completely different edits
// apart); this only still matters if some future role is
// ever added without setting one of its own.
if (undo->text().isEmpty())
undo->setText(tr("Modifier une image"));
// Every push here is one complete, finished gesture (press,
// drag, release) -- never a continuation of an earlier one.
// QPropertyUndoCommand::mergeWith() already treats any
// command with children as never mergeable; a dummy child
// guarantees that here regardless of which role produced
// undo. Without this, a single-property change (Rotate and
// SkewEdge always are; Resize sometimes is, when only one
// axis actually changed) would silently coalesce into
// whatever identically-labelled edit came right before it
// -- even after a deselect/reselect proved they were two
// separate actions, since the shared label alone is
// otherwise indistinguishable from a genuine continuation.
new QUndoCommand(undo);
diagram()->undoStack().push(undo);
}
}
@@ -1029,6 +1061,177 @@ void DiagramImageItem::restoreAspectRatio()
diagram()->undoStack().push(undo);
}
/**
@brief DiagramImageItem::saveImageAs
Saves the currently displayed pixmap (crop and colour-keyed
transparency already applied -- what the item actually looks like
on the diagram, not the pristine original) to an arbitrary file on
disk.
*/
void DiagramImageItem::saveImageAs()
{
saveImagePixmapAs(pixmap_, tr("Enregistrer l'image sous..."), !m_transparent_colors.isEmpty());
}
/**
@brief DiagramImageItem::saveOriginalImageAs
Saves m_base_pixmap -- the true, pristine original, before any crop
or colour-keyed transparency -- rather than the item's current,
possibly-cropped-and-keyed display pixmap. The only way to recover
the un-cropped, un-keyed source once either of those has actually
been applied, short of undoing every edit back to the point it was
first inserted or replaced.
*/
void DiagramImageItem::saveOriginalImageAs()
{
saveImagePixmapAs(m_base_pixmap, tr("Enregistrer l'image d'origine sous..."), false);
}
/**
@brief DiagramImageItem::saveImagePixmapAs
Shared by saveImageAs() and saveOriginalImageAs(): prompts for a
destination, resolves whichever format was actually intended, warns
before silently dropping transparency, and writes the file. A
read-only export, not an edit: doesn't touch diagram()'s undo stack,
and works even on a read-only diagram, unlike every other action in
this item's context menu.
@param pixmap the pixmap to save -- pixmap_ or m_base_pixmap
@param dialogTitle distinguishes the two callers in the save dialog's own title bar
@param hasTransparency whether `pixmap` has colour-keyed transparency worth warning about losing (never true for the pristine original, which predates any such keying)
*/
void DiagramImageItem::saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency)
{
QWidget *parentWidget = (diagram() && !diagram()->views().isEmpty()) ? diagram()->views().first() : nullptr;
// Filter text -> extension. Built once and used both to construct
// the dialog's filter list and, below, to resolve whichever one the
// person actually had selected -- rather than only ever falling
// back to a single hardcoded format regardless of their choice,
// which is what this used to do (getSaveFileName() doesn't reliably
// auto-append the selected filter's extension on every platform,
// and the previous version's fallback ignored the selected filter
// entirely, defaulting to PNG even when JPEG or BMP had been
// explicitly chosen).
const QList<QPair<QString, QString>> filters = {
{tr("Image PNG (*.png)"), QStringLiteral("png")},
{tr("Image JPEG (*.jpg *.jpeg)"), QStringLiteral("jpg")},
{tr("Image BMP (*.bmp)"), QStringLiteral("bmp")},
// SVG here always means a raster image wrapped in an SVG
// container (an <image> element embedding this same pixmap as
// base64 PNG), never a true vector export -- this item only
// ever holds raster data, even when originally inserted from an
// SVG file, since that file was rasterized once at import time
// and its vector information is already gone by the time this
// runs.
{tr("Image SVG (*.svg)"), QStringLiteral("svg")},
};
QStringList filterStrings;
for (const auto &f : filters)
filterStrings << f.first;
filterStrings << tr("Tous les fichiers (*)");
QString selectedFilter;
QString path = QFileDialog::getSaveFileName(
parentWidget, dialogTitle, QString(), filterStrings.join(QLatin1String(";;")), &selectedFilter);
if (path.isEmpty())
return;
// Resolve the target format: prefer an extension already present
// and actually writable, otherwise fall back to whichever filter
// was selected in the dialog (not a fixed default), so the format
// picked in the dropdown is the one that's actually honoured even
// when the typed name carries no extension of its own.
QString suffix = QFileInfo(path).suffix().toLower();
const bool suffixIsSvg = (suffix == QLatin1String("svg"));
const bool suffixIsWritableRaster = QImageWriter::supportedImageFormats().contains(suffix.toUtf8());
if (!suffixIsSvg && !suffixIsWritableRaster)
{
QString fallbackExt = QStringLiteral("png");
for (const auto &f : filters)
if (f.first == selectedFilter) { fallbackExt = f.second; break; }
path += QLatin1Char('.') + fallbackExt;
suffix = fallbackExt;
}
// JPEG and BMP have no usable alpha channel here -- Qt's writers for
// both silently drop it. That alone wouldn't be too bad (the
// warning already tells the person transparency won't survive),
// except pixmap_ itself has already lost the true colour
// information wherever it's transparent by this point: QPixmap
// stores its data premultiplied internally, which zeroes out RGB
// at alpha=0 the moment applyColorKey()'s QImage result gets
// wrapped into a QPixmap -- confirmed directly, not assumed
// (QPixmap::fromImage() on a white, alpha=0 pixel reliably comes
// back black on toImage()). Saving pixmap_ as-is to a format with
// no alpha channel doesn't fall back to the original picture, it
// reveals that already-lost black. The fix below substitutes the
// cropped base pixmap for the save in this specific case -- it has
// the same, correct RGB values everywhere applyColorKey() left a
// pixel opaque (colour-keying only ever changes alpha, never RGB),
// so it's an exact reproduction of what the image looked like
// before any colour was ever keyed out, not an approximation.
const bool formatPreservesAlpha = (suffix == QLatin1String("png") || suffix == QLatin1String("svg"));
QPixmap toSave = pixmap;
if (hasTransparency && !formatPreservesAlpha)
{
if (QMessageBox::warning(parentWidget,
tr("Transparence non conservée"),
tr("Ce format ne prend pas en charge la transparence : l'image sera enregistrée "
"telle qu'elle était avant l'application de la couleur transparente. Continuer ?"),
QMessageBox::Yes | QMessageBox::Cancel) != QMessageBox::Yes)
return;
toSave = m_base_pixmap.copy(m_crop_rect);
}
bool ok;
if (suffix == QLatin1String("svg"))
ok = writeRasterAsSvg(toSave, path);
else
ok = toSave.save(path);
if (!ok)
{
QMessageBox::warning(parentWidget,
tr("Échec de l'enregistrement"),
tr("Impossible d'enregistrer l'image à cet emplacement."));
}
}
/**
@brief DiagramImageItem::writeRasterAsSvg
Wraps `pixmap` as a base64-embedded PNG inside a minimal, valid SVG
document -- the closest this can honestly offer to "save as SVG"
given this item only ever holds raster data (see the note in
saveImagePixmapAs()). Opens in any SVG viewer at the pixmap's own
pixel size, but is not, and cannot be, a vector re-export.
@param pixmap the raster image to embed
@param path destination file path
@return whether the file was written successfully
*/
bool DiagramImageItem::writeRasterAsSvg(const QPixmap &pixmap, const QString &path)
{
QByteArray pngData;
QBuffer buffer(&pngData);
buffer.open(QIODevice::WriteOnly);
if (!pixmap.save(&buffer, "PNG"))
return false;
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
return false;
QTextStream out(&file);
out << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
<< "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" "
<< "width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
<< "viewBox=\"0 0 " << pixmap.width() << ' ' << pixmap.height() << "\">\n"
<< " <image width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
<< "xlink:href=\"data:image/png;base64," << QString::fromLatin1(pngData.toBase64()) << "\"/>\n"
<< "</svg>\n";
return true;
}
/**
@brief DiagramImageItem::itemChange
*/
@@ -1064,23 +1267,24 @@ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant
@brief DiagramImageItem::computeDisplayPixmap
Re-derives what pixmap_ should be from first principles: crop the
true original down to the chosen region, then colour-key whichever
colours have been picked out of it. Used whenever crop() or
setTransparentColor() changes one of those two independently, so
the other's effect is correctly re-applied on top rather than lost
or compounded -- cropping after colours were already picked has to
still show them keyed out; picking colours after a crop has to only
ever consider what's still actually part of the image.
colours have been picked out of it, each at its own tolerance. Used
whenever crop() or setTransparentColor() changes one of those two
independently, so the other's effect is correctly re-applied on top
rather than lost or compounded -- cropping after colours were
already picked has to still show them keyed out; picking colours
after a crop has to only ever consider what's still actually part
of the image.
@param base the true, uncropped original
@param cropRect the region of base to keep, in base's own coordinates
@param colors colours to key transparent within the cropped region
@param tolerance how loosely to match those colours, 0-100
@param colors colours (each with its own tolerance) to key transparent within the cropped region
*/
QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<QColor> &colors, int tolerance)
QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect,
const QList<ImageTransparentColorDialog::PickedColor> &colors)
{
const QPixmap cropped = cropRect == base.rect() ? base : base.copy(cropRect);
if (colors.isEmpty())
return cropped;
return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors, tolerance));
return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors));
}
/**
@@ -1142,19 +1346,25 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
m_base_pixmap = pixmap;
m_crop_rect = pixmap.rect();
m_transparent_colors.clear();
m_transparent_tolerance = 10;
const QDomElement colorsElement = e.firstChildElement("transparent_colors");
bool hasColors = !colorsElement.isNull();
if (hasColors)
{
m_transparent_tolerance = colorsElement.attribute("tolerance", "10").toInt();
// Files saved before per-colour tolerance existed wrote a
// single value on the wrapper element itself, shared by every
// colour -- kept here purely as the fallback default for a
// <color> that doesn't carry its own attribute, which for
// those old files is every one of them, exactly reproducing
// what they used to do (one tolerance applied to all of them).
const int wrapperTolerance = colorsElement.attribute("tolerance", "10").toInt();
for (const QDomElement &colorElement : QET::findInDomElement(colorsElement, "color"))
{
m_transparent_colors.append(QColor(
colorElement.attribute("r").toInt(),
colorElement.attribute("g").toInt(),
colorElement.attribute("b").toInt()));
m_transparent_colors.append({
QColor(colorElement.attribute("r").toInt(),
colorElement.attribute("g").toInt(),
colorElement.attribute("b").toInt()),
colorElement.attribute("tolerance", QString::number(wrapperTolerance)).toInt()});
}
}
@@ -1296,13 +1506,21 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const
if (hasColors)
{
QDomElement colorsElement = document.createElement("transparent_colors");
colorsElement.setAttribute("tolerance", m_transparent_tolerance);
for (const QColor &color : m_transparent_colors)
// Best-effort fallback for an OLDER version of this same code
// (from before per-colour tolerance existed) reading a file
// saved by this one: uses the first colour's own tolerance as
// a single, plausible value rather than some fixed default,
// in case it ever needs to open a file like this. Newer code
// (including this version) always prefers each <color>'s own
// attribute below over this one.
colorsElement.setAttribute("tolerance", m_transparent_colors.first().tolerance);
for (const auto &pc : m_transparent_colors)
{
QDomElement colorElement = document.createElement("color");
colorElement.setAttribute("r", color.red());
colorElement.setAttribute("g", color.green());
colorElement.setAttribute("b", color.blue());
colorElement.setAttribute("r", pc.color.red());
colorElement.setAttribute("g", pc.color.green());
colorElement.setAttribute("b", pc.color.blue());
colorElement.setAttribute("tolerance", pc.tolerance);
colorsElement.appendChild(colorElement);
}
result.appendChild(colorsElement);
@@ -1367,23 +1585,33 @@ void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
QAction *replace = menu.data()->addAction(tr("Remplacer l'image..."));
connect(replace, &QAction::triggered, this, &DiagramImageItem::replaceImage);
QAction *saveAs = menu.data()->addAction(tr("Enregistrer l'image sous..."));
connect(saveAs, &QAction::triggered, this, &DiagramImageItem::saveImageAs);
QAction *saveOriginalAs = menu.data()->addAction(tr("Enregistrer l'image d'origine sous..."));
connect(saveOriginalAs, &QAction::triggered, this, &DiagramImageItem::saveOriginalImageAs);
QAction *transparentColor = menu.data()->addAction(tr("Couleur transparente..."));
transparentColor->setIcon(QET::Icons::EditOpacity);
connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor);
QAction *cropAction = menu.data()->addAction(tr("Rogner..."));
cropAction->setIcon(QET::Icons::TransformCrop);
connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop);
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
QAction *restoreRatio = menu.data()->addAction(tr("Restaurer les proportions"));
connect(restoreRatio, &QAction::triggered, this, &DiagramImageItem::restoreAspectRatio);
menu.data()->addSeparator();
QAction *properties = menu.data()->addAction(tr("Propriétés..."));
connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
// menu.data()->addSeparator();
// QAction *properties = menu.data()->addAction(tr("Propriétés..."));
// connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
menu.data()->addSeparator();
menu.data()->addActions(d_view->contextMenuActions());
@@ -1439,6 +1667,15 @@ void DiagramImageItem::replaceImage()
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
undo->setText(tr("Remplacer une image"));
// Every call here is a separate, deliberate menu action with no
// compound child of its own (unlike crop(), which always chains a
// pos/rawPivot change and is naturally immune) -- two of them in a
// row would carry the exact same object, property, and text, which
// is indistinguishable from a legitimate merge to
// QPropertyUndoCommand::mergeWith(). A dummy child (already treated
// as "never merge" by that check) keeps each one its own, separate
// undo step regardless.
new QUndoCommand(undo);
diagram()->undoStack().push(undo);
}
@@ -1482,6 +1719,11 @@ void DiagramImageItem::mirror(bool horizontal)
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image"));
// See replaceImage()'s identical comment: a separate, deliberate
// action with no compound child of its own, so a dummy one is
// needed to stop two consecutive same-direction mirrors (identical
// object, property, and text) from silently merging into one.
new QUndoCommand(undo);
diagram()->undoStack().push(undo);
}
@@ -1520,18 +1762,22 @@ void DiagramImageItem::setTransparentColor()
QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first();
const QPixmap croppedBase = m_base_pixmap.copy(m_crop_rect);
ImageTransparentColorDialog dialog(croppedBase, m_transparent_colors, m_transparent_tolerance, parentWidget);
ImageTransparentColorDialog dialog(croppedBase, m_transparent_colors, parentWidget);
if (dialog.exec() != QDialog::Accepted)
return;
m_transparent_colors = dialog.pickedColors();
m_transparent_tolerance = dialog.tolerance();
const QPixmap oldPixmap = pixmap_;
const QPixmap newPixmap = dialog.resultPixmap();
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
undo->setText(tr("Définir une couleur transparente"));
// See replaceImage()'s identical comment: a separate, deliberate
// action with no compound child of its own, so a dummy one is
// needed to stop two consecutive transparency edits (identical
// object, property, and text) from silently merging into one.
new QUndoCommand(undo);
diagram()->undoStack().push(undo);
}
@@ -1609,7 +1855,7 @@ void DiagramImageItem::crop()
const QPointF oldPos = pos();
const QPixmap oldPixmap = pixmap_;
const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors, m_transparent_tolerance);
const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors);
m_crop_rect = newCropRect;
// boundingRect() is exactly QRectF(pixmap_.rect()) (confirmed by
+32 -13
View File
@@ -20,9 +20,11 @@
#include "qetgraphicsitem.h"
#include "shapetransform.h"
#include "../ui/imagetransparentcolordialog.h"
#include <QColor>
#include <QList>
#include <QStyleOptionGraphicsItem>
#include <QVector>
class QDomElement;
@@ -95,6 +97,19 @@ class DiagramImageItem : public QetGraphicsItem {
QRectF boundingRect() const override;
QString name() const override;
/// DXF export: replay this item's paint() on an arbitrary QPainter
/// (e.g. one targeting DxfPaintDevice). paint() itself stays
/// protected, as it should for the normal
/// QGraphicsScene/QGraphicsView paint contract - this is a
/// deliberate, narrow escape hatch for exporters, not a general
/// relaxation of that contract. Matches CrossRefItem::paintForExport()
/// exactly, for the identical reason.
void paintForExport(QPainter *painter)
{
QStyleOptionGraphicsItem option;
paint(painter, &option, nullptr);
}
qreal scaleFactorX() const { return m_transform.scaleX; }
qreal scaleFactorY() const { return m_transform.scaleY; }
void setScaleFactorX(qreal factor);
@@ -128,7 +143,11 @@ class DiagramImageItem : public QetGraphicsItem {
void setTransparentColor();
void crop();
void restoreAspectRatio();
static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<QColor> &colors, int tolerance);
void saveImageAs();
void saveOriginalImageAs();
void saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency);
static bool writeRasterAsSvg(const QPixmap &pixmap, const QString &path);
static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<ImageTransparentColorDialog::PickedColor> &colors);
void toggleHandleMode();
HandleMode nextHandleMode() const;
@@ -161,22 +180,22 @@ class DiagramImageItem : public QetGraphicsItem {
QPixmap pixmap_;
// The true, pristine original -- never itself cropped or colour-
// keyed. pixmap_ (the displayed result) is always re-derived from
// this plus m_crop_rect and m_transparent_colors/tolerance, via
// computeDisplayPixmap(). Without keeping this separate, re-opening
// either the crop or transparency dialog after using the other
// would show an already-modified image as if it were the source --
// areas already cropped away or coloured out would be gone for
// good, with no way to recover or adjust them, only start over.
// Updated by whatever genuinely replaces or reorients the image's
// actual content (construction, replaceImage(), and mirror(), which
// also mirrors m_crop_rect to keep referring to the same region of
// the now-flipped base) -- never by crop() or setTransparentColor()
// this plus m_crop_rect and m_transparent_colors (each colour
// carrying its own tolerance), via computeDisplayPixmap(). Without
// keeping this separate, re-opening either the crop or
// transparency dialog after using the other would show an
// already-modified image as if it were the source -- areas already
// cropped away or coloured out would be gone for good, with no way
// to recover or adjust them, only start over. Updated by whatever
// genuinely replaces or reorients the image's actual content
// (construction, replaceImage(), and mirror(), which also mirrors
// m_crop_rect to keep referring to the same region of the
// now-flipped base) -- never by crop() or setTransparentColor()
// themselves, which only ever change which subset of this base is
// shown.
QPixmap m_base_pixmap;
QRect m_crop_rect; // relative to m_base_pixmap; equals m_base_pixmap.rect() when nothing has been cropped
QList<QColor> m_transparent_colors;
int m_transparent_tolerance = 10;
QList<ImageTransparentColorDialog::PickedColor> m_transparent_colors;
// Independent scaleX/scaleY here is the actual point of this whole
// member: QGraphicsItem::scale() is a single, uniform float, which
+263 -18
View File
@@ -1097,18 +1097,39 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
if (canConvertToPath)
{
QAction *convert = menu.data()->addAction(tr("Convertir en polyligne"));
connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathExplicitly);
// Only a sharp-cornered rectangle converts losslessly to
// straight-edged polyline -- an ellipse (or arc) flattened
// to its bounding rect's 4 corners wouldn't resemble the
// original shape at all, and rounded corners would be
// silently squared off. Both of those instead need an
// actual Bezier curve to preserve their real geometry; the
// label reflects whichever this particular shape will
// actually get, rather than always claiming "polyligne"
// regardless of what's about to happen.
const bool needsBezier = (m_shapeType == Ellipse) || (m_xRadius > 0 || m_yRadius > 0);
QAction *convert = menu.data()->addAction(needsBezier ? tr("Convertir en courbe de Bézier") : tr("Convertir en polyligne"));
if(needsBezier && m_shapeType == Rectangle) {
convert->setIcon(QET::Icons::RectToBezier);
}
else if(needsBezier && m_shapeType == Ellipse) {
convert->setIcon(QET::Icons::EllipseToBezier);
}
else {
convert->setIcon(QET::Icons::RectToPolyline);
}
connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathOrPolygon);
}
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
menu.data()->addSeparator();
QAction *properties = menu.data()->addAction(tr("Propriétés..."));
connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty);
//menu.data()->addSeparator();
//QAction *properties = menu.data()->addAction(tr("Propriétés..."));
//connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty);
menu.data()->addSeparator();
menu.data()->addActions(d_view->contextMenuActions());
@@ -1929,34 +1950,207 @@ void QetShapeItem::removePathPoint(int nodeIndex)
rebuildHandles();
}
void QetShapeItem::convertToPathExplicitly()
/**
@brief QetShapeItem::convertToPathOrPolygon
Context-menu action, explicitly requested rather than triggered by an
Alt+drag (see promoteRectangleOrEllipseToPolygon() for that separate
mechanism). A sharp-cornered rectangle converts losslessly to a
4-corner polygon -- its 4 corners already are exactly what a
rectangle is. Anything else offered here (an ellipse/arc, or a
rectangle with rounded corners) used to get the exact same
treatment, which is wrong: an ellipse flattened to its bounding
rect's 4 corners doesn't resemble an ellipse at all, and rounded
corners would be silently squared off. Both instead become a
genuine Bezier Path, built from the two verified helper functions
below (numerically confirmed against Qt's own arcTo()/addRoundedRect()
output -- worst-case error a small fraction of a unit on shapes
roughly 100-250 units across, i.e. visually indistinguishable, not
merely "close enough to eyeball").
*/
void QetShapeItem::convertToPathOrPolygon()
{
if (m_shapeType != Rectangle && m_shapeType != Ellipse)
return;
const bool hasRoundedCorners = (m_shapeType == Rectangle) && (m_xRadius > 0 || m_yRadius > 0);
// Captured before m_shapeType changes below -- name() reports
// whatever the CURRENT type is, and by the time the undo text is
// built further down, m_shapeType has already become Polygon or
// Path, so calling name() at that point would describe the shape's
// new type, not what it actually was before conversion ("Convertir
// une polyligne en polyligne" instead of "Convertir un rectangle en
// polyligne").
const QString originalName = name();
const QDomElement before = snapshotXml();
QPolygonF corners;
const QRectF r = localRect();
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
prepareGeometryChange();
m_shapeType = Polygon;
m_polygon = corners;
m_closed = true;
if (m_shapeType == Rectangle && !hasRoundedCorners)
{
QPolygonF corners;
const QRectF r = localRect();
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
m_shapeType = Polygon;
m_polygon = corners;
m_closed = true;
}
else if (m_shapeType == Rectangle) // rounded corners
{
m_nodes = bezierNodesForRoundedRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
m_shapeType = Path;
m_closed = true;
}
else // Ellipse, full or arc
{
const QRectF r(m_P1, m_P2);
if (isFullEllipse())
{
m_nodes = bezierNodesForArc(r, 0, 360);
m_closed = true;
}
else if (m_arcClosure == Pie)
{
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
PathNode centerNode;
centerNode.anchor = r.center();
m_nodes.prepend(centerNode);
m_closed = true;
}
else
{
// Chord leaves the wrap-around segment's handles unset, which
// falls through to QPainterPath::closeSubpath()'s own implicit
// straight line -- exactly the chord behaviour -- when m_closed
// is true; NoClosure is the same node list, just left open.
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
m_closed = (m_arcClosure == Chord);
}
m_shapeType = Path;
}
const QDomElement after = snapshotXml();
if (diagram())
{
auto *undo = new PromoteShapeCommand(this, before, after);
undo->setText(tr("Convertir %1 en polyligne").arg(name()));
undo->setText(m_shapeType == Path
? tr("Convertir %1 en courbe de Bézier").arg(originalName)
: tr("Convertir %1 en polyligne").arg(originalName));
diagram()->undoStack().push(undo);
}
rebuildHandles();
}
/**
@brief QetShapeItem::bezierNodesForArc
Bezier approximation of an elliptical arc, split into <=90-degree
segments (the standard cap for this technique to stay visually
exact -- a single segment starts drifting noticeably past that).
Each segment's handle length is (4/3)*tan(segment_span/4), the
general-angle form of the well-known ~0.5523 "kappa" constant for
an exact quarter circle. A full 360-degree span is a special case:
it produces exactly `segments` nodes rather than `segments+1`
(the very last one would just be a duplicate of the first at the
same anchor point) and wires the wrap-around segment's own handles
explicitly, so the loop closes as a continuous curve rather than
the straight "chord" line a generic closed-path wrap-around would
otherwise fall back to.
@param rect the ellipse's bounding rect
@param startAngleDeg arc start angle, matching QPainterPath::arcTo's own convention
@param spanAngleDeg arc angular span, same convention (may be negative)
@return nodes forming the arc; caller sets Path/m_closed and prepends
a centre node itself for a Pie-style closure
*/
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForArc(const QRectF &rect, qreal startAngleDeg, qreal spanAngleDeg)
{
const bool fullLoop = qFuzzyCompare(qAbs(spanAngleDeg), 360.0);
const qreal cx = rect.center().x(), cy = rect.center().y();
const qreal rx = rect.width() / 2.0, ry = rect.height() / 2.0;
auto pointAt = [&](qreal angleDeg) {
const qreal a = qDegreesToRadians(angleDeg);
return QPointF(cx + rx * qCos(a), cy - ry * qSin(a));
};
auto tangentAt = [&](qreal angleDeg) {
const qreal a = qDegreesToRadians(angleDeg);
return QPointF(-rx * qSin(a), -ry * qCos(a));
};
const int segments = qMax(1, int(qCeil(qAbs(spanAngleDeg) / 90.0)));
const qreal segSpan = spanAngleDeg / segments;
const qreal handleLen = (4.0 / 3.0) * qTan(qDegreesToRadians(qAbs(segSpan)) / 4.0);
const int nodeCount = fullLoop ? segments : segments + 1;
QVector<PathNode> nodes(nodeCount);
for (int i = 0; i < nodeCount; ++i)
{
nodes[i].anchor = pointAt(startAngleDeg + i * segSpan);
nodes[i].kind = NodeKind::Smooth;
}
for (int i = 0; i < nodeCount; ++i)
{
const QPointF tangent = tangentAt(startAngleDeg + i * segSpan);
if (fullLoop || i < nodeCount - 1)
nodes[i].outHandle = tangent * handleLen;
if (fullLoop || i > 0)
nodes[i % nodeCount].inHandle = -tangent * handleLen;
}
return nodes;
}
/**
@brief QetShapeItem::bezierNodesForRoundedRect
Bezier equivalent of QPainterPath::addRoundedRect(): 8 anchors (each
edge's two ends), straight lines along the 4 edges, and a Bezier
quarter-turn at each of the 4 corners using the same handle-length
formula as bezierNodesForArc() (with segSpan fixed at 90 degrees,
since a rounded rect's corners always are). xRadius/yRadius are
clamped to at most half the rect's own width/height, matching how
QPainterPath::addRoundedRect() itself behaves for an
over-large radius.
@param rect the rectangle's own corner points, as a QRectF
@param xRadius corner radius along the x axis
@param yRadius corner radius along the y axis
@return 8 nodes forming the closed rounded-rectangle outline
*/
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForRoundedRect(const QRectF &rect, qreal xRadius, qreal yRadius)
{
const QRectF r = rect.normalized();
const qreal rx = qBound(0.0, xRadius, r.width() / 2.0);
const qreal ry = qBound(0.0, yRadius, r.height() / 2.0);
const qreal kx = rx * 0.5522847498;
const qreal ky = ry * 0.5522847498;
const QVector<QPointF> anchors = {
QPointF(r.left() + rx, r.top()),
QPointF(r.right() - rx, r.top()),
QPointF(r.right(), r.top() + ry),
QPointF(r.right(), r.bottom() - ry),
QPointF(r.right() - rx, r.bottom()),
QPointF(r.left() + rx, r.bottom()),
QPointF(r.left(), r.bottom() - ry),
QPointF(r.left(), r.top() + ry),
};
QVector<PathNode> nodes;
nodes.reserve(8);
for (const QPointF &p : anchors)
{
PathNode n;
n.anchor = p;
n.kind = NodeKind::Smooth;
nodes.append(n);
}
nodes[1].outHandle = QPointF(kx, 0); nodes[2].inHandle = QPointF(0, -ky);
nodes[3].outHandle = QPointF(0, ky); nodes[4].inHandle = QPointF(kx, 0);
nodes[5].outHandle = QPointF(-kx, 0); nodes[6].inHandle = QPointF(0, ky);
nodes[7].outHandle = QPointF(0, -ky); nodes[0].inHandle = QPointF(-kx, 0);
return nodes;
}
/**
@brief QetShapeItem::mirror
Flips the shape around its own current pivot -- horizontal negates
@@ -2477,6 +2671,8 @@ void QetShapeItem::handlerMousePressEvent(int handlerIndex)
m_old_polygon = m_polygon;
m_old_xRadius = m_xRadius;
m_old_yRadius = m_yRadius;
m_old_startAngle = m_startAngle;
m_old_endAngle = m_endAngle;
m_old_transform = m_transform;
m_old_pos = pos();
m_old_nodes = m_nodes;
@@ -2555,11 +2751,16 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
{
undo = new QPropertyUndoCommand(this, "rect", QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized());
}
if (undo)
undo->setText(tr("Redimensionner %1").arg(name()));
break;
case HandleRole::Rotate:
if (!qFuzzyCompare(m_transform.rotation, m_old_transform.rotation))
{
undo = new QPropertyUndoCommand(this, "rotation", m_old_transform.rotation, m_transform.rotation);
undo->setText(tr("Faire pivoter %1").arg(name()));
}
break;
case HandleRole::SkewEdge:
@@ -2567,6 +2768,8 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
undo = new QPropertyUndoCommand(this, "skewX", m_old_transform.skewX, m_transform.skewX);
else if (!qFuzzyCompare(m_transform.skewY, m_old_transform.skewY))
undo = new QPropertyUndoCommand(this, "skewY", m_old_transform.skewY, m_transform.skewY);
if (undo)
undo->setText(tr("Incliner %1").arg(name()));
break;
case HandleRole::Pivot:
@@ -2583,20 +2786,37 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
{
undo = new QPropertyUndoCommand(this, "xRadius", m_old_xRadius, m_xRadius);
new QPropertyUndoCommand(this, "yRadius", m_old_yRadius, m_yRadius, undo);
undo->setText(tr("Arrondir les coins d'%1").arg(name()));
}
break;
case HandleRole::ArcEndpoint:
// startAngle/endAngle changes are cosmetic-cost enough (and
// re-derived from each other on snap-to-full-ellipse) that
// they are intentionally not wrapped in undo here yet -- flag
// for a follow-up once ArcEndpoint dragging ships in the UI.
// The snap-to-full-ellipse behaviour in setStartAngle()/
// setEndAngle() (see anglesGeometricallyAdjacent()) can
// reset BOTH angles at once even though only one endpoint
// was actually dragged, so both are checked here regardless
// of which handle (slot 0 or 1) triggered this -- the same
// reasoning as CornerRadius checking both xRadius and
// yRadius above.
if (!qFuzzyCompare(m_startAngle, m_old_startAngle))
{
undo = new QPropertyUndoCommand(this, "startAngle", m_old_startAngle, m_startAngle);
if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle, undo);
}
else if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
{
undo = new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle);
}
if (undo)
undo->setText(tr("Modifier l'angle d'un arc"));
break;
case HandleRole::PathAnchor:
if (m_shapeType == Polygon && m_polygon != m_old_polygon)
{
undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon);
undo->setText(tr("Modifier la forme d'%1").arg(name()));
}
else if (m_shapeType == Path && m_nodes != m_old_nodes)
{
@@ -2611,6 +2831,7 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
m_nodes = after;
const QDomElement afterXml = snapshotXml();
undo = new PromoteShapeCommand(this, before, afterXml);
undo->setText(tr("Modifier la forme d'%1").arg(name()));
}
break;
@@ -2624,14 +2845,38 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
m_nodes = after;
const QDomElement afterXml = snapshotXml();
undo = new PromoteShapeCommand(this, before, afterXml);
undo->setText(tr("Modifier la courbure d'%1").arg(name()));
}
break;
}
if (undo)
{
// Defensive fallback only -- every role above now sets its own,
// distinct label directly (Resize/Rotate/SkewEdge/CornerRadius/
// PathAnchor/PathControlIn/PathControlOut used to all fall
// through to this same generic text, making the undo list
// unable to tell completely different edits apart); this only
// still matters if some future role is ever added without
// setting one of its own.
if (undo->text().isEmpty())
undo->setText(tr("Modifier %1").arg(name()));
// Every push here is one complete, finished gesture (press,
// drag, release) -- never a continuation of an earlier one, the
// way ArcEditor's slider deliberately pushes many commands
// during a single ongoing drag and wants them to merge.
// QPropertyUndoCommand::mergeWith() already treats any command
// with children as never mergeable; a dummy child guarantees
// that here regardless of which role produced undo. Without
// this, a single-property change (Rotate and SkewEdge always
// are; Resize and ArcEndpoint sometimes are, when only one of
// their two properties actually changed) would silently
// coalesce into whatever identically-labelled edit came right
// before it -- even after a deselect/reselect proved they were
// two separate actions, since the shared label is otherwise
// indistinguishable from a genuine continuation.
new QUndoCommand(undo);
diagram()->undoStack().push(undo);
}
}
+6 -2
View File
@@ -249,7 +249,9 @@ class QetShapeItem : public QetGraphicsItem
void repositionHandles(); // moves existing handler items -- safe to call every frame of a live drag
void insertPoint();
void removePoint();
void convertToPathExplicitly(); // context-menu action; see promoteRectangleOrEllipseToPolygon()
void convertToPathOrPolygon(); // context-menu action; see promoteRectangleOrEllipseToPolygon() for the separate Alt+drag mechanism
static QVector<PathNode> bezierNodesForArc(const QRectF &rect, qreal startAngleDeg, qreal spanAngleDeg);
static QVector<PathNode> bezierNodesForRoundedRect(const QRectF &rect, qreal xRadius, qreal yRadius);
void mirror(bool horizontal); // context-menu action: flips scaleFactorX (horizontal) or scaleFactorY (vertical) around the current pivot
void setNodeKind(int nodeIndex, NodeKind kind); // context-menu action on a Path node
@@ -312,7 +314,9 @@ class QetShapeItem : public QetGraphicsItem
qreal m_xRadius = 0,
m_yRadius = 0,
m_old_xRadius,
m_old_yRadius;
m_old_yRadius,
m_old_startAngle,
m_old_endAngle;
ShapeTransform m_transform;
ShapeTransform m_old_transform;
+14
View File
@@ -65,6 +65,7 @@ namespace QET {
QIcon EditCopy;
QIcon EditCut;
QIcon EditDelete;
QIcon EditOpacity;
QIcon EditPaste;
QIcon EditRedo;
QIcon EditRename;
@@ -86,6 +87,7 @@ namespace QET {
QIcon ElementEdit;
QIcon ElementNew;
QIcon ElementSmall;
QIcon EllipseToBezier;
QIcon EndLineCircle;
QIcon EndLineDiamond;
QIcon EndLineNone;
@@ -115,6 +117,8 @@ namespace QET {
QIcon Hide;
QIcon Home;
QIcon HotSpot;
QIcon ImageFlipHorizontal;
QIcon ImageFlipVertical;
QIcon InsertImage;
QIcon Lower;
QIcon IC_MoveFile;
@@ -156,6 +160,8 @@ namespace QET {
QIcon QETOxygenLogo;
QIcon QtLogo;
QIcon Raise;
QIcon RectToBezier;
QIcon RectToPolyline;
QIcon Remove;
QIcon Restore;
QIcon RunDxf;
@@ -169,6 +175,7 @@ namespace QET {
QIcon TitleBlock;
QIcon TitleBlockBottom;
QIcon TitleBlockRight;
QIcon TransformCrop;
QIcon TransformRotate;
QIcon UserInformations;
QIcon ViewFitWidth;
@@ -470,6 +477,7 @@ void QET::Icons::initIcons()
EditCut .addFile(":/ico/22x22/edit-cut.png");
EditDelete .addFile(":/ico/16x16/edit-delete.png");
EditDelete .addFile(":/ico/22x22/edit-delete.png");
EditOpacity .addFile(":/ico/breeze-icons/scalable/apps/hidef/edit-opacity.svg");
EditPaste .addFile(":/ico/22x22/edit-paste.png");
EditPaste .addFile(":/ico/16x16/edit-paste.png");
if (rtl) {
@@ -518,6 +526,7 @@ void QET::Icons::initIcons()
ElementNew .addFile(":/ico/16x16/element-new.png");
ElementNew .addFile(":/ico/22x22/element-new.png");
ElementSmall .addFile(":/ico/16x16/element.png");
EllipseToBezier .addFile(":/ico/generated/ellipse-to-bezier.svg");
EndLineCircle .addFile(":/ico/16x16/endline-circle.png");
EndLineDiamond .addFile(":/ico/16x16/endline-diamond.png");
EndLineNone .addFile(":/ico/16x16/endline-none.png");
@@ -557,6 +566,8 @@ void QET::Icons::initIcons()
Home .addFile(":/ico/16x16/go-home.png");
Home .addFile(":/ico/22x22/go-home.png");
HotSpot .addFile(":/ico/22x22/hotspot.png");
ImageFlipHorizontal .addFile(":/ico/breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg");
ImageFlipVertical .addFile(":/ico/breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg");
InsertImage .addFile(":/ico/22x22/insert-image.png");
Lower .addFile(":/ico/22x22/lower.png");
IC_MoveFile .addFile(":/ico/16x16/item-move.png");
@@ -615,6 +626,8 @@ void QET::Icons::initIcons()
QETVideo .addFile(":/ico/16x16/kdenlive-show-video.png");
QtLogo .addFile(":/ico/16x16/qt.png");
Raise .addFile(":/ico/22x22/raise.png");
RectToBezier .addFile(":/ico/generated/rect-to-bezier.svg");
RectToPolyline .addFile(":/ico/generated/rect-to-polyline.svg");
Remove .addFile(":/ico/16x16/list-remove.png");
Remove .addFile(":/ico/22x22/list-remove.png");
resize_image .addFile(":/ico/22x22/transform-scale.png");
@@ -633,6 +646,7 @@ void QET::Icons::initIcons()
TitleBlock .addFile(":/ico/22x22/label.png");
TitleBlockBottom .addFile(":/ico/22x22/titleblock-bottom.png");
TitleBlockRight .addFile(":/ico/22x22/titleblock-right.png");
TransformCrop .addFile(":/ico/breeze-icons/scalable/apps/hidef/transform-crop.svg");
TransformRotate .addFile(":/ico/16x16/transform-rotate.png");
UserInformations .addFile(":/ico/16x16/preferences-desktop-user.png");
UserInformations .addFile(":/ico/22x22/preferences-desktop-user.png");
+7
View File
@@ -72,6 +72,7 @@ namespace QET {
extern QIcon EditCopy;
extern QIcon EditCut;
extern QIcon EditDelete;
extern QIcon EditOpacity;
extern QIcon EditPaste;
extern QIcon EditRedo;
extern QIcon EditRename;
@@ -92,6 +93,7 @@ namespace QET {
extern QIcon ElementDelete;
extern QIcon ElementEdit;
extern QIcon ElementNew;
extern QIcon EllipseToBezier;
extern QIcon EndLineCircle;
extern QIcon EndLineDiamond;
extern QIcon EndLineNone;
@@ -121,6 +123,8 @@ namespace QET {
extern QIcon Hide;
extern QIcon Home;
extern QIcon HotSpot;
extern QIcon ImageFlipHorizontal;
extern QIcon ImageFlipVertical;
extern QIcon InsertImage;
extern QIcon Lower;
extern QIcon IC_MoveFile;
@@ -164,6 +168,8 @@ namespace QET {
extern QIcon QETOxygenLogo;
extern QIcon QtLogo;
extern QIcon Raise;
extern QIcon RectToBezier;
extern QIcon RectToPolyline;
extern QIcon Remove;
extern QIcon Restore;
extern QIcon RunDxf;
@@ -177,6 +183,7 @@ namespace QET {
extern QIcon TitleBlock;
extern QIcon TitleBlockBottom;
extern QIcon TitleBlockRight;
extern QIcon TransformCrop;
extern QIcon TransformRotate;
extern QIcon UserInformations;
extern QIcon ViewFitWidth;
+146 -87
View File
@@ -25,6 +25,7 @@
#include <QPainter>
#include <QPushButton>
#include <QSlider>
#include <QToolButton>
#include <QVBoxLayout>
#include <QVector>
@@ -48,10 +49,10 @@ ClickableImageLabel::ClickableImageLabel(const QImage &sourceImage, QWidget *par
const qreal scaleH = qreal(MAX_DISPLAY_SIZE) / m_source.height();
m_displayScale = qMin(qreal(1.0), qMin(scaleW, scaleH)); // never upscale a small image, only ever shrink a large one
const QImage displayImage = (m_displayScale < 1.0)
m_displayImage = (m_displayScale < 1.0)
? m_source.scaled(m_source.size() * m_displayScale, Qt::KeepAspectRatio, Qt::SmoothTransformation)
: m_source;
setPixmap(QPixmap::fromImage(displayImage));
setPixmap(QPixmap::fromImage(m_displayImage));
setCursor(Qt::CrossCursor);
setToolTip(tr("Cliquez pour choisir une couleur"));
}
@@ -91,27 +92,28 @@ void ClickableImageLabel::mousePressEvent(QMouseEvent *event)
@param pixmap the image to pick a transparent colour from
@param parent
*/
ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixmap, const QList<QColor> &existingColors,
int existingTolerance, QWidget *parent) :
ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixmap,
const QList<PickedColor> &existingColors, QWidget *parent) :
QDialog(parent),
m_sourceImage(basePixmap.toImage()),
m_pickedColors(existingColors),
m_tolerance(existingTolerance)
m_pickedColors(existingColors)
{
setWindowTitle(tr("Couleur transparente"));
if (!m_pickedColors.isEmpty())
m_lastToleranceUsed = m_pickedColors.last().tolerance;
m_sourceLabel = new ClickableImageLabel(m_sourceImage, this);
m_previewSourceImage = m_sourceLabel->displayImage();
m_previewLabel = new QLabel(this);
m_hintLabel = new QLabel(this);
m_toleranceSlider = new QSlider(Qt::Horizontal, this);
m_toleranceSlider->setRange(0, 100);
m_toleranceSlider->setValue(m_tolerance);
m_hintLabel->setWordWrap(true);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
m_okButton = buttons->button(QDialogButtonBox::Ok);
m_okButton->setEnabled(!m_pickedColors.isEmpty()); // nothing to apply until at least one colour has been picked
m_startedWithColors = !m_pickedColors.isEmpty();
updateOkEnabled();
auto *grid = new QGridLayout;
grid->addWidget(new QLabel(tr("Image source")), 0, 0);
@@ -119,29 +121,19 @@ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixm
grid->addWidget(m_sourceLabel, 1, 0);
grid->addWidget(m_previewLabel, 1, 1);
// An empty row to start with if existingColors is empty --
// rebuildSwatches() below populates it either way (including from
// existingColors on the first call), and again as colours get
// added or removed.
m_swatchesLayout = new QHBoxLayout;
auto *colorRow = new QHBoxLayout;
colorRow->addWidget(m_hintLabel);
colorRow->addStretch();
colorRow->addLayout(m_swatchesLayout);
auto *toleranceRow = new QHBoxLayout;
toleranceRow->addWidget(new QLabel(tr("Tolérance")));
toleranceRow->addWidget(m_toleranceSlider);
// One row per picked colour, appended/removed as rebuildSwatches()
// runs -- starts empty either way, populated by the very first
// rebuildSwatches() call below (including from existingColors, if
// any were passed in).
m_swatchesLayout = new QVBoxLayout;
auto *mainLayout = new QVBoxLayout(this);
mainLayout->addLayout(grid);
mainLayout->addLayout(colorRow);
mainLayout->addLayout(toleranceRow);
mainLayout->addWidget(m_hintLabel);
mainLayout->addLayout(m_swatchesLayout);
mainLayout->addWidget(buttons);
connect(m_sourceLabel, &ClickableImageLabel::colorPicked, this, &ImageTransparentColorDialog::onColorPicked);
connect(m_toleranceSlider, &QSlider::valueChanged, this, &ImageTransparentColorDialog::onToleranceChanged);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
@@ -156,42 +148,88 @@ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixm
the first choice, with no way to work with more than one colour (a
white background *and* a grey border, say) in the same pass.
Skips an exact duplicate rather than adding a second, indistinguishable
swatch for it.
row for it. The new colour is seeded with whatever tolerance was
last used (not some fixed default), so successive picks in one
session feel consistent.
@param color the colour sampled from the source image
*/
void ImageTransparentColorDialog::onColorPicked(const QColor &color)
{
if (m_pickedColors.contains(color))
return;
for (const PickedColor &existing : std::as_const(m_pickedColors))
if (existing.color == color)
return;
m_pickedColors.append(color);
m_okButton->setEnabled(true);
m_pickedColors.append({color, m_lastToleranceUsed});
updateOkEnabled();
rebuildSwatches();
updatePreview();
}
/**
@brief ImageTransparentColorDialog::setToleranceForIndex
Updates one colour's own tolerance in place -- deliberately doesn't
call rebuildSwatches(): only a number changed, not which rows exist
or what order they're in, so recreating every row's widgets (and,
worse, the very slider currently being dragged) on each tick would
be both wasteful and liable to interrupt the drag itself.
@param index which entry in m_pickedColors changed
@param value its new tolerance, 0-100
*/
void ImageTransparentColorDialog::setToleranceForIndex(int index, int value)
{
if (index < 0 || index >= m_pickedColors.size())
return;
m_pickedColors[index].tolerance = value;
m_lastToleranceUsed = value;
updatePreview();
}
/**
@brief ImageTransparentColorDialog::updateOkEnabled
OK is disabled only for the one case where accepting would genuinely
do nothing: a dialog that started with no colours and still has
none. Removing every colour from a dialog that DID start with some
is a different, meaningful action -- clearing all transparency back
to a plain, opaque image -- and has to stay confirmable, not silently
blocked the same way. Re-run after every add or remove, since either
can cross the "any colours at all, ever" threshold in either
direction.
*/
void ImageTransparentColorDialog::updateOkEnabled()
{
m_okButton->setEnabled(!m_pickedColors.isEmpty() || m_startedWithColors);
}
/**
@brief ImageTransparentColorDialog::removeColor
Removes one colour from the set -- the counterpart onColorPicked()
was missing entirely before: picking the wrong pixel by mistake had
no way to undo except cancelling the whole dialog and starting over.
@param color the colour to remove
@param index position in m_pickedColors to remove
*/
void ImageTransparentColorDialog::removeColor(const QColor &color)
void ImageTransparentColorDialog::removeColor(int index)
{
m_pickedColors.removeAll(color);
m_okButton->setEnabled(!m_pickedColors.isEmpty());
if (index < 0 || index >= m_pickedColors.size())
return;
m_pickedColors.removeAt(index);
updateOkEnabled();
rebuildSwatches();
updatePreview();
}
/**
@brief ImageTransparentColorDialog::rebuildSwatches
Rebuilds the row of picked-colour swatches from scratch against the
Rebuilds the list of picked-colour rows from scratch against the
current m_pickedColors -- simpler and safer than trying to
incrementally add/remove individual widgets in sync with the list,
incrementally add/remove individual rows in sync with the list,
given the list only ever changes one colour at a time and is never
large enough for a full rebuild to be a real cost.
large enough for a full rebuild to be a real cost. Each row is a
colour swatch (a plain, round, non-interactive indicator -- there's
nothing left to click it FOR, now that each row carries its own
slider instead of one shared slider needing a row selected first),
that row's own tolerance slider, and a small "x" button that
removes it.
*/
void ImageTransparentColorDialog::rebuildSwatches()
{
@@ -202,88 +240,109 @@ void ImageTransparentColorDialog::rebuildSwatches()
delete item;
}
for (const QColor &color : std::as_const(m_pickedColors))
for (int i = 0; i < m_pickedColors.size(); ++i)
{
auto *swatch = new QPushButton(this);
const PickedColor &pc = m_pickedColors.at(i);
auto *row = new QWidget(this);
auto *rowLayout = new QHBoxLayout(row);
rowLayout->setContentsMargins(0, 0, 0, 0);
auto *swatch = new QLabel(row);
swatch->setFixedSize(24, 24);
swatch->setStyleSheet(QStringLiteral("background-color: rgb(%1,%2,%3); border: 1px solid palette(mid);")
.arg(color.red()).arg(color.green()).arg(color.blue()));
swatch->setToolTip(tr("rgb(%1, %2, %3) -- cliquer pour retirer").arg(color.red()).arg(color.green()).arg(color.blue()));
connect(swatch, &QPushButton::clicked, this, [this, color]() { removeColor(color); });
m_swatchesLayout->addWidget(swatch);
swatch->setStyleSheet(QStringLiteral(
"background-color: rgb(%1,%2,%3); border: 1px solid palette(mid); border-radius: 12px;")
.arg(pc.color.red()).arg(pc.color.green()).arg(pc.color.blue()));
swatch->setToolTip(tr("rgb(%1, %2, %3)").arg(pc.color.red()).arg(pc.color.green()).arg(pc.color.blue()));
auto *slider = new QSlider(Qt::Horizontal, row);
slider->setRange(0, 100);
slider->setValue(pc.tolerance);
slider->setToolTip(tr("Tolérance pour cette couleur"));
connect(slider, &QSlider::valueChanged, this, [this, i](int value) { setToleranceForIndex(i, value); });
auto *removeButton = new QToolButton(row);
removeButton->setText(QStringLiteral("×"));
removeButton->setToolTip(tr("Retirer cette couleur"));
connect(removeButton, &QToolButton::clicked, this, [this, i]() { removeColor(i); });
rowLayout->addWidget(swatch);
rowLayout->addWidget(slider);
rowLayout->addWidget(removeButton);
m_swatchesLayout->addWidget(row);
}
m_hintLabel->setText(m_pickedColors.isEmpty()
? tr("Cliquez sur l'image pour choisir une couleur")
: tr("Cliquez sur l'image pour ajouter une couleur, ou sur une pastille pour la retirer"));
}
/**
@brief ImageTransparentColorDialog::onToleranceChanged
@param value the new tolerance, 0-100
*/
void ImageTransparentColorDialog::onToleranceChanged(int value)
{
m_tolerance = value;
if (!m_pickedColors.isEmpty())
updatePreview();
: tr("Cliquez sur l'image pour ajouter une couleur. Ajustez la tolérance de chaque couleur avec son curseur, ou cliquez sur × pour la retirer."));
}
/**
@brief ImageTransparentColorDialog::updatePreview
Recomputes the checkerboard-backed preview against the current set
of picked colours and the shared tolerance. Always runs against
m_sourceImage (the original, full-resolution image), not any
already-keyed result -- so adjusting the tolerance, or adding or
removing a colour, re-evaluates every picked colour from scratch
each time rather than compounding successive passes.
of picked colours, each at its own tolerance. Runs against
m_previewSourceImage (the same downsampled copy ClickableImageLabel
already computed for its own display, not the full-resolution
m_sourceImage) -- this fires on every tolerance slider tick, not
just on release, and a full-resolution O(width*height*colours) pass
per tick was visibly laggy on a large source image. resultPixmap(),
below, still computes the final, committed result at full
resolution -- only this live preview is downsampled. Always
re-evaluates every picked colour from scratch against
m_previewSourceImage, not any already-keyed result, so adjusting a
tolerance or adding/removing a colour never compounds successive
passes.
*/
void ImageTransparentColorDialog::updatePreview()
{
const QImage keyed = applyColorKey(m_sourceImage, m_pickedColors, m_tolerance);
const QImage keyed = applyColorKey(m_previewSourceImage, m_pickedColors);
m_previewLabel->setPixmap(onCheckerboard(keyed));
}
/**
@brief ImageTransparentColorDialog::resultPixmap
@return the colour-keyed pixmap against every picked colour, or the
original pixmap unchanged if none were ever picked (the Ok button
stays disabled until at least one is, so this is mostly a defensive
fallback).
@return the colour-keyed pixmap against every picked colour, each at
its own tolerance, or the original pixmap unchanged if none were
ever picked (the Ok button stays disabled until at least one is, so
this is mostly a defensive fallback).
*/
QPixmap ImageTransparentColorDialog::resultPixmap() const
{
if (m_pickedColors.isEmpty())
return QPixmap::fromImage(m_sourceImage);
return QPixmap::fromImage(applyColorKey(m_sourceImage, m_pickedColors, m_tolerance));
return QPixmap::fromImage(applyColorKey(m_sourceImage, m_pickedColors));
}
/**
@brief ImageTransparentColorDialog::applyColorKey
Binary transparency within tolerance, not a smooth falloff: every
pixel within `tolerance` (0-100, mapped onto the maximum possible
RGB distance) of *any* of keyColors becomes fully transparent,
everything else keeps its existing alpha untouched. Squared distance
throughout, avoiding a sqrt per pixel; breaks out of the inner loop
on the first matching colour, since further matches wouldn't change
the outcome.
pixel within a colour's own `tolerance` (0-100, mapped onto the
maximum possible RGB distance) of that colour becomes fully
transparent, everything else keeps its existing alpha untouched --
each of keyColors is checked against its own threshold, not one
shared across all of them, since a background colour and a border
colour rarely call for the same looseness of match. Squared
distance throughout, avoiding a sqrt per pixel; breaks out of the
inner loop on the first matching colour, since further matches
wouldn't change the outcome.
@param source the image to key
@param keyColors the colours to make transparent
@param tolerance 0 (exact match only) to 100 (everything)
@param keyColors the colours (each with its own tolerance) to make transparent
@return the resulting image, always in Format_ARGB32
*/
QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList<QColor> &keyColors, int tolerance)
QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList<PickedColor> &keyColors)
{
QImage result = source.convertToFormat(QImage::Format_ARGB32);
if (keyColors.isEmpty())
return result;
QVector<QRgb> keys;
struct KeyEntry { QRgb rgb; qint64 threshold; };
QVector<KeyEntry> keys;
keys.reserve(keyColors.size());
for (const QColor &c : keyColors)
keys.append(c.rgb());
const qint64 threshold = qint64(tolerance) * tolerance * 3 * 255 * 255 / (100 * 100);
for (const PickedColor &pc : keyColors)
{
const qint64 threshold = qint64(pc.tolerance) * pc.tolerance * 3 * 255 * 255 / (100 * 100);
keys.append({pc.color.rgb(), threshold});
}
for (int y = 0; y < result.height(); ++y)
{
@@ -291,11 +350,11 @@ QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QL
for (int x = 0; x < result.width(); ++x)
{
const QRgb px = line[x];
for (const QRgb &key : keys)
for (const KeyEntry &key : std::as_const(keys))
{
const int dr = qRed(px) - qRed(key), dg = qGreen(px) - qGreen(key), db = qBlue(px) - qBlue(key);
const int dr = qRed(px) - qRed(key.rgb), dg = qGreen(px) - qGreen(key.rgb), db = qBlue(px) - qBlue(key.rgb);
const qint64 distSq = qint64(dr) * dr + qint64(dg) * dg + qint64(db) * db;
if (distSq <= threshold)
if (distSq <= key.threshold)
{
line[x] = qRgba(qRed(px), qGreen(px), qBlue(px), 0);
break;
+65 -37
View File
@@ -24,9 +24,8 @@
#include <QLabel>
#include <QList>
class QSlider;
class QVBoxLayout;
class QPushButton;
class QHBoxLayout;
class QMouseEvent;
/**
@@ -45,6 +44,17 @@ class ClickableImageLabel : public QLabel
public:
explicit ClickableImageLabel(const QImage &sourceImage, QWidget *parent = nullptr);
/// The same downsampled image already computed for display --
/// reused as the live-preview source so a tolerance drag runs
/// its per-tick colour-key pass against a small image instead
/// of the full-resolution one, which on a large source (a
/// scanned schematic background, several Mpx) made every
/// intermediate slider tick visibly lag. resultPixmap() still
/// computes the final, committed result from the true
/// full-resolution source -- only the live preview is
/// downsampled.
QImage displayImage() const { return m_displayImage; }
signals:
void colorPicked(const QColor &color);
@@ -53,6 +63,7 @@ class ClickableImageLabel : public QLabel
private:
QImage m_source;
QImage m_displayImage;
qreal m_displayScale = 1.0;
};
@@ -60,73 +71,90 @@ class ClickableImageLabel : public QLabel
@brief The ImageTransparentColorDialog class
Lets the user click directly on a preview of the image to sample one
or more colors -- each click adds to the set rather than replacing
the previous pick, shown as a row of removable swatches -- adjust a
shared tolerance, and see a live checkerboard-backed preview of the
result before committing. A self-contained modal dialog rather than
a diagram-level "click the canvas to pick" interaction mode, since
this needs neither undo-during-drag nor coexistence with other
tools; it only ever needs a handful of clicks, evaluated against a
pixmap the caller already has in hand.
the previous pick -- each shown as its own row: a colour swatch, a
slider for that colour's own tolerance right next to it, and a
remove button. Each colour keeps its own tolerance rather than
sharing one: a white background and a grey border rarely need the
same looseness of match, and forcing one tolerance onto both meant
either the background left ragged edges or the border ate into
content near it. A live checkerboard-backed preview of the combined
result updates as any slider moves. A self-contained modal dialog
rather than a diagram-level "click the canvas to pick" interaction
mode, since this needs neither undo-during-drag nor coexistence
with other tools; it only ever needs a handful of clicks, evaluated
against a pixmap the caller already has in hand.
*/
class ImageTransparentColorDialog : public QDialog
{
Q_OBJECT
public:
/// One picked colour and the tolerance it's individually keyed
/// with -- the whole point of this being a struct rather than
/// two parallel lists is that the two can never drift out of
/// index alignment with each other.
struct PickedColor
{
QColor color;
int tolerance;
};
/// @param basePixmap the pristine source to pick colours from --
/// the caller's responsibility to pass the true original, not
/// an already colour-keyed result, or previously-transparent
/// areas would show as plain background rather than a pickable
/// surface, and re-picking the same colour would be a no-op.
/// @param existingColors colours already keyed out of basePixmap
/// in a previous session, shown as swatches from the start
/// rather than forcing them to be re-picked from scratch.
/// @param existingTolerance the tolerance from that previous
/// session, if any.
explicit ImageTransparentColorDialog(const QPixmap &basePixmap, const QList<QColor> &existingColors = {},
int existingTolerance = 10, QWidget *parent = nullptr);
/// @param existingColors colours (with their individual
/// tolerances) already keyed out of basePixmap in a previous
/// session, shown as rows from the start rather than forcing
/// them to be re-picked from scratch.
explicit ImageTransparentColorDialog(const QPixmap &basePixmap,
const QList<PickedColor> &existingColors = {}, QWidget *parent = nullptr);
/// The resulting pixmap: basePixmap unchanged if no colour is
/// picked, colour-keyed against every picked colour otherwise.
/// picked, colour-keyed against every picked colour (each at
/// its own tolerance) otherwise.
QPixmap resultPixmap() const;
/// The final set of picked colours, for the caller to remember
/// across dialog sessions -- may differ from existingColors if
/// any were added or removed.
QList<QColor> pickedColors() const { return m_pickedColors; }
/// The final tolerance, for the same reason.
int tolerance() const { return m_tolerance; }
/// The final set of picked colours and their individual
/// tolerances, for the caller to remember across dialog
/// sessions -- may differ from existingColors if any were
/// added, removed, or had their tolerance adjusted.
QList<PickedColor> pickedColors() const { return m_pickedColors; }
/// Public so DiagramImageItem can re-derive its display pixmap
/// directly (base + crop + these colours) without needing to
/// re-open this dialog every time the crop region changes --
/// binary transparency within tolerance, not a smooth falloff:
/// every pixel within `tolerance` (0-100, mapped onto the
/// maximum possible RGB distance) of *any* of keyColors becomes
/// fully transparent, everything else keeps its existing alpha
/// untouched. Squared distance throughout, avoiding a sqrt per
/// pixel; breaks out of the inner loop on the first matching
/// colour, since further matches wouldn't change the outcome.
static QImage applyColorKey(const QImage &source, const QList<QColor> &keyColors, int tolerance);
/// every pixel within a colour's own `tolerance` (0-100, mapped
/// onto the maximum possible RGB distance) of that colour
/// becomes fully transparent, everything else keeps its
/// existing alpha untouched. Squared distance throughout,
/// avoiding a sqrt per pixel; breaks out of the inner loop on
/// the first matching colour, since further matches wouldn't
/// change the outcome.
static QImage applyColorKey(const QImage &source, const QList<PickedColor> &keyColors);
private slots:
void onColorPicked(const QColor &color);
void onToleranceChanged(int value);
private:
void removeColor(const QColor &color);
void setToleranceForIndex(int index, int value);
void removeColor(int index);
void rebuildSwatches();
void updatePreview();
void updateOkEnabled();
static QPixmap onCheckerboard(const QImage &image);
QImage m_sourceImage;
QList<QColor> m_pickedColors;
int m_tolerance = 10;
QImage m_sourceImage;
QImage m_previewSourceImage; // downsampled -- see ClickableImageLabel::displayImage()'s comment for why
QList<PickedColor> m_pickedColors;
bool m_startedWithColors = false; // whether existingColors was non-empty -- see updateOkEnabled()'s comment for why this matters
int m_lastToleranceUsed = 10; // seeds a newly-picked colour's own tolerance, so successive picks in one session feel consistent rather than each resetting to some fixed default
ClickableImageLabel *m_sourceLabel;
QLabel *m_previewLabel;
QHBoxLayout *m_swatchesLayout;
QVBoxLayout *m_swatchesLayout; // one row per picked colour, stacked vertically -- each row now carries its own slider, too wide to lay out side by side the way plain swatches once were
QLabel *m_hintLabel;
QSlider *m_toleranceSlider;
QPushButton *m_okButton;
};
+8
View File
@@ -77,3 +77,11 @@ target_link_libraries(
${KF_PRIVATE_LIBRARIES}
${QET_PRIVATE_LIBRARIES})
# diagramsortkeys.h is a header-only helper (no QET link deps needed
# beyond Qt itself), so this test builds independently of the rest of
# the QET sources.
add_executable(tst_diagramsortkeys tst_diagramsortkeys.cpp)
add_test(NAME tst_diagramsortkeys COMMAND tst_diagramsortkeys)
target_include_directories(tst_diagramsortkeys PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_diagramsortkeys PRIVATE Qt::Test)
+39
View File
@@ -0,0 +1,39 @@
#include <QtTest>
#include "diagramsortkeys.h"
class tst_diagramsortkeys : public QObject
{
Q_OBJECT
private slots:
// positionKey() must sort the same way the underlying coordinates do,
// including across differing integer-part digit widths and across the
// negative/positive boundary. A previous implementation formatted
// coordinates with plain "%.4f" and compared the resulting strings
// directly, which sorted "15.0000" before "5.0000".
void sortsLikeNumbers_data()
{
QTest::addColumn<QPointF>("smaller");
QTest::addColumn<QPointF>("larger");
QTest::newRow("single vs double digit") << QPointF(5.0, 0.0) << QPointF(15.0, 0.0);
QTest::newRow("double vs triple digit") << QPointF(0.0, 99.0) << QPointF(0.0, 100.0);
QTest::newRow("negative vs negative") << QPointF(-15.0, 0.0) << QPointF(-5.0, 0.0);
QTest::newRow("negative vs positive") << QPointF(-1.0, 0.0) << QPointF(1.0, 0.0);
QTest::newRow("negative vs zero") << QPointF(0.0, -0.0001) << QPointF(0.0, 0.0);
QTest::newRow("fractional precision") << QPointF(1.0001, 0.0) << QPointF(1.001, 0.0);
}
void sortsLikeNumbers()
{
QFETCH(QPointF, smaller);
QFETCH(QPointF, larger);
QVERIFY(DiagramSortKeys::positionKey(smaller) < DiagramSortKeys::positionKey(larger));
}
};
QTEST_APPLESS_MAIN(tst_diagramsortkeys)
#include "tst_diagramsortkeys.moc"