Compare commits

..

25 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
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
48 changed files with 1159 additions and 120 deletions
@@ -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

+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;
+235 -3
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
*/
@@ -1382,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());
@@ -1454,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);
}
@@ -1497,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);
}
@@ -1546,6 +1773,11 @@ void DiagramImageItem::setTransparentColor()
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);
}
@@ -24,6 +24,7 @@
#include <QColor>
#include <QList>
#include <QStyleOptionGraphicsItem>
#include <QVector>
class QDomElement;
@@ -96,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);
@@ -129,6 +143,10 @@ class DiagramImageItem : public QetGraphicsItem {
void setTransparentColor();
void crop();
void restoreAspectRatio();
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();
+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;
+20 -3
View File
@@ -112,7 +112,8 @@ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixm
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);
@@ -159,7 +160,7 @@ void ImageTransparentColorDialog::onColorPicked(const QColor &color)
return;
m_pickedColors.append({color, m_lastToleranceUsed});
m_okButton->setEnabled(true);
updateOkEnabled();
rebuildSwatches();
updatePreview();
}
@@ -183,6 +184,22 @@ void ImageTransparentColorDialog::setToleranceForIndex(int index, int 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()
@@ -196,7 +213,7 @@ void ImageTransparentColorDialog::removeColor(int index)
return;
m_pickedColors.removeAt(index);
m_okButton->setEnabled(!m_pickedColors.isEmpty());
updateOkEnabled();
rebuildSwatches();
updatePreview();
}
+2
View File
@@ -142,11 +142,13 @@ class ImageTransparentColorDialog : public QDialog
void removeColor(int index);
void rebuildSwatches();
void updatePreview();
void updateOkEnabled();
static QPixmap onCheckerboard(const QImage &image);
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;
+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"