Compare commits

..

269 Commits

Author SHA1 Message Date
Laurent Trinques 06d31d9921 Update Windows CI/CD
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m54s
2026-10-21 15:33:41 +02:00
ispyisail 139993a691 Merge pull request #1575 from ispyisail/fix/1571-element-cache-lock
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m44s
Fix #1571: collection freezes while another QElectroTech is open
2026-10-21 22:30:41 +13:00
ispyisail 2bedfaa394 Fix #1571: collection freezes while another QElectroTech is open
Every QElectroTech on a computer shares elements_cache.sqlite. The
cache set PRAGMA locking_mode = EXCLUSIVE, so a QElectroTech that had
read the file once kept it locked until it closed. A second one -- a
build in another folder, or a build beside a released version, which
SingleApplication lets run because its key includes the program's path
and version -- could not write to it: each write waited SQLite's 5 s
busy timeout and failed, on the GUI thread, twice for every symbol the
collection showed. The report's log is exactly that: an empty
QSqlError every 5.0 s for three minutes.

Use SQLite's normal locking, which holds a lock only while a statement
runs, and give this connection no busy timeout: the cache only saves
time, so a busy file is a cache miss, never a wait. Print the failing
query's error, not the connection's, which was always empty.

Measured with the real program running and a second process writing to
its cache: before, the write fails "database is locked" after 5.01 s;
after, it succeeds in 0.02 s. tst_elementcachelock opens two caches on
one file; it fails before the fix with the report's empty QSqlError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:06:01 +13:00
ispyisail 8ce3b214c1 Merge pull request #1563 from DaTweety/feature/place-symbol-texts
Place symbol texts: label and notes as one block, at one of nine points
2026-10-21 21:46:42 +13:00
ispyisail de248eb20a Merge pull request #1555 from DaTweety/fix/element-text-resize-corner
Symbol text width handles: the point the text is aligned on stays in place
2026-10-21 21:41:17 +13:00
ispyisail 76a36bc5e9 Merge pull request #1573 from ispyisail/fix/autonum-increment-width
Fix the Auto numbering Increment boxes cut off on Windows
2026-10-21 21:40:13 +13:00
ispyisail 0e5d4ad990 Merge pull request #1572 from ispyisail/fix/selection-panel-collapse
Shrink the Selection properties panel again when nothing is selected
2026-10-21 21:39:55 +13:00
ispyisail f4a7d5ebbb Merge pull request #1574 from ispyisail/refactor/autonum-save-from-db
Save the project's numbering schemes from the project database
2026-10-21 21:39:36 +13:00
ispyisail 537e3c3c10 Fix the Increment boxes of the Auto numbering panel cut off on Windows
Each Increment spin box had a fixed maximum width of 55 px. The Windows
11 style puts the up and down arrows side by side, which leaves almost
nothing of 55 px for the number (#1566). The boxes now take the width
their style asks for.

Test: tst_autonumincrementwidth runs the editor under a style that
widens spin boxes the way the Windows 11 style does and checks each
Increment box is at least as wide as the style needs: 55 px against 81
px before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:26:57 +13:00
ispyisail dc30b26dab Shrink the Selection properties panel again when nothing is selected
#1533 stopped the panel shrinking to its title bar when the selection
had nothing to edit, so that it would keep the height the user gave it
(#1439). Users want the room back for the Collections and Auto numbering
panels while nothing is selected (#1566).

The panel's layout is back to QLayout::SetMaximumSize, so the empty
panel shrinks as before. Just before its last editor goes, the panel
keeps the heights of every docked panel in its area; when an editor
comes back it gives them back with QMainWindow::resizeDocks, the call a
separator drag makes. Giving only this panel its height back fell 35 px
short in the test: the neighbours had been split again from their size
hints and gave up only part of the room.

Test: tst_selectionpanelheight now checks, twice over, that the empty
panel shrinks and that selecting an element again brings back the height
the user gave it. Without the restore the panel comes back at 470 px
instead of 560; on master the empty panel does not shrink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:26:57 +13:00
ispyisail 04ad1c890a Write the project's numbering from the project database
QETZ-DB-FIRST stage 2b-2. The save writes the numbering schemes and
their settings from the project database's numbering rows (2b-1)
instead of the project's own lists. The project still keeps the rows
in step after each change, and QET_CHECK_NUMBERING_ROWS still says
whether the two agree, until the lists go (2b-3).

tst_elementautonumids::numberingRowsFollowEveryChange also checks the
saved file: the current symbol and wire numbering and the schemes. With
setCurrrentElementAutonum() not writing the rows, a save straight after
it now names the old scheme in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:40:29 +13:00
ispyisail 672ba7c409 Merge pull request #1568 from ispyisail/refactor/autonum-through-db
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m54s
Keep the project's numbering schemes in the project database
2026-10-21 14:39:34 +13:00
ispyisail 025bd0a57e tst_elementautonumids: the numbering rows test on a one-folio project
A --run script is stopped after 30 s: on industrial.qet the 23 saves
took 23 s natively and did not finish under AddressSanitizer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:54:50 +13:00
ispyisail d05cc654ad Keep the project's numbering in the project database too
QETZ-DB-FIRST stage 2b-1. The numbering schemes of wires, symbols,
folios and cables, and the settings saved with them (the scheme in use
of each kind, freezing new labels, auto-break, the cable axis and
question), get three tables of the project database: numbering_scheme,
numbering_part (one row per part, in order) and numbering_setting.
QETProject writes them when it reads its file and after each change of
its numbering, from every setter; nothing reads them yet.

The save writes the numbering through writeNumberingXml(), the code it
had moved out of writeDefaultPropertiesXml(), so the same writer can
take the rows once the save moves to them (2b-2).
QET_CHECK_NUMBERING_ROWS=1 writes the numbering from the project and
from the rows at each save and says whether the two differ.
tst_elementautonumids::numberingRowsFollowEveryChange checks it after
each of 23 changes; with one setter not writing the rows, one save
differs and it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:54:49 +13:00
Laurent Trinques 992058348b Report jump: put the mouse pointer on the end jumped to
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m41s
A double click on a linked folio report showed the other end's folio
and highlighted it, but left the mouse pointer where it was, on every
platform, while only Wayland forbids a program to move it. On Windows,
X11 and macOS the pointer is now put on the end jumped to, once the
folio shown has its size (next turn of the event loop), so that a
second double click jumps back. On Wayland the highlight alone still
shows where the jump landed.

The pointing hand over a report's label text also stayed after a
click: mousePressEvent() puts the text colour back, and the cursor was
only unset with the colour in hoverLeaveEvent(). It is now always
unset when the mouse leaves the text.
2026-10-20 19:42:23 +02:00
DaTweety 1bf076eca8 Width handles: a text is not dragged narrower than its widest word
Narrower, a long word ran out of the text's box while the short lines
were still aligned inside the too narrow width: on a right aligned or
centred text a lone "1" stood in the middle under its words, and moved
on every step. Found on a real plan (-XS20, "Steckdosen Windfang, 1").
The probe checks that no line runs out of the text after narrowing, and
also for a text aligned bottom left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:53:52 +02:00
DaTweety 8f2cd9f039 Width handles: read the drag in the text's coordinates as they were at the press
The text moves on every step of a drag to keep its alignment point in
place. Mapping the mouse with its current transform made the delta grow
with every step, so a right or centre aligned text ran away: 90 px of
mouse gave +1220 px of width. The probe now drags in 30 steps and checks
the width grows by exactly as much as the mouse moved.

Reported by ispyisail in the review of #1555.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:23 +02:00
DaTweety 214b0e3b7d Width handles keep the point the text is aligned on
Which point of a symbol's text stays in place while its width is
dragged now follows the text's alignment, the nine points of its
alignment dialog, rather than the handle: a text aligned top right
keeps its top right corner whichever handle is dragged, one centred
keeps its middle, and a text that wraps grows away from that point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:23 +02:00
DaTweety 52adc8b3f8 Width handles: a text that wraps keeps its top corner in place
Narrowing a symbol's text with the left handle until it wraps onto
more lines moved it up: the point kept in place was the middle of the
opposite edge, and the middle moves down as the text grows. Keep the
top of the opposite edge instead, or the middle or the bottom for a
text aligned that way, for both handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:23 +02:00
DaTweety 74f908aa1e Symbol text width handles: the opposite edge stays in place
Dragging the left width handle of a symbol's text changed its width but
kept its position, which is its left edge: the right edge moved with the
drag and the text jumped away from where the user held it. Now the edge
opposite the dragged handle stays in place, as in a drawing program and
as the corner handles of a free text already do (TextResizeHandles). It
is found along the text's own baseline, so a rotated text behaves the
same. Undo restores width and position together, with TextResizeCommand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:23 +02:00
DaTweety f828e464c4 Place symbol texts: undo puts a group's texts back where they lay
Placing a group switches its alignment, which lays its texts out anew.
Undoing to AlignJustify, the default of the "Label and comment" group,
lays out nothing, so the texts stayed at their placed offsets. The
positions of the group's texts are now part of the undo step. The probe
reproduces the case from a group with freely placed texts.

Reported by ispyisail in the review of #1563.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:17 +02:00
DaTweety 25ffa414af Place symbol texts: bigger bars in the icons, bigger grid buttons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:17 +02:00
DaTweety 9c3c19a316 Place symbol texts: the icon shows where the texts go
Each of the nine points has its own icon: a grey square for the symbol
and two bars for the lines of text, where they will go and lined up as
they will be. The grid shows them instead of arrows, and the toolbar
button takes the icon of the point chosen last, so that one sees where
a click on it puts the texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:17 +02:00
DaTweety 0a577eaabf Place symbol texts: label and notes as one block, at one of nine points
A new command, "Place symbol texts", for the selected symbols: their
label and the notes beside it go as one block next to the drawing of
the symbol, 3 px away, at one of the nine points of the text alignment
dialog, chosen in a 3 x 3 grid that opens from the toolbar button and
from the Edit menu. A click on the button places them at the point
chosen last (left of the symbol at first).

Left of the symbol the lines are aligned right, so that they hang on it
and a longer text grows away from it (their alignment is set too);
right of it aligned left; above, below and on it centred. The lines
keep their order and their spacing, a group of texts is placed as one
line with the same alignment, a turned symbol is placed in the folio's
directions, and everything is one undo step.

Element::drawingRect() gives the area the drawing covers, tighter than
the bounding rect, which is rounded up to the grid. A toolbar command
with a menu now shows as a button with an arrow beside it.
Translations for all languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:01:17 +02:00
ispyisail aebe82d6c2 Merge pull request #1554 from DaTweety/feature/report-jump
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m13s
Jump between folio report ends with a pointing hand and a highlight
2026-10-20 13:10:14 +13:00
ispyisail eba05bed43 Merge pull request #1537 from DaTweety/feature/nomenclature-folio-place
Nomenclature: an element is where its folio is
2026-10-20 13:10:02 +13:00
DaTweety 7a2b390b78 Report jump: show the target's whole folio, shade its texts too
Feedback on a real plan: zooming in on the end jumped to was too much.
A jump now shows the target's folio whole, as a page (the view's fit),
selects the target and shades it and its texts blue for three seconds,
so that the eye finds the label too. One function, Element::jumpTo(),
serves the double click on the arrow and on the text.

The mouse pointer is not moved onto the target: Wayland does not let a
program move the pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:39:06 +02:00
Laurent Trinques aa7f328aeb Update translations files 2026-10-19 22:04:05 +02:00
Laurent Trinques 3a863d4b09 Update translations files 2026-10-19 21:59:21 +02:00
Laurent Trinques d2bacfd526 Update translations files 2026-10-19 21:46:18 +02:00
DaTweety 5641b031f9 Jump between folio report ends with a pointing hand and a highlight
A linked folio report now shows a pointing hand under the mouse, on its
arrow as well as on the text that shows its label (which already turned
blue), and a double click on the arrow goes to the other end, as a
double click on the text did. Before, a double click on the arrow opened
the report's properties, which stay reachable from the context menu and
the properties panel.

The end jumped to is highlighted for two seconds, with the same blue as
the hover highlight but independent of it: the hover highlight is
cleared as soon as the mouse leaves the end jumped from, so on the same
folio the user saw nothing mark where the jump landed.

The text of a linked slave that shows its master's label gets the same
blue and hand, since a double click on it already jumped to the master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:30 +02:00
Laurent Trinques adf3ea301c Update translations files
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m52s
2026-10-19 20:01:16 +02:00
Laurent Trinques ee227a007e Update translations files 2026-10-19 19:28:16 +02:00
plc-user c32ebc5936 PartArc, normalize: after review 360 becomes 0 degree 2026-10-19 19:12:19 +02:00
Laurent Trinques 758703f50e Merge pull request #1560 from frakbenparker/perf/assignvariables-qsettings
AssignVariables: open QSettings only for element formulas
2026-10-19 18:27:47 +02:00
Laurent Trinques a12fb43bcb Merge pull request #1550 from Kellermorph/auto-terminalstrip
Terminal strip: create the strips from the free terminals' labels
2026-10-19 18:14:13 +02:00
Laurent Trinques e7a168d1f9 Merge pull request #1556 from ispyisail/test/round-trip-gate
Test the round trip between .qet and .qetz in CI
2026-10-19 17:23:51 +02:00
Laurent Trinques a9715b5bf6 Merge pull request #1559 from DaTweety/feature/folio-crosshair
Folio-wide crosshair under the mouse (Display menu)
2026-10-19 17:21:08 +02:00
Laurent Trinques f5ffbd6a9c Merge pull request #1557 from ispyisail/fix/spacemouse-scan-only-3d-mice
Fix the 3D mouse search asking every device for its name (#1553)
2026-10-19 17:17:37 +02:00
DaTweety 4f0c493a7f Nomenclature: an element is where its folio is
element_nomenclature_view gave an element's own plant and location only,
which are empty unless typed into every element. A project structured by
its folios (=plant +location on the title block, %M / %LM) therefore
showed no place in the BOM, nomenclature tables and queries, although the
folio says where each device is -- the way EPLAN builds the device tag
from the page structure.

The view now falls back to the folio's plant and location when the
element has none of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:40:29 +02:00
DaTweety f994957965 Folio-wide crosshair: translations for all languages
The menu entry and its status tip in every translation file, added by
hand next to "Display or hide the grid of sheet" so that the files are
not rewritten by lupdate. German: "Fadenkreuz über die ganze Seite".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:49:38 +02:00
Ramon f6c9aeb177 AssignVariables: open QSettings only for element formulas
Every formula evaluation constructed a QSettings, although only element
formulas read it (border-columns_0, for %c). Constructing QSettings is
costly, on Windows a registry lookup, and conductor labels are evaluated
in bulk: Conductor::refreshText() runs for every wire of a folio when it
is built, and for every wire of every built folio when the project or a
title block changes.

Debug build, 40 folios with 4,280 numbered wires:
- refreshText() of every wire: 1,136-1,264 ms before, 203-208 ms after
- opening the project (database fill): 2.24-2.48 s before, 1.46-1.59 s after
- building 39 lazy folios: 15.0-15.3 s before, 12.3-12.5 s after

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:44:08 +02:00
DaTweety 3738b3c1d8 Folio-wide crosshair under the mouse (Display menu)
A new option in the Display menu, off by default and kept in the
settings: two thin lines cross the whole folio, inside its border,
through the grid point under the mouse, so that one sees at a glance
whether symbols and terminals are in line, also while dragging them.

They are drawn by the view in drawForeground(), on screen only: no item
is added to the folio, so nothing of them is saved, exported or
printed. Only the strips the lines leave and enter are repainted, and
they go when the mouse leaves the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:20:00 +02:00
ispyisail 8fa58bd609 Fix the 3D mouse search asking every device on the PC for its name
While no 3D mouse is plugged in, which for most users is always, the
hidapi backend looks for one every 3 seconds for as long as
QElectroTech runs. It asked hidapi for every HID device,
hid_enumerate(0, 0), and on Windows hidapi reads the serial number,
manufacturer and product strings of each device that matches what it
is asked for from the device itself. So every mouse, keyboard, headset
and webcam on the PC was asked for its names every 3 seconds, on the
GUI thread. Issue #1553 reports the pointer moving by itself on Windows
since the first package with this backend; nothing else in that package
talks to devices. SDL 2.0.9 polled the same way and saw freezes and a
keyboard that kept re-initialising (libsdl-org/SDL#3071).

The scan now asks for 3Dconnexion's vendor id, and for each 3D mouse
made under Logitech's id by its product id, since most other mice and
keyboards carry Logitech's id too. That is twelve hidapi calls instead
of one, but a device that does not match is no longer asked for its
names. On Linux and macOS hidapi takes the names from the system, and
one call is cheaper than twelve (on Linux, median of 200: 5 ms against
36 ms), so the scan there is unchanged.

Where the scan is filtered, a Logitech-id device that is not one of the
eleven 3D mice on the list is no longer found, even if it reports
itself as a multi-axis controller. 3Dconnexion's own mice and keyboards,
such as the CadMouse, are still asked: their vendor id is asked for as
a whole, so that new 3D mice are found without a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:39:02 +13:00
ispyisail 5837bec4fe Merge remote-tracking branch 'upstream/master' into test/round-trip-gate 2026-10-19 22:34:36 +13:00
Kellermorph 3e2ad8c4bd Terminal strip: create the strips from the free terminals' labels
Add an automatic import which groups the free terminals of a project
by the part of their label before the first colon ("X1:2" goes to the
strip "X1"), creates the strips missing and sorts the terminals in
natural order, as one undoable step.

The import runs when the terminal block manager opens on a project and
can be run again from a new toolbar action. It only adds : the strips
already known keep their terminals and their order, and terminals
without label are left free.
2026-10-19 10:47:17 +02:00
ispyisail 4fee464919 Merge pull request #1552 from ispyisail/fix/keep-unloadable-items
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m43s
Fix symbols and wires lost when their definition cannot be found
2026-10-19 20:58:13 +13:00
ispyisail 2adbc17b3c Merge pull request #1549 from ispyisail/fix/closed-project-panels
Fix a crash after closing a project without saving (forum 3215)
2026-10-19 20:39:31 +13:00
ispyisail 64e8ca0263 Merge pull request #1547 from ispyisail/feat/gost-eskd-titleblocks
Add GOST ESKD title blocks (Russian standard GOST 2.104)
2026-10-19 20:38:58 +13:00
ispyisail 289e33d06e Merge remote-tracking branch 'upstream/master' into fix/keep-unloadable-items 2026-10-19 20:21:02 +13:00
ispyisail 46d8588113 Fix a closed project staying in the element collection panel
A project opened before the element collection panel's first load is
queued until the load starts; one opened during a load goes into the
model being loaded. ElementsCollectionWidget::removeProject() only
removed the project from the model already shown, so a project closed
in either window stayed in the collection, and the next reload read the
freed project (crash in XmlElementCollection::root()).

removeProject() now also takes the project out of the queue and out of
the model being loaded. That model waits for its loading threads, which
read the project, and drops the project's items from the list applied
when the loading ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:28:35 +13:00
ispyisail 31730678c5 Fix a crash on clicking a sheet of a project closed without saving
Closing a modified project and choosing Discard left the project in the
project tree. The panel removed its item, but the project still emits
projectInformationsChanged() while it is torn down (setModified()), and
GenericPanel answered by adding the project back, with items for its
sheets. Once the project was freed, a click on one of those sheets
called diagram->showMe() on a freed Diagram: SIGSEGV in
QObjectPrivate::maybeSignalConnected(), the backtrace reported on the
forum (topic 3215).

The panel now stops listening to a project when it is closed, to its
sheets and to its embedded title block collection, before removing its
item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:28:31 +13:00
ispyisail 7fcd6086a8 Test the round trip between .qet and .qetz on every example and a project using every feature
scorpio810's condition for the database as the model (#1512): a lossless
round trip between the formats, tested in CI. For every example and for
fixtures/every_feature.qet, the save stamps aside:
- a .qetz saved again is the same file;
- a .qetz saved as a .qet is the project saved straight as a .qet;
- that .qet saved as a .qetz is the project saved straight as a .qetz,
  part by part, its database row by row;
- lazy folios save what every folio built saves, in both formats.

fixtures/every_feature.qet (arummler's test file, #1512) was built through
QElectroTech's script API: four folios, numbering schemes with every kind
of part, a coil linked to three contacts, folio reports, terminals, a
cable, wires with every property, every symbol information field, symbol
texts of every source, independent texts, shapes, a curve, a picture, a
PLC master with its inputs and outputs, a terminal strip bridged, a parts
list and a folio summary, copied, rotated and mirrored symbols. What the
API cannot set was added in the form QElectroTech saves it (new-folio
defaults, a folio field of the user's, single-line wires, a wire text
moved by hand, a coloured symbol text, a text group, an old information
key, a parts list continued on a fourth folio), then QElectroTech opened
and saved it. Of every tag and attribute QElectroTech wrote saving the
341 projects of our corpus, it holds all those found in 5 or more of them
and 98.3 % of all; it also holds what none of them has (terminal strips,
PLC data, tables).

It failed before the previous commit (the .qetz saved again had its folio
parts in another order).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:14:49 +13:00
ispyisail 5292e290d1 Write a .qetz's folio parts in the order of the folios
A .qetz's folios kept as they were (not built since it was opened) had
their parts written first, then those of the folios built: saving the same
.qetz again with another folio built gave a zip with every part the same
and the parts in another order. Write them in the order of the folios,
the names of the parts kept still taken first so a folio built cannot
take one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:14:49 +13:00
Laurent Trinques 1c0049e083 Update translations files 2026-10-19 06:53:00 +02:00
ispyisail 833ed0f523 Keep in a .qetz what QElectroTech cannot compute for a symbol it cannot build
The .qetz leaves out of its folios what QElectroTech computes when it
builds them (format 4): a symbol's terminal list and information texts,
a wire's description of its ends. A symbol whose definition the project
does not hold is never built, so none of it came back: through a .qetz,
the symbols and wires kept by the previous commit lost their terminal
lists, texts and end labels. QetContainer::dropComputed() now leaves a
symbol whose definition is not in the project's collection
(embeddedDefinitions()), and the wires ending on it, as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:21:45 +13:00
ispyisail 7f05b23062 tst_movedterminals: count the wires joined, a wire not joined is kept now
A wire whose terminal is not matched is saved back as it was read (F128),
so the saved file holds it: the cases count the wires joined (saved, less
those QElectroTech says it did not load), and the cases that refuse a
match check the wire is still in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:11:24 +13:00
ispyisail 735995037c Add GOST ESKD title blocks (GOST 2.104-2006 forms 1, 2 and 2a)
Three title block templates for Russian-standard drawings, made by
arccis and shared on the forum (topic 3215):

- ESKD_Form1: main inscription for drawings and diagrams (form 1)
- ESKD_Form2_text: main inscription for text documents (form 2)
- ESKD_Form2a: inscription for the following sheets (form 2a)

The cell text is Russian, so it is tagged lang="ru" instead of the
lang="en" the files were saved with. QET shows the only translation a
cell has whatever the interface language, so they display the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:04:09 +13:00
ispyisail 92ee1b6617 Name a missing definition in the warning about wires not loaded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:48:40 +13:00
ispyisail 0bcd0ca4b6 Keep the symbols and wires a folio cannot load, and save them back
Opening a project dropped, with only a debug line, a symbol whose
definition could not be found (missing from the project's collection and
from the library) and every wire on it; and a wire whose terminal could
not be matched, after the symbol's definition was redrawn. The next save
wrote the project without them. One save of a forum upload whose
collection was incomplete kept 1 of 219 symbols and none of its 299 wires.

A folio now keeps what it could not load as it was read
(Diagram::m_kept_symbols, m_kept_wires) and writes it back when it is
saved whole (Diagram::writeKept()):
- a symbol, its terminals given ids after those of the folio's symbols;
- a wire, an end found written from its terminal as Conductor::toXml()
  does, an end not found as the symbol and terminal uuids it names; for a
  wire saved by legacy ids, the terminal's uuid derived from where it was
  saved (Element::savedTerminalUuid(), out of matchMovedTerminals()), so
  it joins again once the definition has it there. A wire whose ends
  cannot be read, or an end naming no terminal of the file, is kept so
  that it joins nothing.
A symbol kept counts as waiting (projectDataBase::waitingSymbols()), so a
partner keeps its link to it. The open warning now lists the symbols not
loaded too, and says both are kept.

Once the definition is back, the symbol and its wires are built again:
741.qet with one transistor definition removed (8 symbols, 33 wires on
them) saves all 67 wires, and with the definition put back opens with
all 65 symbols and 67 wires, the same folio as before.

tst_resaveunchanged::unloadableItemsKept: fixtures/coil_contact_wired.qet
(coil, linked contact, two wires; built with the script API) with the
coil's definition removed, or the contact's terminals moved and given new
uuids: both symbols, wires and links saved, a second save the same, and
the coil's definition put back gives the undamaged project's symbols and
wires. Fails on master (1 symbol; 0 wires).

FINDINGS F127, F128.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:45:55 +13:00
ispyisail a5da2e9b8d Merge pull request #1532 from DaTweety/fix/check-labels-per-location
qet MCP: count duplicate labels per plant and location
2026-10-19 16:21:59 +13:00
ispyisail cafae25c31 Merge pull request #1531 from DaTweety/fix/mcp-edit-output-tail
qet MCP: keep the edit script's report lines when cutting long output
2026-10-19 16:21:43 +13:00
ispyisail 6dce85f2fb Merge pull request #1526 from DaTweety/feature/script-add-cable
Scripting and qet MCP: draw a cable across conductors
2026-10-19 16:21:26 +13:00
ispyisail b4d69f176a Merge pull request #1536 from DaTweety/fix/new-wire-inherits-potential
A new wire takes the potential it reaches through terminals and reports
2026-10-19 16:21:10 +13:00
ispyisail 266d1e2573 Merge pull request #1540 from hairykiwi/fix/elements-panel-crashes
Guard elements-panel auto-select against a detached project item
2026-10-19 16:00:33 +13:00
ispyisail 1e988eadfd Merge pull request #1490 from Kellermorph/fix-textposition
Stop a folio report's text jumping when the report is not linked
2026-10-19 15:49:13 +13:00
ispyisail 3abaf21302 Merge pull request #1543 from ispyisail/fix/save-keeps-qet
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m54s
Fix Save writing a .qetz beside an opened .qet: keep the .qet
2026-10-19 15:07:41 +13:00
ispyisail 171a597b12 Merge pull request #1546 from ispyisail/refactor/link-from-db-rows
Link symbols on folios opened later from the project database
2026-10-19 15:07:21 +13:00
ispyisail 4d61909fd1 Merge pull request #1545 from ispyisail/refactor/save-links-from-db
Fix removed folios leaving links to their symbols in the saved file
2026-10-19 15:07:03 +13:00
ispyisail 9421aff1b2 Merge pull request #1525 from DaTweety/feature/report-folio-title
Reports: %T for the target folio's title, element info in composite texts
2026-10-19 14:46:10 +13:00
ispyisail 3ce605b57a Merge pull request #1528 from DaTweety/fix/tray-icon-name
Show the logo in the Linux system tray
2026-10-19 14:29:21 +13:00
ispyisail afe2138feb Merge pull request #1542 from plc-user/master
PartArc: normalize start and span to have positive values in element-file
2026-10-19 14:17:57 +13:00
ispyisail 9586bc16cd Merge pull request #1544 from Kellermorph/fix/terminal-level-spinbox-max
Terminal strip: limit the level spinbox to the drawn stages
2026-10-19 14:05:34 +13:00
ispyisail 9a41e01187 Link a symbol built to its partners from the project database's rows
A symbol built after opening (lazy folios) links to the partners its
link rows name, and to the symbols whose rows name it; its file's list
only adds what the rows miss. The rows keep a partner on a folio not
built until it is built, in its saved place, so a symbol no longer keeps
a list of its own: the list read from the file is dropped once opening
has filled the database. pendingLinks(), waitingLinks(),
keepsSavedLinks(), linksToSave() and QETProject::waitingLinkTargets()
go; the callers that asked a symbol for its waiting partners ask the
database (waitingPartners()).

When its links change, a symbol's rows are written from its built
partners and the waiting partners its rows held, in their order
(rowsToWrite()), so a full rewrite of the table keeps them too.

A symbol built alone as a partner on a folio not built
(Diagram::buildSymbols()) counts as built: its rows are kept, it is not
waiting (waitingSymbols(), waitingPartners()), so saving does not build
its folio, and removing it does not remove the rows naming it. A row
naming a placed symbol that has not linked yet (its folio being built)
is kept too. At opening the database holds no link until its fill
(forgetLinks()); the symbols read their files' lists until then, and the
fill takes their order: a symbol's own rows, its list, then the rows
naming it. The symbols waiting on folios not built are cached and
updated as folios are built.

QET_CHECK_LINK_ROWS now checks the rows against the links: each built
partner in its order, no other row but to a folio not built.

tst_resaveunchanged::undoKeepsLinksToUnbuiltFolios: m_000 opened with
lazy folios, a contact, its folio or its coil's folio (not built)
removed, then undone: every link saved as before.

QETZ-DB-FIRST stage 2a-3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:47:41 +13:00
DaTweety 308cf4b551 Tray icon: each size at device pixel ratio 1, comment indentation
On a HiDPI screen QIcon::pixmap() returns each requested size at the
screen's ratio, so the sizes handed to the tray no longer matched
16 ... 256. Requested at 1.0, as ProjectFileGP in qeticons.cpp does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:57:21 +02:00
DaTweety ba64fd5164 %T last: a folio title keeps the variables it contains
The folio title was put into the formula before %c, %l, %prefix, the
wire variables, the title block and project variables and the
sequences, so a title such as "Schema %c" had them replaced inside it.
It is replaced after all of them now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:52:06 +02:00
DaTweety 7fa242a55f Keep a potential's wire number for a new wire drawn from a cable core
inheritedProperties() cleared the text of a conductor held by a cable,
on the premise that CableManager writes the core label into it. It
writes it into m_cable only (Conductor::setCableReference()); the text
is the potential's real wire number. Clearing it lost a typed number on
the new wire and made distinctProperties() see a choice between ""
and the number where the potentials agreed. The text is now cleared only
when it repeats the core label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:46:25 +02:00
Laurent Trinques d1b5489bfe Update Slovak translations files, thanks Jozef 2026-10-18 21:52:49 +02:00
Kellermorph f32df521fc Terminal strip: limit the level spinbox to the drawn stages
The level spinbox of the terminal strip editor allowed stages up to 6
while the strip drawing handles at most 4 stages (0 to 3), so a stage
entered there was simply not drawn. Cap the spinbox at 3 and keep the
editor consistent with the drawing and the level columns.
2026-10-18 21:13:53 +02:00
Laurent Trinques 24c90cc049 Terminal block manager: show the folios being read, stay responsive
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m41s
Opening the terminal block manager builds the folios not built yet (lazy
folios). This is done in one go without running the event loop, so the
window system said the opening message was "not responding".

- QETProject::setFolioBuildObserver(): a function called after each folio
  built by buildFolios(), with the number of folios built and to build.
- The manager uses it to show "Reading folio n of N..." with a real
  progress bar, and runs the event loop (without the user input) between
  two folios, so the window system keeps getting answers.
- The autosave skips its turn while folios are being built, since the
  event loop can now run then.
- The opening message only has a title bar: nothing to minimize, maximize
  or close.
2026-10-18 17:33:18 +02:00
Laurent Trinques 662a0c8f5c Terminal block manager: really show the opening message
One pass of the event loop after show() was not enough: the message box
was not painted yet when the long work started (seen on Wayland), only
the busy cursor was visible.

Wait for the window of the message to be exposed by the window system
(500 ms at most), then paint it before building the manager.
2026-10-18 17:06:39 +02:00
Laurent Trinques 89bf07f5c7 Terminal block manager: say that it is opening
Opening the terminal block manager reads every terminal of the project,
and builds the folios not built yet (lazy folios): on a big project it can
take a while, and the user could think that nothing happens.

TerminalStripEditorWindow::instance(), used by the menu action and by a
double click on a terminal strip of a folio, now shows a busy message
("Opening the terminal block manager...") and a wait cursor while the
window is built or switched to another project. Moved from the header to
the .cpp file.
2026-10-18 17:01:59 +02:00
Laurent Trinques ff18467117 Terminal strip item: also redraw an item just added to a folio
The redraw on change was connected in setTerminalStrip() only, used when
an item is read from the project. An item added with the "Add terminal
strip" dialog is built with TerminalStripItem(strip), which did not
connect: it was not redrawn after the terminal strip editor was applied,
until the project was reopened.

Both constructors now go through connectStrip().
2026-10-18 16:53:31 +02:00
Laurent Trinques 015811d243 Terminal strip editor: add spare terminals, numbered, counted in the nomenclature
A spare (reserved) terminal is a terminal of a strip which is drawn on no
folio. RealTerminal already foresaw it ("a terminal set by user but not
present on any folio") but every terminal needed an element.

- RealTerminal::createSpare(): a terminal without element, with its own
  data (label, type, function, led, informations: section, width, catalog
  terminal, color, manufacturer...). Saved in the strip:
  <real_terminal spare_uuid="..." type="..." function="..."><informations>
  Older versions ignore these terminals.
- TerminalStrip::insertSpareTerminals() / insertTerminals(): insert at a
  position of the strip. Undoable commands AddSpareTerminalsCommand and
  ChangeSpareTerminalDataCommand.
- Terminal strip editor: "Add spare terminals..." button. The terminals
  are inserted after the selected terminal (at the end if none), numbered
  from its label ("X1:12" -> "X1:13", "X1:14", leading zeros kept), with
  its properties if asked. The labels can be changed or removed in the
  table, like the other cells; the Xref column shows "Spare". Moving a
  spare terminal to the independent terminals removes it (undoable).
- Nomenclature: the spare terminals are in the new spare_terminal table,
  added to element_nomenclature_view (UNION ALL, same columns), after the
  folios, in the order of their strip. exclude_from_bom is honoured.
- The modified cells of the editor model are kept by real terminal, not by
  element, since a spare terminal has none.
2026-10-18 16:34:41 +02:00
Laurent Trinques ddc71553e2 Terminal strip item: redraw the strip as soon as it changes
TerminalStripItem was connected to nothing: after applying the terminal
strip editor (width, catalog terminal, color, type...), undo or redo, the
strip drawn on the folio kept its old drawing until something else made
the scene repaint, and its bounding rect was not updated when the width of
the terminals changed.

- TerminalStrip::dataChanged() is emitted when the data of the strip is
  set, and when the data of the element of one of its terminals changes
  (Element::setElementData(), Element::setElementInformations()).
- TerminalStripItem connects to dataChanged(), orderChanged(),
  bridgeChanged() and bridgeColorChanged() of its strip, and updates its
  geometry and redraws itself.
2026-10-18 16:06:59 +02:00
Laurent Trinques cbe41aeebf Previous versions: avoid the trigraph warnings of the file name pattern
The glob "????-??-?? ??-??-??" contains the trigraph "??-", which GCC
reports with -Wtrigraphs (8 warnings). The pattern is now written with
"\?" escapes, in one place; it is the same string.
2026-10-18 16:01:58 +02:00
Laurent Trinques ea2acfcaeb Realistic terminal strip: draw the clamp of the terminal chosen in the catalog
The realistic styles drew every terminal of a strip with the clamp of the
layout (RealisticScrew or RealisticPushIn), whatever terminal was chosen:
a Phoenix Contact ST (spring) was drawn with screws in a screw layout.

Each entry of the terminal catalog now has its clamp: screw (Phoenix
Contact UT, Weidmüller WDU, ABB Entrelec M, Legrand Viking 3), spring
(Phoenix Contact ST) or push-in (WAGO TOPJOB S). A terminal chosen in the
catalog is drawn with its own clamp, the spring-cage ones like the push-in
ones; the clamp of the layout is kept for the other terminals, so a strip
mixing screw and spring terminals is drawn as it is.
2026-10-18 15:48:00 +02:00
Laurent Trinques f7196c48e1 Terminal strip editor: remember the manufacturer filter of each strip
The manufacturer chosen to filter the terminals of the Width column is
stored in the data of the terminal strip (information "manufacturer" of
terminal_strip_data, written only when set), and selected again when the
strip is shown in the editor. Each strip keeps its own manufacturer.
Older versions ignore this information.

TerminalStripEditor::apply() now starts from the current data of the strip
instead of a new TerminalStripData: the uuid of the strip was replaced by
a new one at each apply, and the new information would have been lost.
2026-10-18 15:48:00 +02:00
Laurent Trinques 851ddfad16 Terminal catalog: remember the chosen terminal, not only its width
Several terminals of the catalog have the same width (6.2 mm: Phoenix
Contact UT 4 and ST 4; 5.2 mm: WAGO 2002 and Phoenix Contact ST 2,5...).
Only the width was stored, so on reopening the first entry with that
width was shown instead of the terminal that was chosen.

- New element information terminal_catalog_entry: the label of the
  terminal chosen in the catalog (e.g. "Phoenix Contact ST 4").
- Choosing a terminal also fills the manufacturer and manufacturer
  reference informations (bill of materials, %{manufacturer_reference}),
  only when they are empty or still hold what the previous catalog entry
  wrote: a value typed by the user is never overwritten.
- Element editor: the manufacturer and the exact terminal are restored on
  reopening. A width typed by hand, or stored before this change, stays a
  plain width instead of taking the first terminal with the same width.
- Terminal strip editor: new "Manufacturer" combo filtering the terminals
  proposed in the Width column (with the section filter); the column shows
  "6,2 - Phoenix Contact ST 4", and copy/paste keeps the terminal.

Existing projects are unchanged: without the new information, only the
width is used, as before.
2026-10-18 15:48:00 +02:00
Laurent Trinques 41339869a6 Add Traditional Chinese (zh_TW) translation, draft converted from qet_zh.ts with OpenCC s2twp, to be reviewed 2026-10-18 15:07:11 +02:00
plc-user 3a92ec3fb1 PartArc: normalize start and span to have posive values in element-file 2026-10-18 15:01:21 +02:00
ispyisail b473a07b7d Save a symbol's links from the project database
A symbol's <links_uuids> are now what the project database's link rows
list for it, in their order (projectDataBase::savedLinks()), instead of
a list the save built itself from the symbol's partners: QETZ-DB-FIRST
stage 2a, the save reads links from the database. A symbol the database
does not hold (saved outside a project) still lists them itself.

The rows leave out a partner the project no longer has, so removing a
folio with every folio built no longer saves links to its symbols, as
lazy folios already did (the links part of F113). Every other save is
unchanged.

tst_resaveunchanged removedFolioLeavesNoLinkToIt: affuteuse_250h with
every folio built, folio 3 removed: 4 links named symbols no longer in
the file, none now.

Needs #1522 (each symbol its own uuid) and #1523 (rows in saved order);
local only until both are merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-19 01:50:35 +13:00
Laurent Trinques a98f8e33be Merge pull request #1530 from DaTweety/fix/continuity-cable-cores
Continuity check: conductors in different cables are no divergence
2026-10-18 14:45:26 +02:00
hairykiwi 959695ed5c Add regression test for elements-panel project-open auto-select crash
- tst_elementspanelprojectopen drives ElementsPanel::projectWasOpened() --
  the slot the application connects to a project being opened -- on a fresh
  panel whose first reload() has not run, reproducing the detached-item
  ordering deterministically offscreen.
- On master without the guard the test fails: the auto-select leaves
  currentItem() pointing at an item whose treeWidget() is not the panel (and
  on some builds crashes in QTreeModel). With the guard it passes: no item is
  selected while the project is detached.
- Registered via qet_add_app_test(), which links the application code and
  runs a real QETApp offscreen.

Generated-by: Claude Code
Signed-off-by: hairykiwi <174199+hairykiwi@users.noreply.github.com>
2026-10-18 13:44:35 +01:00
hairykiwi 64869810cd Guard elements-panel project-open auto-select against a detached item
- ElementsPanel::addProject auto-selects the first diagram via
  setCurrentItem(qtwi_project->child(0)). When a project is opened before
  the panel's first reload() has created its title-block collection items,
  indexOfTopLevelItem(common_tbt_collection_item_) returns -1 and the
  preceding insertChild() leaves qtwi_project detached from the tree model.
- The unguarded auto-select leaves a detached/invalid current item -- an
  intermittent SIGSEGV in QTreeModel::index()/parent() on some builds; on
  current master with Qt 6.11.2 the test shows the invalid selection without
  a fault. Calling setCurrentItem() on an item the QTreeModel does not own is
  unsafe; guard the auto-select with qtwi_project->treeWidget() so it runs
  only when the item is attached.
- No behavioural change in the normal case: reload() re-adds the project
  attached shortly after and performs the selection then.
- Pre-existing since a82f6de23 ("Add highlight current page in ProjectView").

Assisted-by: Claude Code
Signed-off-by: hairykiwi <174199+hairykiwi@users.noreply.github.com>
2026-10-18 13:44:35 +01:00
Laurent Trinques 3b517d31c0 Merge pull request #1527 from DaTweety/feature/terminal-catalog-phoenix-st
Terminal catalog: add the Phoenix Contact ST spring-cage terminals
2026-10-18 13:56:20 +02:00
ispyisail 89c3979957 Fix Save writing a .qetz beside an opened .qet: keep the .qet
A project opened from a .qet was saved as a .qetz beside it, with a
notice, and the .qet left as it was: a project kept in git did not stay
a .qet. Save now writes the format the project was opened in; only Save
As changes it (scorpio810, discussion #1512). A .qetz is saved as a
.qetz as before.

The save-beside path goes: the flag set on opening, the .qetz path made
from the .qet's, write()'s switch to it and back on failure, and the
notice and replace question in ProjectView::doSave().

tst_resaveunchanged::qetSavedAsQetzBeside becomes qetSavedAsQet: Save of
an opened .qet writes no .qetz and the .qet holds what a plain save of
it holds (it fails without the fix: a .qetz is written beside).
tst_changedondisk makes its .qetz with Save As, as Save no longer does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:56:22 +13:00
DaTweety f325b8001e qet-mcp: count duplicate labels per plant and location
qet_check reported -F1 as a duplicate master when one sits in +UV1 and one
in +UV2, although they are two devices: the place is part of the name, as
the %M/%LM label formulas show it and as IEC 81346 tools treat it. Group
the two duplicate-label checks by label and the effective place, the
element's own plant/location or else its folio's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:49 +02:00
DaTweety 1ff2fa5dcf qet MCP: keep the edit script's report lines when cutting long output
_run_qet() kept only the last 400 000 characters of a run's output. A
large qet_edit (a whole multi-folio drawing in one call, some 2000 ops)
logs more than that, and the cut took the capability report the script
makes first: the tool then answered "the binary never ran the script"
although QElectroTech had run it and saved the result.

Lines the script reports through its marker are now kept whole wherever
they stand; only the other output is cut to the tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:49 +02:00
DaTweety 7d966a0ed4 Show the logo in the Linux system tray
The tray icon was QET::Icons::QETLogo, which comes from QElectroTech's
own icon theme bundled in its resources and so carries the theme name
"qet". On Linux Qt's StatusNotifierItem then sends only that name, and
the panel -- another process, without those resources -- cannot resolve
it: KDE Plasma showed an empty slot. Seen on D-Bus as IconName "qet"
and no IconPixmap.

The tray now gets an icon made of the logo's pixmaps only (16 to
256 px), which the tray sends as pixels. Checked on Plasma: IconName
is empty and IconPixmap carries the eight sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:49 +02:00
DaTweety a8463288aa Continuity check: conductors in different cables are no divergence
checkContinuity() reported potential_mismatch on "cable" whenever the
conductors of one potential ran in different cables. A conductor held
by a cable carries its own core's label in the cable field (15W1:BN,
written by CableManager); a potential spread over several cables is the
normal case, e.g. a neutral fed from a terminal strip into each room.
On a house installation every N and PE potential was flagged.

Conductors held by a cable are left out of the cable comparison; the
free-text cable field of other conductors is still compared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:49 +02:00
DaTweety 83607c0b76 Terminal catalog: add the Phoenix Contact ST spring-cage terminals
ST 1,5 (4.2 mm, 1.5 mm²), ST 2,5 and ST 2,5-QUATTRO (5.2 mm, 2.5 mm²)
and ST 4 (6.2 mm, 4 mm²), next to the UT screw terminals already listed.
Widths from the Phoenix Contact catalog and data sheets (ST 2,5-QUATTRO
3031306, ST 4 3037151, ST 1,5 3031076).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:49 +02:00
DaTweety 04716b0731 Scripting and qet MCP: draw a cable across conductors
qet.addCable(folio, designation, type, coreColors, length, x1, y1, x2, y2)
does what the cable tool does after its dialog: a new cable whose trunk
is the line between the two points, every conductor the line crosses
taking the next core in order along the line, with the colour coreColors
lists at that place. One undo step, through AddCableCommand. An empty
designation takes the next free name. A conductor another cable already
holds is refused rather than taken away, since a script cannot be asked
the question the dialog asks. qet.cables() lists the project's cables.

The qet MCP server gains the add_cable op for qet_edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:48 +02:00
DaTweety ff65b85357 Report composite texts: element information and the partner's label
A folio report whose text is a composite text replaced only %{label}
and the conductor variables. The report's own information is now
replaced as well, as on any other element, so a report can name the
device and terminal it leads to: "%{comment} %{label}" reads
"-XS27:N +UV1/2 UG Raum - 1.1" like EPLAN's interruption points.

Changing a report's information rebuilt such a text the way an ordinary
element's is built, turning %{label} into the report's stored label,
where its partner was when last saved. It now takes the same text
updateReportText() builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:48 +02:00
DaTweety 1080188f6e Add %T, the target folio's title, to report and cross reference formulas
A folio report could name the folio it points to by position (%f), folio
number (%F), installation (%M) and location (%LM), but not by its title.
EPLAN and other tools print references like "+UV1/1 Versorgung UG/EG.0",
which with %T is the formula "+%LM/%F %T.%c".

- FormulaContext carries the folio title, filled from the title block.
- %T is replaced on its own only, not as the start of a longer word.
- A report whose formula uses %T follows the target folio's title block
  (BorderTitleBlock::informationChanged), as %F already follows its
  folio number.
- The report and cross reference property pages list %T.

tst_reportfoliotitle checks the replacement, that %total and title
block variables keep their values, and an empty title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:47:48 +02:00
ispyisail 8c5a16d50e Merge pull request #1535 from ispyisail/fix/link-rows-test-skip-without-qml
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m39s
Fix the Linux CI failing since #1523
2026-10-18 22:31:50 +13:00
ispyisail a715ff25ff Merge pull request #1534 from ispyisail/feat/multi-symbol-info
Add editing the information of several symbols at once (#1439)
2026-10-18 22:31:36 +13:00
ispyisail b393e8a133 Merge pull request #1533 from ispyisail/fix/selection-panel-height
Fix the Selection properties panel losing the height the user gave it
2026-10-18 22:31:13 +13:00
ispyisail ff1a81d85d Fix the Selection properties panel losing the height the user gave it
The panel shrank to its title bar whenever the selection had nothing it
could edit (a click on an empty spot, several elements, a mix of items):
its content layout used QLayout::SetMaximumSize, and an empty vertical
layout's maximum height is 0. The other right-hand panels took the room,
and when an element was selected again the panels were split again from
their size hints, so the height the user had dragged the panel to was
lost (reported by ankostis in #1439).

With the default size constraint the panel keeps its height and stays
blank while there is nothing to edit. The element, wire, text and shape
editors draw the same: their screenshots in a 730-860 px tall panel are
pixel-identical before and after.

Test: tst_selectionpanelheight (on master the height goes from 560 to
19 px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:29:54 +13:00
DaTweety a0e2193971 Joining potentials: agreeing ones leave nothing to choose
ConductorCreator asked which potential's properties to keep whenever a
new wire joined two potentials, even when they agreed in every
property; headless (scripting, qet MCP) such a wire was refused. Only
distinct properties are offered now, and with one left the wire takes it.

A potential's properties seen through one of a cable's cores carried
the core with them -- cable, cable uuid, core slot, and the core label
CableManager writes as text -- so a new wire claimed to be that core.
They are cleared; the text is left to the formula.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:21:09 +02:00
DaTweety fa498a3d20 A new wire takes the potential of a terminal it reaches through another
ConductorCreator::existingPotential() looked for an existing potential
only at the terminals being wired and, for a report, at its partner's
first conductor. A wire drawn to the free side of a terminal element
whose other side is wired, or to the far side of such a chain, found
none and got the folio's default properties: the neutral after a
terminal strip came out solid and unnumbered while relatedPotentialConductors()
counts it as part of the dashed, numbered N potential.

Each terminal is now followed through relatedPotentialTerminal() -- the
rule relatedPotentialConductors() applies -- until a wired terminal is
found, and two terminals on one potential count once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:10:10 +02:00
ispyisail 389dae2079 Merge pull request #1521 from ispyisail/fix/joined-wire-uuid
Fix a deleted symbol's joining wire getting a random uuid on every run
2026-10-18 21:54:26 +13:00
ispyisail e46492d575 Merge pull request #1524 from ispyisail/fix/1495-element-editor-zoom-content
Fix #1495: show the symbol editor's fit button as Zoom content
2026-10-18 21:53:54 +13:00
ispyisail e05b511cb2 Tests: skip linkRowsAfterLazyEdit on a build without --run
linkRowsAfterLazyEdit() (#1523) edits a project through --run, which a
QElectroTech built without Qt Qml does not have. The Linux CI builds it
that way, so the test has failed there since #1523 merged, on master
(e46bb97c5) and on every pull request after it: "MOVED true" is never
printed. It now skips, as the five other --run tests of
tst_resaveunchanged do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:51:25 +13:00
ispyisail ce0798bb18 Add editing the information of several elements at once (#1439)
Selecting several elements left the Selection properties panel blank and
Edit (Ctrl+E) disabled, so giving many elements the same supplier or
manufacturer meant editing them one by one.

The panel now shows the Information fields for two or more elements, or
for elements selected with other items (left unchanged, and counted in
the panel's header). Ctrl+E opens the same editor as a dialog when only
elements are selected.

- A field shows the value every element shares, or "Multiple values".
  Left alone, each element keeps its own value; typed text goes to every
  element; the erase box clears the field on every element.
- A restore button per field, and Reset, bring back the values the
  elements had.
- One apply is one undo step. The keystrokes typed in the panel merge
  into one step but never into an earlier change of the same elements
  (a paste of element information, another edit).
- Numbering scheme, label freezing and the number picker are left out:
  they are about one element. Custom properties are kept as they are.

Also:
- An element's own texts, selected with it by Select all, count as part
  of the element, not as other items.
- ElementInfoWidget::reset(): Reset in the element dialog now brings back
  the Information tab's values; it did nothing.
- The panel opens an element's editor on the tab picked last, instead of
  the first tab, after the selection went to something else and back.

Test: tst_multielementinfo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:44:00 +13:00
ispyisail 8db65ddaeb Merge master into fix/joined-wire-uuid
The conflict was tests/qttest/CMakeLists.txt, where #1520 added
tst_savedstamps at the place this branch adds tst_joinedwireuuid. Both
are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:50:24 +13:00
ispyisail 1b54155cf9 Show the symbol editor's fit button as Zoom content (#1495)
The symbol editor has no sheet to fit: its "Fit in view" action zooms on
the drawn parts (elementSceneGeometricRect()), which is what the folio
editor calls "Zoom content". Use that command's zoom-draw icon and name,
and give the action a status tip like the folio editor's.

The object name and the shortcut id stay the same, so saved shortcuts
and toolbar layouts keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:17:14 +13:00
ispyisail e46bb97c5b Merge pull request #1523 from ispyisail/refactor/link-rows-in-place
Fix the project database listing a symbol's links in another order than its file
2026-10-18 19:53:12 +13:00
ispyisail 5cba4dd7d7 Merge pull request #1522 from ispyisail/fix/shared-symbol-uuid-renew
Fix symbols sharing a uuid: give each copy its own when a project opens
2026-10-18 19:52:54 +13:00
ispyisail b36d9fc9a9 Merge pull request #1518 from ispyisail/fix/windows-test-failures
Fix the two tests that fail on Windows CI
2026-10-18 19:52:27 +13:00
ispyisail ee2a060b16 Write a symbol's link rows in the order it saves them, with its waiting partners
The project database's link table should say what each symbol saves:
QETZ-DB-FIRST stage 2a moves link reading and writing to it. Measured over
the corpus and 25 kinds of edit, its rows disagreed with the file for
three reasons; this fixes the one in the database:

- rows followed the order partners were built in, not the order the
  symbol saves them (11 symbols, mostly .qetz files where an edit built a
  partner's folio, with lazy folios);
- the rows were written by three pieces of code with three rules: the
  rebuild and the flush from the built partners then the waiting ones,
  the save from its own list.

Element::linksToSave() is now that one list: the order the symbol was
saved with, then partners linked since, a partner on a folio not built
included. toXml() writes it and the database's rows are written from it
(projectDataBase::insertLinkRows()), with their place in a new ord
column of the in-memory table (the .qetz file's own link table is not
touched). A built partner the project no longer has, on a removed folio,
still gets no row, as before.

The other two reasons are not here: symbols sharing a uuid (PR #1522)
and links saved to symbols on a removed folio (F113), which the save will
stop writing once it writes from the database (stage 2a-2).

QET_CHECK_LINK_ROWS=1 says at each save whether the rows are what the
symbols save, without building any folio. tst_resaveunchanged
linkRowsAfterLazyEdit moves a symbol of m_000 as a .qetz with lazy
folios: with the previous order, 2 symbols differ.

No saved byte changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-18 16:35:58 +13:00
ispyisail 1c0deb2bc8 Give each copy of a symbol sharing a uuid its own uuid when a project opens
Older versions' copy and paste could leave two symbols with the same
uuid. Wires, links and the project database name a symbol by its uuid,
so a copy was ambiguous: #1417 worked around it for wires, but the
project database keeps one row per uuid, and a link to the uuid could
mean either copy.

When a project opens, every symbol carrying a uuid another symbol also
carries gets a uuid of its own, derived from the shared one and its
folio's uuid, so the same file gives the same result on every open. The
copy that keeps the shared uuid is the first one linked to another
symbol (its partner's link names that uuid), else the first. It is done
once the folio's wires are connected, so #1417's choice of which copy a
wire meets is kept. Folios holding such a copy are built when the
project opens, also with lazy folios.

Only files with shared uuids change, once: 3 of the 341 corpus projects.

tst_sharedsymboluuid: the lamps given one uuid now come back with three;
a linked contact and an unlinked symbol sharing its uuid keep every link
and wire, the linked one keeping the uuid. Both fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-18 15:01:26 +13:00
ispyisail abd1c8df2f Merge pull request #1520 from ispyisail/fix/stable-saved-stamps
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m52s
Fix the saved date and time lagging one save in the title block
2026-10-18 13:33:34 +13:00
ispyisail f49abcc6cc Fix a deleted symbol's joining wire getting a random uuid on every run
Deleting a symbol whose terminal held several wires joins the wires
left behind with a new one. That wire got a random uuid, so the same
deletion saved a different file every time: a diff for nothing in a
project kept in git (discussion #1512), and two copies with the same
edit could not be compared.

Its uuid now comes from its two ends (each end's symbol and terminal,
sorted), the recipe a wire saved without a uuid already gets on load
(Diagram::fromXml(), derivedItemUuid()). Undo and redo keep the same
wire, so its uuid too.

tst_joinedwireuuid deletes the coil KA1 of examples/affuteuse_250h.qet
in two runs: both saves are identical and no wire has a random uuid.
Without the fix one wire has a random uuid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-18 10:51:16 +13:00
ispyisail ea9da9b2c8 Fix the saved date and time lagging one save in the title block
The title block variables of a save (saveddate, saveddate-eu,
saveddate-us, savedtime, savedfilename, savedfilepath) were set after
the file was written, and also when a project was opened. A file
therefore held the previous save's date and path: after editing and
saving, the title block showed the date of the save before. And every
save but the first in a session wrote the time of the one before, so a
project saved twice without a change gave two different files, a diff
for nothing in a project kept in git (discussion #1512).

stampSaved() now sets them before writing, when the project has
unsaved changes (the modified flag or the undo stack moved since the
last save, as the window's "[modified]" uses) or is written to another
file. Otherwise they keep what the file holds, and opening a project no
longer sets them.

tst_savedstamps: an unchanged project saved twice in place is
byte-identical; a moved symbol then a save gives today's date and the
file's own path. Both fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-18 10:51:16 +13:00
ispyisail 3c40bf5bd1 Tests: fix the two tests that fail on Windows
Windows CI stopped running tests (bdd3b2050) after these two failed:

- tst_reporttextafterinfo wrote "columns numbered from 1" to a .conf
  under XDG_CONFIG_HOME, which only Linux reads. On Windows the setting
  stayed in the registry, columns counted from 0 and the report read
  1-C8. The setting now goes to an INI file QET_SETTINGS_DIR points at
  (#1178), read on every system.
- tst_projectlock::readOnlyFolderDoesNotBlock relies on a read-only
  folder refusing new files. Windows ignores that flag on folders, so
  the lock was taken; the case is skipped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:06:15 +13:00
ispyisail 732017b015 Merge pull request #1517 from ispyisail/fix/apptest-standardpaths-testmode
Fix app tests hanging on an autosave and rewriting the user's files
2026-10-18 10:02:27 +13:00
ispyisail eccf11d792 Merge pull request #1516 from ispyisail/fix/terminal-orientation-empty
Fix a crash opening a project whose symbol has a terminal with no orientation
2026-10-18 10:01:47 +13:00
ispyisail ea0d86e8f4 Merge master (0b0c20fdb: realistic drawer in the CMake sources) into fix/terminal-orientation-empty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-18 09:01:04 +13:00
ispyisail 0a0809d7f4 Tests: keep every app test out of the user's standard folders
QetAppTest::run() moved settings, configuration and data into a
temporary folder, but QStandardPaths still pointed at the user's own.
Two things follow on a developer's machine:

- qet-assistant.json, live-mode token included, is rewritten by every
  app test as its editor opens.
- A .qetautosave file left in the user's data folder makes QETApp offer
  to restore it in a modal dialog at start. Offscreen nobody answers,
  so all six app tests hang until ctest times them out.

Test mode is now enabled first thing in run(). tst_assistantinfopaths
(#1513) did this in its own main(); it goes back to QET_APP_TEST_MAIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:33:17 +13:00
Laurent Trinques 0b0c20fdb5 Terminal strip: add the realistic drawer to the CMake sources 2026-10-17 21:32:12 +02:00
ispyisail 108f49443d Merge pull request #1513 from DaTweety/fix/assistant-info-collection-paths
Write qet-assistant.json after the settings dialog applies the paths
2026-10-18 08:25:51 +13:00
ispyisail ae151b350a Fix a crash opening a project whose symbol has a terminal with no orientation
A terminal in a symbol's definition saved without its orientation
attribute crashed QElectroTech whenever the project was opened, in the
GUI and with --resave alike: Qet::orientationFromString() read the
first character of an empty string. Hand-edited or generated symbols,
or a damaged file, can hold such a terminal.

An empty string is now an inconsistency like any other, and the terminal
faces north, as the function's comment already said. Every non-empty
string gives the same orientation as before.

tst_terminalnoorientation runs the real binary's --resave on
fixtures/terminal_no_orientation.qet (unlinked_contact_label.qet with one
terminal's orientation removed): it crashes without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-18 08:04:13 +13:00
Laurent Trinques 3d2da9ed4e Terminal strip: specific drawings of fuse, disconnect and test terminals
In the realistic styles, some types of terminal get the parts seen on
the real products:
- fuse terminal: black hinged fuse holder on the middle of the terminal,
  with its hinge pin, a window on the fuse, a ribbed grip and, for a
  fuse terminal with led, a red led
- sectional (disconnect) terminal: orange knife with its hinge and grip
- test terminal: two brass test sockets between the wire entries and
  the middle band (in the middle band for a terminal with levels)

The marker tag and the type symbol are drawn on the lever. The lever is
shorter with screw clamps and stays on the middle band for a terminal
with levels.
2026-10-17 20:56:23 +02:00
Laurent Trinques fed204eb78 Terminal strip: clickable terminals in the realistic styles
The realistic styles don't write the xrefs, so each terminal becomes a
clickable zone, like the xref text of the schematic style:
- double-click on a terminal goes to its element on the folio
- the terminal under the mouse is outlined in blue, and a tool tip
  shows its label and its xref
- the zones are exported as clickable links in the PDF

A terminal with one level is clickable on its whole body; a terminal
with levels is clickable on the rows of wire entries of each level.
Only the terminals with an xref (element placed on a folio) are
clickable. The zones use the same physical / real indexes as the
schematic style, so the double-click and the PDF export need no
other change.
2026-10-17 20:41:25 +02:00
Laurent Trinques 34557928d1 Terminal strip: realistic drawing styles and separator plates
Add two realistic drawing styles for the terminal strip plan, besides
the existing schematic one:
- Realistic (push-in) and Realistic (screw): the strip seen from above,
  at scale (real width of each terminal), like a cabinet mounting
  drawing: end stops, group marker carrier with the strip name, one row
  of wire entries per level, bridge channels with red bridges, marker
  tags with the terminal number, type symbol (fuse, LED, diode,
  sectional, ground...), green/yellow PE and terminal color.

The style is chosen for each plan in the "Add terminal strip plan"
dialog and saved with the item (attribute draw_style of
terminal_strip_item). Items saved before keep the schematic style.
Several plans of the same strip can use different styles.

The values used by the realistic styles are in the layout (scale in
px/mm, terminal length in mm, end stops, group marker) and can be set
in the layout editor, whose style combo box only changes the preview.

Separator plates: a new "Separator plate after" check box column in
the terminal strip editor, stored in the terminal element informations
(key terminal_separator). Drawn as a plate at scale in the realistic
styles and as a thick line in the schematic style.

New files: realisticterminalstripdrawer.{h,cpp} (realistic drawing) and
terminalstripdrawerhelpers.h (helpers shared with the schematic drawer).
2026-10-17 20:21:35 +02:00
ispyisail f4dcbcc366 Merge pull request #1515 from ispyisail/test/lazy-save-parity
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m54s
Test that saving with lazy folios equals saving with every folio built
2026-10-18 06:00:12 +13:00
ispyisail c13595f8a4 Merge pull request #1514 from ispyisail/fix/lazy-table-chain-folio-removed
Fix a table losing its query when a folio is removed with lazy folios
2026-10-18 05:59:34 +13:00
DaTweety a8f70fe071 Write qet-assistant.json after the settings dialog applies the paths
GeneralConfigurationPage::applyConf() wrote qet-assistant.json before it
applied the collection and title block paths, so the file kept the paths
the dialog had just replaced. The qet MCP server hands the user and
company collections from that file to every headless run it starts: a
company collection chosen in the dialog stayed unknown there, and
company:// paths failed to resolve until QElectroTech was started again.

Write the file last. tst_assistantinfopaths picks a new company and user
collection on the page, applies it and reads the file back; it runs in
QStandardPaths test mode from main() on, since QETApp writes the file to
the standard data folder as its editor opens, whatever --data-dir says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:27:42 +02:00
ispyisail bc6da100d3 Fix a table losing its query when a folio is removed with lazy folios
With lazy folios, removing a folio left the tables on folios not built
yet as the file had them. A table split over several folios keeps its
query in its first part; when the folio holding that part was removed,
the next part, built later from the file, named a part that was gone and
was saved with no query. A parts list that listed the removed folio's
symbols also kept its old size. With every folio built neither happens.

Removing a folio that holds a table or a symbol now builds the folios
holding tables first, while the removed folio is still in the project.
A folio with neither builds nothing, as tst_lazyfolios checks.

buildFolios() now holds the project database's update signal back and
sends it once at the end, as pasting does: without that every item built
made every table lay itself out again, and the save after a removal on a
50-folio project took about 270 s instead of 4.5 s. The rows are still
written as each item is built, so no full refill of the database is
needed (one per build made a 136-folio project's edit 20 % slower).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdg2Er2vYhB8Vwa5G7fSR
2026-10-18 00:53:54 +13:00
Laurent Trinques 894f9e39c0 Update translations files 2026-10-17 13:25:20 +02:00
Laurent Trinques 6ec9108ac3 Update translations files 2026-10-17 13:02:48 +02:00
Laurent Trinques 04f48d1de4 Update EN translations files 2026-10-17 12:36:02 +02:00
ispyisail 61bf2e1afa Test that saving with lazy folios equals saving with every folio built
The same 48 edits (industrial.qet and affuteuse_250h.qet, as .qet and
.qetz) are made with lazy folios and with QET_EAGER_FOLIOS=1; the saved
files must be equal but for the saved date and path. Answers the question
raised on #1440 about the hand-kept list of what marks a kept folio stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:33:35 +13:00
Laurent Trinques 391ed4a252 Complete the Italian translation (qet_it.ts)
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m3s
Translate the 2660 untranslated strings of qet_it.ts, including 91
plural forms (singular and plural), so the file no longer has any
unfinished entry.

- Reuse the terms already used in the file (pagina, elemento,
  conduttore, terminale, morsetto/morsettiera, cavo, progetto,
  cartiglio, collezione, riferimento incrociato, potenziale,
  Installazione (=) / Posizione (+)); new cable terms: anima (core),
  schermo (shield).
- Style editor colours translated as descriptive names, like the
  existing ones.
- Keep placeholders (%1, %n, %{...}), HTML tags, accelerators and
  line breaks.
- The "unfinished" drafts were retranslated from the current source
  text.
- PlcLinkWidget: the English source "Log in" is translated as
  "Collega" (link), which matches the context.
- Checked with lrelease: 4137 translations, none unfinished.

Machine-assisted translation: a review by a native speaker is welcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:49:49 +02:00
Laurent Trinques 10ba853b28 Complete the Simplified Chinese translation (qet_zh.ts)
Translate the 1378 untranslated strings of qet_zh.ts, including 82
plural forms, so the file no longer has any unfinished entry.

- Reuse the terms already used in the file (图页, 元件, 导线, 端子排,
  线缆, 工程, 标题栏, 库, 交叉引用, 电势); new cable terms: 芯线
  (core), 屏蔽 (shield).
- Keep placeholders (%1, %n), HTML tags, accelerators and line breaks.
- The 134 "unfinished" drafts were retranslated from the current
  source text.
- Checked with lrelease: 4137 translations, none unfinished.

Machine-assisted translation: a review by a native speaker is welcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:23:43 +02:00
Laurent Trinques b1cd3a8a6e Terminal strip: marker tags calibrated on the longest number of the strip
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m39s
Every marker tag of a strip has the length of its longest terminal
number (at least two digits), so three-digit numbers are no longer
reduced. The font is still reduced if a number doesn't fit the text
zone of the layout.
2026-10-16 16:42:26 +02:00
Laurent Trinques 5a60dac2aa Terminal strip: xref as plain text, same size for every marker tag
- The xref is drawn as plain text, without marker tag.
- Every marker tag of the terminal numbers has the same length
  (two digits), the font is reduced when the number is longer.
- The body of every terminal reserves the zone of the longest xref
  of the strip, so all terminals of a strip are drawn the same way.
2026-10-16 16:36:12 +02:00
Laurent Trinques bdd3b20500 Update windows-build.yml 2026-10-16 13:38:35 +02:00
Laurent Trinques d2c2a09dae Terminal strip drawer: draw terminals like a real terminal block
- Draw only the terminal number (part after the last ':'), the strip
  name is already in the header; the manager and the tree still show
  the full label.
- Clip texts to the width of their terminal and shrink the font in
  narrow terminals so they no longer overlap.
- New housing: body with narrower wire entry caps and a slope between
  them, grey by default (terminals with a color or a ground type keep
  theirs); stripes are clipped to this shape.
- White marker tags behind the number and the cross reference, fitted
  to the text and its alignment; black ribbed tags for isolating
  (sectional) terminals.
- Clamping parts (screw in a window, castle tab, big center circle, tab,
  screw) drawn in the free zone between the two tags, whatever the
  layout; fewer parts if the zone is small.
- Remove the wire entry mark that overlapped the cross reference text.
2026-10-16 13:31:56 +02:00
Laurent Trinques 7d87c0e789 Terminal strip drawer: show only the terminal number, fit texts to cells
- Draw only the terminal number (the part after the last ':') instead of
  the full label (e.g. "1" instead of "XAUZ1:1"); the terminal manager
  and tree still show the full label.
- Clip each text to the width of its terminal and shrink the font in
  narrow terminals so that texts no longer overlap their neighbours.
- Remove the wire entry mark in the lower part of the terminal body,
  which overlapped the cross reference text.
2026-10-16 12:43:16 +02:00
Laurent Trinques 3365817cf1 Terminal catalog: more manufacturer widths and manufacturer filter
Extend the terminal catalog (terminalcatalog.h) with the widths of
more terminal blocks, from the catalogs of Phoenix Contact (UT),
Weidmuller (WDU), WAGO (TOPJOB S 2002/2006), ABB Entrelec (M series)
and Legrand (Viking 3). For Legrand and ABB Entrelec the value is the
pitch given by the catalog.

Each entry now carries its manufacturer, and the element properties
dialog gets a "Manufacturer" combo box before the width one. The list
of widths is filtered by manufacturer and by the selected section; if
the manufacturer has nothing for that section, all its terminals are
listed. The combo box is created by the code, no change in the .ui.
2026-10-16 12:10:30 +02:00
Laurent Trinques 5e28ec4246 Terminal sections: show the AWG equivalent in the section lists
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m42s
The section combo boxes of the terminal strip editor and of the
element editor now show the nearest AWG / kcmil size next to the
section in mm2, for example "2.5 mm² (AWG 14)".

The correspondence is approximate (the two systems don't have the
same steps) and is stored in TerminalCatalog::awgForSection().
The stored value is unchanged (terminal_section, in mm2).
2026-10-16 07:57:00 +02:00
Laurent Trinques 5cbc1c4b8d Element editor: add section, width and color to terminal elements
Terminal block elements can now define, in the element properties
dialog (Terminal block element group):
- the section in mm2 (combo box with the usual sections, editable)
- the width / manufacturer reference (Weidmuller, Phoenix Contact,
  WAGO, ABB Entrelec), filtered by the selected section
- the color, using the palette of the terminal strip (editable,
  accepts a name, #rrggbb or #rrggbb/#rrggbb)

Values are stored in the element informations (terminal_section,
terminal_width, terminal_color) with the dot as decimal separator,
the keys already read by RealTerminal. An empty value removes the key.

The sections and the manufacturer widths move from
terminalstripmodel.cpp to the new header-only terminalcatalog.h,
shared by the terminal strip editor and the element editor.
2026-10-16 07:50:34 +02:00
Laurent Trinques cc9fe1a5c8 Merge pull request #1511 from ispyisail/fix/recover-test-damaged-message
Fix the recovery test failing on master since #1504
2026-10-16 07:08:02 +02:00
Laurent Trinques 2d32be22eb Merge pull request #1508 from ispyisail/fix/report-text-shift-lazy
Fix report text shifted by its width after a lazy-folio save
2026-10-16 07:07:03 +02:00
Laurent Trinques e612a59888 Merge pull request #1507 from ispyisail/feat/project-lock
Add a project lock: tell a second person the project is open
2026-10-16 07:05:44 +02:00
Laurent Trinques 1c55c3f7b8 Merge pull request #1506 from ispyisail/feat/changed-on-disk-check
Ask before saving over a project someone else changed
2026-10-16 07:05:22 +02:00
ispyisail 10988770c6 Merge master into feat/project-lock
Conflicts: the lock dialog and the recovery dialog both kept in
qetdiagrameditor.cpp; the lock and previous-versions settings both kept.
The spacer in the General settings page moves to row 9, below the lock
checkbox, which the merge had left on the same row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
2026-10-16 17:44:44 +13:00
ispyisail cc5a634cab Merge master into feat/changed-on-disk-check
Conflicts: QETProject::write() now checks for a change on disk before it
keeps the previous version; both new tests kept in tests/qttest/CMakeLists.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
2026-10-16 17:44:42 +13:00
ispyisail 0f65e912e7 Expect a damaged-database message when recovering an overwritten page
Since the integrity check on opening a .qetz database (#1499) runs before
anything is read, an overwritten page is reported as "the project
database is damaged", not "cannot be read". recoverLeavesOutOnlyTheDamage
was written before that check and has failed on master since #1504.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
2026-10-16 17:14:12 +13:00
ispyisail 6b08b87d16 Merge pull request #1503 from ispyisail/fix/qetz-header-folio-pictures
Fix a .qetz project that cannot be opened after changing a folio's title
2026-10-16 13:41:11 +13:00
ispyisail 3521c64ba3 Tell a second person who has a project open, and open it read-only
Two people opening the same project on a shared drive could both edit it,
and the last save silently wiped the other's work. A project now has a
hidden lock file beside it (.~plant.qetz.lock, small JSON: user, host,
process, version, since, heartbeat). Opening a project another live session
holds shows who has it, since when and how long ago they were last active,
and offers Open read-only (default), Open anyway, or Cancel.

- The lock is made with an exclusive create, refreshed every minute and
  deleted when the project closes. It is not a QLockFile: that treats any
  lock from another machine as stale after 30 s. Here only a lock made on
  this machine by a process that no longer exists is taken silently.
- A folder the lock cannot be made in, or the setting turned off
  (Settings > General > Projects), opens as before.
- Save As and .qet saved as .qetz move the lock to the new file, and refuse
  a file someone else has open. A project opened read-only for another's
  lock does not save over that file.
- --resave and --set-titleblock refuse a file someone has open unless given
  --ignore-lock; the read-only verbs never look at locks and headless runs
  never take one. A script's qet.save() refuses the same way.

Checked: 17 new unit tests (5 go red when stale detection and the exclusive
create are broken), full ctest 107/107, the dialog and both choices in the
GUI under Xvfb, and --resave refused/allowed from the command line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:01:54 +13:00
ispyisail fcc11bff74 Keep a right-aligned text where it was when lazy folios refill it
A report symbol's right-aligned text (the function of its partner's
wire) was saved at another x than with every folio built after deleting
a coil on its folio: moved left by its own width. Cosmetic, nothing lost.

setPlainText() keeps a right-aligned text's right edge, except while the
project is being opened (ProjectParsingRunning) or the symbol is built.
With lazy folios the text is emptied outside that state (the partner's
folio is not built, so no wire is watched) and the edge is kept, then
filled again while the late folios are built, which holds the project in
the opening state: the edge was not kept, and x stayed where emptying
moved it. With every folio built both happen during the opening.

A text emptied with its alignment kept is now filled again the same way,
whatever the project state; a text set while the project is opened and
never emptied that way is untouched, so what a plain opening writes does
not change.

Evidence: forum f1537 (resaved with every folio built), deleting coil
{794d8944} on folio 9 saved x=11.7969 lazily, x=60 eagerly; both 60
now. Resave of the 342 corpus projects, lazy and eager, identical to
before. tst_reporttextlatefolio fails without the fix (x=28.6875 lazily).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:57:17 +13:00
ispyisail e8a5d6aec0 Ask before saving over a project file someone else changed
Saving replaced the project file whatever had happened to it since it was
opened. If another user, another program or a second QET had saved the same
file in between (a shared drive, a copy synced from another PC), their work
was lost without a word.

QETProject now remembers the project file's size and modification time when
it reads it and after each save of its own. Before writing, write() compares
them with the file on disk and, if they differ, writes nothing and returns
"someone else changed the file on disk at <time> since it was opened".

- Interactive save (Ctrl+S, and the save on closing a project) asks:
  "Someone else changed <file> on disk at <time> since you opened it" with
  Save as a copy (opens Save As), Overwrite, or Cancel.
- Scripts (--run), the command line, MCP and the autosave timer have no one
  to ask, so they keep the refusal (autosave logs it). Setting
  QET_OVERWRITE_CHANGED=1 makes a script overwrite on purpose.
- A file that does not exist yet, and a Save As to another name, are not a
  conflict; after Save As the remembered state is the new file's.

No change to what is written, so .qet and .qetz stay as they were.

Evidence: new tst_changedondisk (unchanged file saves twice; a file written
behind the open project is refused; the override overwrites; a Save As
target is no conflict) fails on the changed-file case without the source
change and passes with it. Full ctest 107/107. Physical check with
qet-gui-dialog.sh: file modified behind the open project, Ctrl+S shows the
three-button dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
2026-10-16 12:17:21 +13:00
ispyisail b4bbd17933 Fix a .qetz that cannot be opened after only a folio's title changed
With a .qetz and lazy folios (both the default), changing only the
title, author, date or revision of a folio holding a picture, while that
folio was not displayed, then saving, gave a .qetz that refused to open:
'picture "images/<sha>.png" is missing'.

Such a folio is saved from its items as read, with its new header
(Diagram::deferredXmlWithHeader()). Read from a .qetz, its <image> names
its picture's file (qetz-file) instead of holding the picture, and
QetContainer::split() takes out only pictures held inline: the file was
never written, while the folio still named it. The folio now gets its
pictures written in, from the bytes the project keeps
(QETProject::containerPicture()), as a built folio writes them.

On master, a picture on folio 2 of grafcet, iso_sfc_example, 741,
tremie_vibrante or Projet_vierge is lost this way; found on three forum
projects by comparing lazy saves with saves of every folio built. Saving
as .qet was not affected.

New tst_qetzheaderpictures (grafcet.qet: picture on folio 2, saved as
.qetz; its title changed with folios built when needed; reopened with
its picture and title): fails without this change ("picture ... is
missing"). ctest 107/107.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:33:51 +13:00
ispyisail 973417a694 Merge pull request #1505 from ispyisail/feat/qetz-open-failed
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m4s
Offer a damaged project's previous version, or what can be read of it
2026-10-16 11:27:21 +13:00
ispyisail d97434cec4 Merge pull request #1504 from ispyisail/feat/qetz-recover
Add recovering what can be read of a damaged .qetz project (--recover)
2026-10-16 11:26:55 +13:00
ispyisail 8301856ee7 Merge pull request #1502 from ispyisail/fix/report-text-after-info-edit
Fix a folio report pointing at its partner's old place after its properties are edited
2026-10-16 11:26:16 +13:00
ispyisail 7e82d290f9 Merge pull request #1501 from ispyisail/feat/keep-previous-versions
Add keeping the previous versions of a project each time it is saved
2026-10-16 11:25:52 +13:00
ispyisail c7ae853e8f Merge pull request #1499 from ispyisail/fix/qetz-sqlite-checks
Check a .qetz project's database the way SQLite advises before reading it
2026-10-16 11:25:05 +13:00
ispyisail 4df4458d7b Merge pull request #1498 from ispyisail/fix/save-sync-folder
Fix a saved project being lost to a power cut just after saving
2026-10-16 11:24:31 +13:00
ispyisail ea8871af1b Offer a damaged project's previous version, or what can be read of it
A project file that could not be read showed "The file ... does not
appear to be a QElectroTech project file", for a .qetz missing one
picture as for a picture file renamed .qet: wrong, and no way out.

Now the message says what is damaged (QETProject::openError()) and
offers, when there are any:
- the newest previous version kept beside it (PreviousVersions);
- for a .qetz, what can be read of it (QetContainer::recover()), then a
  list of what was left out.
Either opens as a new project, "<name> (recovered)" beside the file,
numbered if that is taken, not saved yet. The damaged file is never
moved or written.

Checked in the program (scripts/qet-gui-dialog.sh, File > Open on a
.qetz whose picture is missing, a previous version beside it): the
dialog says which picture; "Recover what can be read" opens plant
(recovered).qetz without the picture, saved with Ctrl+S it opens again;
"Open the version saved ..." opens plant (recovered 2).qetz with its
picture; plant.qetz unchanged (same sha256). ctest 107/107.

Stacked on "Keep the previous versions of a project when saving it" and
"Add recovering what can be read of a damaged .qetz".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:39:29 +13:00
ispyisail ec8fd5f8d6 Merge branch 'feat/keep-previous-versions' into feat/qetz-open-failed 2026-10-16 10:20:21 +13:00
ispyisail 2bd19e04be Add recovering what can be read of a damaged .qetz (--recover)
A .qetz that does not open as it is -- a picture's file missing (as
the title-change fault wrote them), a folio file missing, a row deleted
from project.sqlite by hand (issue #1458), a damaged database -- was
refused whole, and nothing in QElectroTech could get anything back.

QetContainer::recover() reads what it can and says what it left out:
an entry that cannot be unpacked, a missing folio file or picture, a
symbol or wire that does not match the database, and, when the
database cannot be read at all, the information it held (the wires go
too, as it holds their ends). join(), restore() and QetZip::fromBytes()
take an optional list for this; given none they refuse as before.
`qelectrotech --recover damaged.qetz out.qet` writes what was read to a
.qet and lists what was left out.

Measured on industrial (50 folios, 618 symbols, 671 wires): a wire row
deleted -> 670 wires, the rest whole; a folio file missing -> 49
folios; a database page overwritten -> every folio and symbol drawn, no
wires and no symbol information. A zip cut short (no central directory)
still cannot be read.

Tests: recoverLeavesOutOnlyTheDamage (nothing, folio file, wire row,
database page), recoverMissingPicture, recoverNotAZipRefused; they fail
with the database side kept strict. The strict cases pass unchanged.
ctest 106/106.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:15:37 +13:00
ispyisail e8201abb8a Keep the previous versions of a project when saving it
Saving replaced the project file with nothing kept of what it was: one
bad save -- a fault in QElectroTech, a full disk, a wrong edit saved by
mistake -- and the last good version was gone. AutoCAD keeps a .bak on
every save, KiCad several backups.

Before a project is saved over, the file as it was is now copied to a
folder beside it, "<name>-backups", named by the time that version was
saved ("industrial 2026-10-16 08-30-12.qetz"); only the newest are kept,
5 by default (Settings > General > Projects, 0 for none). A copy is
never a move, so a save that fails leaves the file in place; a copy that
fails only warns, the save goes on. The same bytes as the newest version
kept are not kept twice.

The prompt on opening ("Create a backup copy?"), which writes one dated
copy beside the project and never removes any, is left as it is.

New tst_previousversions (newest kept, same bytes once, same second,
nothing for a new file or a count of 0, a failed copy said); it fails
with the oldest never removed. Checked with the program: two scripted
saves of a .qetz leave the original and the first save in
industrial-backups, the project holds the second. ctest 107/107.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:00:07 +13:00
ispyisail fcc797342e Fix a folio report pointing back at its partner's old place after its information changes
A folio report's label shows where its partner is (updateReportText()).
Changing any other information of the report -- a comment, a function --
ran elementInfoChanged(), which set the text from the label stored in
the report's information instead: the partner's position when the file
was last saved. Move the partner from column 4 to column 9, give the
report a comment, and it showed (and saved) 1-C4 again.

With lazy folios the partner is often built after the change, so the
report was recomputed and the two modes saved different files; that is
how this was found (FINDINGS F116, tools/lazy-folios/edit-diff.py).

New tst_reporttextafterinfo: move the partner, comment the report, save,
check the report says 1-C9, with folios built when needed and with every
folio built. Both rows fail without this change ("1-C4").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:54:27 +13:00
ispyisail e3fa4a049c .qetz: read the project database the way SQLite advises for untrusted files
A .qetz's project.sqlite comes from a file, which may be damaged, made
by another tool, edited by hand (issue #1458) or made to harm. SQLite's
advice for such files (https://www.sqlite.org/security.html) was only
partly followed: trusted_schema was off and every name had to be a plain
table. readAsUntrusted() now also turns on the defensive mode, turns off
triggers and views, checks each cell's size as its page is read, limits
a value to 256 MiB, and runs quick_check before anything is read, for
the database read on opening and for the one a save copies kept folios
from.

A damaged database is now refused as "the project database is damaged",
where it was "cannot be read: database disk image is malformed" or a
missing row. Damage on disk was already caught before SQLite: the zip
checks each entry (one flipped bit in project.sqlite: refused, "unexpected
decompressed size"). quick_check does not see a changed value inside a
row (49 of 400 random small changes read silently, quick_check "ok"):
only the zip's checksum does.

New test damagedDatabaseRefused (a page overwritten at three places):
fails without this change on the message. Opening nuri (136 folios):
3.56-3.66 s with and without. ctest: the 7 container, save and lazy
folio suites pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:41:27 +13:00
ispyisail 7e96bb51d2 Sync the folder after saving, and save from the command line through a temporary file
QSaveFile writes a new file, forces it to disk and renames it over the
old one, but does not force the folder to disk. On ext4 or XFS a power
cut soon after a save can then lose the rename: the previous file comes
back, or none. QET::syncFolderOf() now syncs the folder after every
project, symbol and title block save (QET::writeXmlText()) and every
.qetz save (QetZip::write()). Nothing to do on Windows.

--resave and --set-titleblock wrote a .qet straight over the output file
(QFile, truncate, write): a run stopped part-way left half a project.
They now write through QET::writeXmlText() like the GUI's save. On
Windows their .qet now has LF line ends, as the GUI writes.

Checked with strace on --resave: fdatasync of the new file, linkat into
place, fsync of the folder. The .qet written is byte-identical to before.
ctest 106/106.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:23:31 +13:00
Laurent Trinques c4b5cf2f11 Merge pull request #1496 from ispyisail/fix/qetz-upgrade-step-by-step
Read older .qetz files by upgrading them one format at a time
2026-10-15 20:47:54 +02:00
ispyisail 8ef92c75c8 Merge pull request #1465 from itseogo/feature/text-format-painter-and-chained-reports
Add Element Text Format Painter tool
2026-10-16 07:18:24 +13:00
Itsego 8d56d927e1 Add Element Text Format Painter tool
- Implement ElementTextFormatPattern to capture and reapply dynamic element text formats (font, color, alignment, rotation, frame, positions, groups) across elements.
- When applying a format, missing ElementInfo texts present on the source template are added to the target element with the template's layout and styling, matching other texts in order.
- Register actions under Edit menu, diagram context menu, and ShortcutManager (customizable via Toolbar settings).
- Bind default shortcuts Ctrl+Alt+C (copy text format) and Ctrl+Alt+V (apply text format).
- Provide full undo/redo support via ApplyElementTextFormatCommand.
- Add SVG icons format-copy and format-apply.
- Include English source strings and German/French translations.
2026-10-15 20:07:01 +02:00
ispyisail d50f27dc8b .qetz: bring an older database to the current format one step at a time
The reader of project.sqlite knew two formats at once: every query asked
the database which column names to use (v2() ? "folio_id" : "folio_uuid"
and so on), and each new format would have added another branch to every
query. Asked in discussion #1440.

An older database is now brought up a format at a time when a .qetz opens
(readyToRead()): format 2 to 3 renames its tables' columns, adds the show
columns and puts the symbols' and wires' uuids and the symbols' type back
in the folio files; 3 to 4 has nothing to change for reading. The reader
then knows only the current format. The next format adds one step.

Checked: the format 2 test file (tests/qttest/fixtures/qetz_format2.qetz)
resaved as .qet and as .qetz gives the same files as master. format2Read
now also checks that every symbol and wire gets its uuid and type back:
it failed with the type left out of the step, which the test did not see
before. ctest 106/106.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:59:46 +13:00
Laurent Trinques 8d59d25796 Improve terminal_strip
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m45s
2026-10-15 16:26:45 +02:00
Laurent Trinques e883d2c3bf Improve terminal_strip 2026-10-15 14:38:11 +02:00
Laurent Trinques 892a899a20 Merge pull request #1492 from Xyli0/fix/snap-common-collection-layout
snap: restore layout so the QET collection is found
2026-10-15 13:02:18 +02:00
Kellermorph 68914ee31b Stop a folio report's text jumping when the report is not linked
A report's texts that show a wire's properties (function, section...)
are only filled once the wires are read, and those are read after the
symbols: the fill comes from the 100 ms timer of
DynamicElementTextItem::conductorWasAdded(), by which time the project
state is Ok again and the alignment is live.

For a report whose partner is linked, that fill had already happened
inside the loading state (Element::initLink() -> linkedElementChanged()
-> reportChanged()), so the alignment was blocked and the text stayed
where the file put it. buildFolios() relies on that and says so. An
unlinked report, and one whose partner sits on a folio not built yet,
got nothing during loading: the first fill was the timer's one. It took
the rect of the still empty text for anchor and shifted the item by the
whole width of the text, on every open. Only the right, centred or
bottom aligned texts showed it -- the others have a null offset.

Fill those texts inside the loading state instead. Element::
updateConductorTexts(), already run when a wire is connected to a
terminal, now covers a report as well as a wire label, and
Diagram::refreshContents() runs it for every symbol while the project
is still parsing, right after initLink() has resolved the links. The
timer's fill that comes 100 ms later then finds the text already set,
and DynamicElementTextItem::setPlainText() returns on it unchanged, so
the alignment is never handed an empty rect to anchor. A wire that
appears, or a property that changes, while the project is open is
aligned as it was.

A report's "label" text is left to updateReportText(): it is the linked
folio's, not the symbol's own information.
2026-10-15 12:47:13 +02:00
Xyli0 902f12e333 snap: restore layout so the QET collection is found 2026-10-15 11:52:30 +02:00
ispyisail 224324c56f Merge pull request #1491 from ispyisail/fix/reattach-wires-moved-terminals
Fix wires lost when a symbol's terminal moved a few units
2026-10-15 22:22:16 +13:00
ispyisail b085dcd5dd Pair terminals moved by one shift, and terminals sharing a place
The first pass refused a pair whenever a saved terminal had two live
candidates in reach. That happens when a whole drawing moves by half the
spacing of its terminals (pins 10 apart moved 5), and when two terminals
share a place. When one offset fits every unmatched terminal, as when a
whole drawing moved, that offset now settles all pairs; terminals sharing
a place pair in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:42:43 +13:00
ispyisail 786f8574d9 Reattach wires to a terminal that moved a little since the project was saved
A project saves, for every placed symbol, where each terminal docked, its
orientation and its number. When the symbol's definition is replaced by one
whose terminal sits a few units elsewhere, wires were matched by exact
position and so were dropped on the next open: legacy wires by Terminal::
fromXml(), uuid wires by the uuid derived from the terminal's new place.

Element::fromXml() now pairs each saved terminal that nothing matched
exactly with the live terminal of the same orientation whose docking point
is within 5 units in x and y, only when each is the other's only candidate.
A pair registers the saved number, and the uuid derived from the old place,
which Diagram::findTerminals() checks after the derived uuid of the current
place. Wire ends reattached this way are counted and logged once per folio.

tst_movedterminals covers a move of 3 and 5 units (number and derived
uuid), 6 units, two candidates, and an unmoved terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:26:30 +13:00
Laurent Trinques 6af9c097d8 Update French translations files
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m54s
2026-10-15 08:22:05 +02:00
Laurent Trinques d27187ddeb Merge pull request #1487 from Kellermorph/german-translation
german translation
2026-10-15 07:54:29 +02:00
ispyisail d5c90a0861 Merge pull request #1486 from ispyisail/fix/mirror-keys-and-labels
Fix the M and F mirror keys doing nothing on a folio
2026-10-15 18:54:08 +13:00
Kellermorph 3c6d8dcb09 german translation 2026-10-15 06:55:10 +02:00
ispyisail 74b480934f Fix the M and F mirror keys doing nothing on a folio
#1346 rebuilt the Edit menu from a list of named actions, written before
#1354 added Horizontal mirror and Vertical mirror. The two actions ended up
in no menu and on no toolbar, so their shortcuts (M, F) never fired; only
the right-click menu reached them. Add both to the Edit menu after
"Choose texts orientation".

Also correct two labels left behind by later changes:
- Settings > General: the fine-movement step is now used with Ctrl, not
  Alt (#1395).
- Terminal editor tooltip: the checkbox is now called "Function
  separation" (#1456).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:06:29 +13:00
ispyisail fba67c3b2a Merge pull request #1483 from ispyisail/fix/qetz-1472-followups
Fix saving to a read-only folder, and the #1472 review follow-ups
2026-10-15 13:31:03 +13:00
ispyisail c65a81b0ca Merge pull request #1484 from ispyisail/fix/1482-crlf-spec-test
Fix tst_qetcontainer failing on Windows (#1482)
2026-10-15 13:30:41 +13:00
ispyisail a4ca61d48b CI: run the unit tests in the Windows job again
f06fd1130 turned them off because tst_qetcontainer failed there (#1482);
the previous commit fixes that test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:11:29 +13:00
ispyisail 41baea32a8 Fix tst_qetcontainer on a Windows checkout (#1482)
specMatchesSchema() finds the schema in QETZ-FORMAT.md by looking for
"```sql\n". A Windows checkout gives the file CRLF line endings, so the
marker is never found and the test fails with "no schema in
QETZ-FORMAT.md". Drop the carriage returns after reading the file.

Checked by converting QETZ-FORMAT.md to CRLF: the test failed with the
CI message before this change and passes after it; with LF it passes
both ways. All 51 cases of tst_qetcontainer pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:11:29 +13:00
ispyisail 3f3453b3ce Update TS files
lupdate for the strings added by #1472 and this branch; the rest of the
change is moved line numbers. Review of #1472 (scorpio810, point 11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:21:03 +13:00
ispyisail e5c5670f24 Register .qetz in the remaining packaging files
- register_filetypes.bat (portable Windows build, two copies): .qetz
  opens with QElectroTech like .qet.
- AppStream metadata: lists the four file types QElectroTech opens.
- macOS Info.plist: declares the .qet and .qetz types (UTIs), which
  recent macOS needs to associate an extension with the application.

Review of #1472 (scorpio810, point 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:20:57 +13:00
ispyisail cd3a726e26 Name the .qetz MIME type application/x-qet-project+zip
Every other QElectroTech file type is application/x-qet-*; the zipped
project was application/x-qelectrotech-project+zip. No release has
shipped it yet, so it is renamed now.

Files written with the old name still open: the reader accepts both, and
the shared-mime-info file keeps the old name as an alias and matches
either on content.

Review of #1472 (scorpio810, point 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:20:57 +13:00
ispyisail e398f65f0a Warn when a terminal strip lists a terminal no symbol has
A terminal strip saved with a terminal whose symbol is gone left that
terminal out without a word. It still does, and says so in the log.
The same happens with every folio built: the database is filled from
the document, so a symbol in the file is never missing from it.

Review of #1472 (scorpio810, point 8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:20:57 +13:00
ispyisail 56b142127b Use one file filter for opening a project
File > Open and Import numbering from a project each had their own
filter for .qet and .qetz, worded differently. Both use
QETProject::openFileFilter() now.

Review of #1472 (scorpio810, point 11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:20:57 +13:00
ispyisail 9d807101e4 Keep a .qet project when its .qetz cannot be saved beside it
Saving a project opened from a .qet writes a .qetz beside it. In a
read-only folder that write fails, but the project had already been
switched to the .qetz path: the window then named a file that does not
exist, and the error said only "Permission denied".

The project now stays the .qet it was when the write fails, and the error
names the file: "Cannot save /path/name.qetz: Permission denied". Save as
still saves it elsewhere.

Found answering the review of #1472 (scorpio810, point 12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:20:56 +13:00
ispyisail f244f6dda0 qet-mcp: clean up .qetz copies, never keep an unfinished one, bound scans
Review of #1472 (scorpio810, points 4-7):

- The .qet made from each .qetz lived in its own temporary folder that was
  never removed. Only the newest 8 are kept now, an older version of the
  same file is dropped when a newer one is read, and all of them are
  removed when the server exits.
- A --resave that failed or timed out could leave a partial .qet behind,
  which was kept and read. Only a run that succeeded is used now.
- qet_scan read every .qetz in the folder, each through QElectroTech with
  up to 900 s. It reads the first max_qetz (default 20) and lists the rest
  as skipped.
- test_edit_writes_a_qetz checks the rotated symbol is the one reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:00:23 +13:00
Laurent Trinques f06fd11305 Update windows-build.yml
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m39s
2026-10-14 18:28:20 +02:00
Laurent Trinques dbecfae35b Merge pull request #1481 from ispyisail/fix/pdf-orientation-per-folio
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m39s
Add per-folio page orientation to the PDF export (#1480)
2026-10-14 17:27:15 +02:00
Laurent Trinques 2ce508e373 Merge pull request #1472 from ispyisail/feat/qetz-db-truth-v2
Save as .qetz by default, with the project database as the source of truth
2026-10-14 17:22:44 +02:00
ispyisail f42958d2c2 PDF export: turn each page to its folio's orientation (#1480)
The print window exported every page in one orientation, landscape
unless changed, so a portrait folio was shrunk onto a landscape page.
With "Fit in page" on, a PDF export now turns each page to portrait or
landscape as its folio is drawn. The preview keeps one orientation
(QPrintPreviewWidget lays out every page at one paper size), and a
printer keeps it too (only Qt's PDF engine accepts a new page layout
once printing has started). The orientation chosen in the window is
put back afterwards, so it is still the one saved.

The cross-reference links took their target from painter->viewport(),
which QPainter sets once when it opens, so on a turned page they missed
their text. They now use the device's size, as render() does.

tst_printpdforientation exports through the window: an A3 landscape and
an A3 portrait folio get one page each way; tiled pages keep the chosen
orientation; and the links on examples/industrial.qet's turned pages
land where they do when the export starts portrait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:38:00 +13:00
ispyisail 33a731b56f Merge pull request #1477 from ispyisail/perf/parallel-picture-decode
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m6s
Speed up opening folios with photos: decode pictures in parallel
2026-10-14 19:10:59 +13:00
ispyisail c0a5084bc4 Merge master into feat/qetz-db-truth-v2
The .qetz files take this branch's side: it already holds #1473, and
adds the folio parts to join().

qetscriptapi.cpp: #1456 gave setConductorProperty() the voltage that
crosses a function separated terminal element; here that function hands
its work to applyConductorProperties(), so the change goes there, with
the properties captured before the pushes. The database path for folios
not built already gives up at such a terminal, so it builds the folio and
takes the same route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:57:29 +13:00
ispyisail 373a705bbb Merge master into perf/parallel-picture-decode
tests/qttest/CMakeLists.txt: this branch's tst_folioimageload and
master's tst_svgsmallpictures (#1478) were added at the same place;
both are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:36:36 +13:00
ispyisail af142aba90 Merge pull request #1473 from ispyisail/fix/qetz-format-ids-types-version
Fix .qetz database: rows keyed by position, untyped, unversioned
2026-10-14 18:29:57 +13:00
ispyisail 8cda53f4ce Merge pull request #1456 from Kellermorph/terminal-potential
Terminal: rename potential separation to function separation
2026-10-14 18:26:00 +13:00
ispyisail 4e8af81fc6 Merge pull request #1478 from ispyisail/feature/svg-picture-limit
Add an SVG export option to store pictures smaller
2026-10-14 18:22:28 +13:00
ispyisail 9920c13bd9 Merge pull request #1476 from ispyisail/perf/derived-uuid-no-copy
Speed up opening older projects: work out the project uuid in place
2026-10-14 18:22:13 +13:00
ispyisail 1768518b44 Merge master into fix/qetz-format-ids-types-version
join() and readDocument() gained a parameter on both sides: master's
Pictures map (#1470) and this branch's format. Both are kept, pictures
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:56:32 +13:00
ispyisail da6eb83035 Add an SVG export option to store pictures smaller
An SVG keeps every picture at full resolution, whatever size it is
drawn at: a folio with 22 phone photos shown at a tenth of their size
gave a 270 MB SVG and took 65 s to write, most of it compressing the
photos again.

The export dialog gets "SVG: smaller pictures" (off by default, kept
with the other export settings), and --export-svg gets
--svg-small-pictures. With it, a picture is stored at four times the
size it is drawn at when that is smaller than the picture; smaller
pictures stay whole. Four times is about 375 dpi on a printed folio.

The exporter sets Diagram::smallPictures() for its own drawing only,
so screen, print, PDF and PNG are unchanged. It is not part of
applyProperties(), which every format uses.

On that project: --export-svg 84.9 s -> 34.5 s (opening is 20 s of
both), 270 MB -> 57 MB, peak memory 2.45 -> 1.86 GB. Rendered at twice
the folio size, the two SVGs differ by more than 32/255 on 0.23 % of
pixels, along photo detail.

tst_svgsmallpictures: an 800x600 picture drawn at 0.1 is stored at
320x240 with the option, through --export-svg and through the
dialog's QPicture route, and at 800x600 without it; a 40x30 picture
stays whole. It fails when the factor is changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:12:38 +13:00
ispyisail 6751fc6f19 Speed up opening folios with photos: decode their pictures in parallel
Each picture of a folio was decoded in turn while its item was built,
a quarter of a second for a 12 megapixel photo. Diagram::fromXml() now
reads every picture of the folio first, decodes them all at once on a
pool of its own (one thread per core, at most one per picture), then
builds the items in file order in the GUI thread as before.

The decoding (base64 text, then the picture file) moves out of
DiagramImageItem::fromXml() into decodePicture(), which touches only
its argument. QImage::fromData() then QPixmap::fromImage() is what
QPixmap::loadFromData() does, so the pixels are the same.

On a 263 MB project with 22 photos on one folio: --info 21.3 s ->
12.5 s, the folio's content 10.6 s -> 3.2 s, opening its .qetz 15.6 s
-> 9.3 s. Peak memory +220 MB (1.81 -> 2.03 GB), the pictures' text
being converted by several threads at once. A resave is byte-identical
to master's, and so is a PNG export.

tst_folioimageload: six pictures come back with their own pixels and
positions, and an <image> without a picture does not stop the others.
It fails when the decoded pictures are handed to the wrong items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:09:09 +13:00
ispyisail bf465f9245 Merge #1473's format document; describe format 4
QETZ-FORMAT.md now describes format 4: the computed text left out of the
folio files, QElectroTech's own database built on opening rather than
stored, the tables written without keys, and format 3 among those read.

Tables written without keys keep their declared column types (INTEGER
was written as INT); tst_qetcontainer checks the columns and types
against the document, and that the file has no keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:34:52 +13:00
ispyisail 2bf73585ba Open legacy projects faster: derive the project uuid without copying
QETProject::derivedUuid() copied the whole file content to drop '\r',
then copied it again to prepend the name prefix, before hashing.
On a 263 MB project with embedded photos that took 2 s of a 21 s open.
Hash the content in place, skipping '\r', and set the version 5 bits
as QUuid::createUuidV5() does.

The uuid is unchanged: same result as before on the 23 example
projects, their CRLF copies, edge cases (empty, only '\r') and the
263 MB project, and a resave of that project is byte-identical.
2026-10-14 11:25:29 +13:00
ispyisail f65b24250c Document the .qetz format
QETZ-FORMAT.md describes format 3: the archive and its files, the
manifest and the version rules, every table and column of project.sqlite
and what it comes from, how rows are keyed, the markers a moved block
leaves, and what reading checks. Asked for in #1440: the format had no
written specification.

tst_qetcontainer checks that the schema the document gives is the one
written: the same tables and columns, in the same order, with the same
types and keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:14:12 +13:00
ispyisail a9ed99293e Merge #1473: a property's show flag gets a column
A folio is copied as it was only from a database whose folio_property and
element_info have the columns this writes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:54:09 +13:00
ispyisail 590bd6e532 .qetz: a property's show flag gets a column
The JSON column of a property row held only its show flag, in every
project QElectroTech saves: the 25 examples and a 136-folio project used
it for nothing else. Folio and project properties now store it as a
number, as symbol information does, so a saved project's database holds
no JSON; JSON is left for attributes QElectroTech does not write (an
older or hand-edited file).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:36:19 +13:00
ispyisail 286779a2e4 Merge #1473: no position columns, a later format opened read-only
A symbol changed on a folio kept as it was needs the place its
information goes back to: its part is checked for the marker
(QetContainer::split()), where the position column said it before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:17:21 +13:00
ispyisail 4c3d87e3f7 .qetz: no columns for where a block sat; open a later format read-only
The database held where each moved block sat among its parent's
children (props_at, info_at, links_at), though reading only asked
whether there was one: the marker the folio file keeps says that, and
where it goes back. The columns are gone; format 2 files leave the same
markers and still open.

A .qetz of a later format whose min-reader allows this version was
still refused by the database's own format check. It opens now,
read-only, with a message: saving it here could lose what this version
does not know. Save As writes a copy in this version's format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:47:08 +13:00
ispyisail 56c704be1f Merge #1473: a symbol's type stays in its folio, mismatched databases refused
The checks run once every folio is read. A .qetz written by this branch
has no keys in its tables, so a row twice is caught by the counts too.
A folio is copied as it was only from a database with the columns this
writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:00:59 +13:00
ispyisail 5975b75766 .qetz: keep a symbol's type in its folio; refuse a database that does not match
A folio file in a .qetz did not say which symbol each item is: the type
(embed://...) was moved to project.sqlite, so a folio read alone could
not be drawn or diffed. It stays in the folio file now; format 2 files,
which moved it, are still read.

Nothing checked that project.sqlite and the folio files agree: a row no
item has, an item's row on another folio, or information rows of no
symbol were read as far as they went. Reading now refuses such a file and
says what does not match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:57:25 +13:00
ispyisail c259cae429 Keep each fact once in a .qetz: drop the stored project database
A .qetz held the engineering data twice: the document's tables, and
beside them QElectroTech's own project database (stage D2/D3), which
QElectroTech also builds from the document. Two copies of one fact can
disagree, and the copy cost a third of the file. Now project.sqlite holds
the document's tables only; QElectroTech builds its own project database
from them when the project opens, as it does for a .qet.

Measured (this build, lazy folios):
  industrial (50 folios): 620 -> 374 KB, open 0.37 -> 0.65 s
  f2099/nuri (136 folios): 3.27 -> 2.20 MB, open 2.2 -> 3.5 s
The same projects as .qet open in 3.3 s and 23 s.

Saving still copies the file of every folio not built, with its rows
(stage D4): the project keeps the part each folio was read from. nuri
opened and saved again: 6.3 s, no folio built, the same bytes.

A symbol's terminal list is left out of the folio file when every
folio, symbol and wire has a uuid of its own, now checked on the
document (QetContainer::ownUuids()) rather than by the stored database
being written.

Checked: 342 corpus projects open, .qetz -> .qet equals the plain
resave, a .qetz saved twice gives the same bytes; tst_lazyfolios,
tst_qetcontainer, tst_resaveunchanged (which now checks project.sqlite
holds only the document's tables).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:35:34 +13:00
Kellermorph 408ef5114d Terminal: pass the voltage across a separation only when it was edited
The three code paths that copied the voltage / protocol to the far
side of a function separated terminal -- the conductor properties
dialog, search/replace and the script API -- compared it against the
far wire's own properties instead of against the edit itself. The two
sides differ on that field just as often as not, so the comparison
held no matter what was edited and every change overwrote the far
side's voltage: setting a colour on a 24 V side dragged the 0 V one
along, where master left it alone. Compare the edit -- what the
dialog was filled with against what the user made of it -- so that a
change of the voltage / protocol is what crosses, which is what the
selection panel multi-edit already did. The values are read before
the undo commands are pushed, since QPropertyUndoCommand applies on
push.

An explicitly selected wire is never the far side of anything:
picking one wire on each side of a separation and editing them edits
them both, in full, however the selection happens to be walked. Nor
is a wire that belongs to a potential on any path, so reaching it
once normally and once across a separation gives it the full edit.
2026-10-13 20:13:18 +02:00
ispyisail 39855e1bb5 Merge #1473 into feat/qetz-db-truth-v2: format 4
#1473 keys the .qetz database's rows by the items' uuids, stores
true/false as 1/0 and writes the format to PRAGMA user_version, as
format 3. This branch's own changes (computed text left out of the
folios, tables without keys, folios kept as they were) become format 4;
formats 2 and 3 are still read.

Folios kept as they were copy their rows by folio uuid, and the keys of
those rows are kept from the other items' keys. The document's tables
are named doc_<name> only beside the project database's own tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:12:57 +13:00
ispyisail f48bd6717d Merge master into feat/qetz-db-truth-v2
#1470 keeps a .qetz's pictures out of the document; the index path still
reads them in, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:00:37 +13:00
ispyisail 8d118faf36 .qetz: key database rows by uuid, type true/false, version the schema
Answers the points arummler raised in discussion #1440 about the
project.sqlite that .qetz files carry (format 2, PR #1462):

- Folio rows were keyed "f1", "f2"... by position, and every symbol and
  wire carried a separate key beside its uuid. A row's key is now the
  item's own uuid, which stays in the folio file; no key attribute is
  added to the folio files of a project as QElectroTech saves it. An item
  without a usable uuid (none, or one another item shares, F100) gets a
  uuid made from its own content, the same on every save, never from
  where it is.
- freezeLabel is stored as 0/1 (INTEGER) and an element information's
  "show" in its own column; a value QElectroTech would not write stays in
  the folio file verbatim. The tag is kept in the JSON only when it is
  not the usual one.
- The database's PRAGMA user_version holds the format.

Format 3. Format 2 files, written by development builds since #1462,
still open: tst_qetcontainer reads one (fixtures/qetz_format2.qetz,
grafcet.qet saved by master aa503aec8) and saves it again.

Checked: tst_qetcontainer 38/38, and 343/343 readable projects of the
legacy corpus round-trip exactly. Every example saved as .qetz then .qet
equals its plain resave, and every format 2 file read by this build
equals what master writes from it. Each new test goes red with the
behaviour it checks broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:34:35 +13:00
Laurent Trinques 8b680af938 Merge pull request #1471 from ispyisail/fix/lazy-refused-opens-eager
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m41s
Fix lazy folios opening older projects slower than without them
2026-10-13 12:47:40 +02:00
Laurent Trinques 7c2a09f2bc Merge pull request #1470 from ispyisail/perf/qetz-picture-load
Speed up opening .qetz projects that hold large pictures
2026-10-13 12:42:15 +02:00
Laurent Trinques 132eefb8b8 Merge pull request #1467 from ispyisail/fix/qetz-zip-entry-date
Fix the dates of the files inside a .qetz project
2026-10-13 12:41:42 +02:00
ispyisail fbda162e34 Find a .qetz's folios with cable wires in its database
Opened from its database, a .qetz's folios not built have no XML in
memory: reading each one's part only to look for wires referring to a
cable read every folio. The project database already knows which wires
carry a cable reference, and so which folios to build for the label pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:52 +13:00
ispyisail f7e0283e61 Include QDomDocument in wiringlistexport.h
The header declares projectDocument() returning a QDomDocument but did not
include it; it only built where the precompiled header pulled it in. CI
builds without one and failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail bfa9912c5f Make modal_quit_regression deterministic
The test interrupted QElectroTech at a random moment inside the dialog's
loop and called quitQET() there. Landing inside malloc(), the call waited
on the allocator's lock for ever: 1 run in 20 timed out at 120 s, on
master too. It now lets the program run on to the start of the main
thread's next QEventLoop::processEvents(), between two events, and calls
from there; the event loop watchdog wakes the loop every 50 ms.

A run could also stop before any dialog: SingleApplication keys a System V
shared memory block from a file in the temporary folder, 16 bits of the
key varying, and each run killed by gdb leaves its block; once a new
run's key fell on one of them (976 here), it refused to start. The run
now removes the blocks it leaves, attached to no process, and is tried
again (a new binary path, a new key, up to three times) when
SingleApplication stops it that way: the scenario never ran. The "dialog
entered" marker no longer prints when the program had exited.

60 runs under load, 976 old blocks present: 60 passed, one try repeated.
With refuseCloseWhileModal() disabled: 3 of 3 FAIL (signal 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail 4104bb8d53 Write only the wire attributes reading needs in a .qetz (stage D3b)
A wire's properties are one row per attribute in the project database,
45 071 rows for f2099's 2 139 wires. The database a .qetz holds now keeps
only the attributes reading would not give anyway: each is left out in
turn when the properties read without it are the same (ConductorProperties
::fromXml(), its defaults); rows by wire and name, so the file does not
depend on memory order. Reading writes every attribute back into the
table, so queries and saves see what they saw before.

f2099: 9 387 rows, the file 3.60 MB -> 3.22 MB (5.14 MB before D3b);
industrial.qet 0.71 -> 0.61 MB. Gates with the master merge and the wire
edits by uuid: ctest 99/99 lazily, 98/99 with every folio built
(modal_quit_regression, the race fixed next); hostile .qetz all refused;
342 corpus projects save, export, round trip and re-save byte-stable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail 798010cc83 Change a wire's properties by uuid without building its folios (stage D5)
Scripts get conductorPropertyByUuid() and setConductorPropertyByUuid(),
the wire named by its uuid (conductorUuids()) rather than by a symbol's
terminal. A change goes to every wire at the wire's potential, as
setConductorProperty() does. When those wires are all on folios not
built, none is numbered by a formula, and what changes in each is what a
.qetz database holds in its own columns (number, function, voltage,
colour and section of the wire, cable, bus), it goes through the project
database: the store and the conductor rows, undoable by uuid. The
potential is followed in the database (projectDataBase::potentialWires():
through wire ends, folio reports and terminal blocks, as
Conductor::relatedPotentialConductors() follows it; a terminal block that
isolates its terminals answers nothing). Building a wire takes its change
(Conductor::fromXml()); a .qetz save writes it in the database, the folio
file copied; a folio written from its XML gets it there; a .qet builds.
Anything else builds the folio, as before.

tst_lazyfolios: every wire's potential from the database = as followed,
1 342 of 1 342 (industrial, eager and from a .qetz); a cable set by uuid
on a 14-wire potential builds no folio, reaches every wire, saves as when
built, undoes, shows once built (fails when building ignores the change,
and when the .qetz writer does). The test's run limit is raised (500 s ->
1 500 s, ctest 1 800 s): under load it ran past 500 s. ctest 96/96 both
ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail b8390c853c Read and write .qetz in the qet MCP server
Every tool now takes a .qetz as it takes a .qet. The tools that read a
project's XML read the .qet the configured QElectroTech saves from the
.qetz, made once per version of the file (path, size, modification
time); without a QElectroTech, they say to set QET_BINARY. The tools that
launch QElectroTech hand it the .qetz, and write one when the output path
ends in .qetz. qet_scan counts .qetz files too. The server's description
and README say so.

Reading through QElectroTech instead of the file's own layout keeps the
server right while the zipped format still changes; the first read of a
big .qetz takes as long as QElectroTech needs to save it.

test_qet_mcp.py: the read tools answer the same for a .qetz and its .qet,
qet_diff finds nothing between them, qet_scan counts both, a .qetz is
converted once, qet_query reads it, qet_edit writes one (four fail when
_root() parses the file itself); without QElectroTech the answer names
QET_BINARY. Whole suite: 430 tests pass with QET_ENABLE_SCRIPTING=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail a6d81a21a8 Build the folios before reading the drawn items' view (stage D6)
A query of drawing_item_view, which is made of the shape, text and
picture tables, built no folio, and with folios built only when needed
(the default since D6) it found nothing: the drawn items are written to
the database by built folios. A query naming the view builds them, as
one naming the tables does.

Found by the qet MCP server's suite (four drawn-item tests failed lazily,
passed with every folio built). tst_lazyfolios: the view's rows from a
.qetz as when built (5 = 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail 975ec57c74 Write a .qetz's database without keys or indexes (stage D3b)
The database a .qetz holds is read whole: its tables are now written
without their primary keys and indexes, in the order of their rows, and
the file compacted; the reader indexes what it looks folios up by once
it has read the file. Keys and indexes on text uuids were half of the
database once zipped. A file written before reads as before.

f2099 (136 folios) 5.14 MB -> 3.60 MB, industrial.qet 1.03 -> 0.71 MB;
opening and showing a folio take as long as before (2.5 s, 215 ms). Gates
on 342 corpus projects: saves, exports, round trip, re-save byte-stable,
all open; hostile .qetz all refused; ctest 96/96 both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail 9d3b9ae789 Translate the .qetz strings into French
The strings the zipped project format added (Save a copy for an older
QElectroTech, the notice when a .qet is saved as .qetz beside it, the
.qetz file filters) and the undo text of a symbol's information changed
through the project database. Only French, by hand, in the wording the
file already uses ("Projets QElectroTech (*.qet)", "Modifier les
informations de l'élément", "Fichier"); the other languages are left for
their translators. lrelease: 0 unfinished; lupdate over sources keeps
every entry finished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail 60489c9893 Fix two faults the end-state gates found (stage D7)
A .qetz re-saved after opening from its index saved wrong folio numbers
in its database: reading each folio's header wrote its row with a
number counted from the folios read so far ("1/1", "2/2" for "1/3",
"2/3"), and an index-opened project keeps the rows it was saved with,
so nothing wrote them again. The folio table's rows are no longer
written while the project is being read (projectDataBase::
diagramInfoChanged()). Folio list tables showed it.

A .qetz whose project.sqlite is not a database crashed QElectroTech: the
first statement could not be prepared and SQLite was handed a null
statement. A statement that cannot be prepared now binds nothing and
steps to no row, and the file is refused.

Gates: 342 corpus projects,
written by QElectroTech 0.3 to 0.200, all open; .qet -> .qetz -> .qet
equals the plain resave with every folio built; a .qetz re-saved twice
holds the same bytes entry by entry but the save's own name, path and
time (the first fault made the first re-save differ); hostile .qetz
(truncated, garbage database, view for a table, trigger, escaping and
repeated names, later format, missing folio, 2 GiB zip bomb) neither
crash nor hang and none opens; an unknown extra entry still opens.
tst_lazyfolios: folio numbers with totals from the index as when built
(fails without the fix); tst_qetcontainer: garbage database refused
(segfaults without it). ctest 96/96 both ways; save and export gates
same on 342.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail 5c4fa8fadf Build folios only when needed, by default (stage D6)
Every project now opens without building its folios; a folio is built
when it is shown or something needs its items (QETProject::lazyFolios()).
QET_LAZY_FOLIOS is gone; QET_EAGER_FOLIOS=1 builds every folio on
opening, as before, for comparisons and troubleshooting. Comments that
named the old variable now say "lazy folios"; the tests switch modes
with the new one.

The first save of a .qet as .qetz still builds every folio: a .qet does
not say which program wrote it, so its folios are written again rather
than copied. A .qetz opens and saves without building.

ctest 96/96 by default and with QET_EAGER_FOLIOS=1; 342 corpus projects
save the same in all four arms and export the same. In the GUI
(qet-gui-dialog): a 136-folio .qetz opens with no folio built, shows
folios 1 to 4, saves building none, the save = the original but the time
spent in it; a .qet opens lazily, saves as a .qetz beside it after the
notice, the .qet unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:51 +13:00
ispyisail b151f52428 Save a new title, author or revision without building the folio (stage D5)
A folio not built whose own data changed was built to save, with the
folios its links reach. When what changed is only title block fields
nothing on a folio is computed from -- its title, author, date,
revision index, version, file name, template; a formula reads the
folio's label, plant, location, extra fields and grid -- its items as
read are what building it would write: it is saved from them with its
own data as it is now (Diagram::deferredXmlWithHeader()), .qet and
.qetz alike, with the information of its symbols changed through the
project database (or built, when one of those needs it). Any other
change still builds it.

136-folio .qetz, revision index and author set on every folio by a
script: 0.8 s, saved in 6.8 s, no folio built; with every folio built,
the same file. tst_lazyfolios: a new revision on every folio of a .qet
and of a .qetz saved as when built, building only the folios holding a
table (fails, 47 built, when every change counts); a folio whose label
changed is built (fails when none counts). ctest 96/96 eager and lazy;
342 corpus projects save the same in all four arms and export the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:44 +13:00
ispyisail 244de05421 Change a symbol's information without building its folio (stage D5)
A script changing the information of a symbol on a folio not built, when
nothing else shows that information (no symbol built holds it, no
partner is linked to it, no label formula), changes it through the
project database: the store and the symbol's element info row, undoable
by uuid (ChangeStoredElementInformationCommand). Reading it back reads
the store. Building the symbol later takes the change
(projectDataBase::takeUnbuiltEdit(), in Element::fromXml()), the label
and texts following as for any change. Saving a .qetz writes the
changed information in the database in place of the rows the folio was
read with, its file copied, the folio still not built; a symbol whose
file holds no information block, or a .qet, builds the folio to save.

136-folio .qetz, "comment" set on the 3 513 symbols the database can
change: 0.9 s, no folio built, saved in 6.1 s, none built (27 s and 116
folios when saving built them). The same edits on the 4 039 symbols with
no partner and no formula, lazily and with every folio built: the two
saved projects are the same file. tst_lazyfolios: a script changes a
symbol not built, building none; saved as when built (fails when
building ignores the change, and when the .qetz writer does); undone;
the folio built afterwards shows it. ctest 96/96 eager and lazy; 342
corpus projects save the same in all four arms and export the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:44 +13:00
ispyisail 9078e4e321 Answer scripts from the folio asked for, without building every folio (stage D5)
The scripting API (scripts and the assistant's live mode) reached a folio
through diagrams(), which builds every folio, for every call. A folio's
own data -- count, title, uuid, title block, border, wire defaults --
now comes from folios(), built or not; a call reading or changing a
folio's items builds that folio, as showing it does (built()); selecting
a symbol by uuid builds the folio the project database places it on.
Search and replace and the continuity check still build every folio.

DiagramContent worked out one wire of each potential when made, which
follows potentials through folio reports and built the folios at the
other end, for every caller; it is now worked out when first asked
(Search and replace asks; a merge asks both sides, as before).

tst_lazyfolios: on a .qetz, a script reads every folio's title, uuid and
border building none (fails with diagrams()), and the symbols and wires
of the folio with the most links to other folios as when built, building
that folio only (27 before potentials were lazy). ctest 96/96 but
modal_quit_regression in both modes: a race in that test (gdb interrupts
the main thread inside malloc while the folio view repaints during the
dialog, master too), 2 of 23 runs here, 0 of 10 on master, passing alone.
342 corpus projects save the same in all four arms, and export the same
wiring list and wire numbers lazily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:44 +13:00
ispyisail 65b10b01d9 Export the wiring list without building every folio (stage D5)
The wiring list export read the project as QETProject::toXml() writes
it, which builds every folio. It now reads a document made without
building: a built folio written as saving writes it
(QETProject::folioXml(), which keeps its symbols' links to partners not
built), a folio not built copied from its XML, and what QElectroTech
computes for a wire's ends -- its symbols' labels, their last partner's
label, the terminals' names -- filled in from the symbols built and from
the project database. A symbol still waiting for a partner not built
takes its partners from the database, saved order.

136-folio .qetz, wiring list from the command line: 30.1 s with every
folio built, 4.5 s lazily, same file. tst_lazyfolios: the wiring list of
a .qet and of a .qetz as when built, building no folio. New corpus gate
(it fails on
all 6 projects tried with terminal names broken): --export-cables and
--export-wires lazily from the .qet and the .qetz = eager on 342
projects; one (forum f3386) differed until waiting symbols took their
partners from the database. ctest 96/96 eager and lazy (one earlier
run had modal_quit_regression and tst_resaveunchanged fail once each,
both passing on rerun in both modes); 342 corpus projects save the same
in all four arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:44 +13:00
ispyisail b972dbec4b Number terminals and export wire numbers without building every folio (stage D5)
Terminal numbering builds only the folios holding a terminal. The wire
numbers export reads the wires of the folios not built from the project
database, and walks a built folio's own wires instead of a
DiagramContent, which followed their potentials through folio reports
and built the folios at the other end. The wiring list export names a
folio from its title block, which a folio not built has; it still
builds every folio, writing the whole project out (toXml()).

tst_lazyfolios: wire numbers exported as when built, from a .qet and a
.qetz, building no folio (fails with DiagramContent: 27 built);
terminal numbering builds 7 folios, those with a terminal (fails with
diagrams(): 47); the wiring list as when built. ctest 96/96 eager and
lazy; 342 corpus projects save the same in all four arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail e7fd820db6 Add, move or remove a folio without building the others (stage D5)
Adding, moving or removing a folio rewrote every folio's position in
the project database through diagrams(), which builds every folio: 3 s
on a 50-folio project opened lazily. A folio not built has its position
and title block already, so folios() is enough. The list of tables a
table can continue builds only the folios holding a table (from the
index of a .qetz), and the wiring list's count of wires left out looks
at built folios only: reading a wire drops it unless both its ends are
found.

tst_lazyfolios: a folio added, moved and removed builds no other, with
the folios' rows as when every folio is built (fails with diagrams():
47 built); the tables listed as when built, building the 12 folios that
hold one (fails when every folio is built). ctest 96/96 eager and lazy;
342 corpus projects save the same in all four arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail 8ae937007c Answer numbering, searches and unused symbols from the database (stage D5)
The project database's element table now holds each symbol's definition
(the path its file names it by) and its sequential numbers, filled from
the document and from built folios alike. Numbering, the searches for
symbols of a kind (a link tab's candidates) and the list of unused
symbols read them for the folios not built, instead of reading those
folios' files. Saving looks up a folio's tables in the index it was
opened from.

A .qetz asked its numbering, searches and unused symbols reads only the
files of the folios it builds (tst_lazyfolios, which fails when the
unused symbols are read from the folio files: 50 read, 22 built).
ctest 96/96 eager and lazy; 342 corpus projects save the same in all
four arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail b891b6354a Show a folio without building its partners' folios (stage D5b)
Showing a folio built it and every folio holding a partner of its
symbols. Now a partner is built alone, as a symbol, into its folio's
scene (Diagram::buildSymbols()), and reused when that folio is built.

- A linked folio report built alone had no wires when it was linked,
  so its texts showing a wire's properties stayed empty: after any
  build, linked reports on the built folios and their partners look at
  their wires again, as opening does. Unlinked reports keep their texts,
  as opening leaves them.
- A report whose partner was built alone builds the partner's folio
  when its potential is followed.
- tableChainFolios() parsed every unbuilt folio's file. The index now
  lists the drawn tables and the table each continues (folio_table),
  and a .qetz opened from it uses that list. An index without it falls
  back to the folio files.
- A .qetz's project.sqlite is opened once per project
  (QetContainerDb::FolioReader), not once per folio built.

nuri (136 folios), opened fresh, one folio shown: median 1104 -> 184 ms,
each show builds one folio; industrial 234 -> 87 ms.

tst_lazyfolios: a folio whose table continues another is shown first
from a .qetz (fails when the index's chains are ignored). ctest 96/96
eager and lazy; 342-project corpus: master, lazy, .qetz lazy and .qetz
kept all the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail 2f94724df3 Save a .qetz without building partners: symbols save the links they wait for (stage D5a)
A built symbol whose partner is on a folio not built yet (QET_LAZY_FOLIOS)
now saves that link too, in the order it was saved with
(Element::toXml(); QETProject::waitingLinkTargets() keeps it to partners
the project has, so a link to a symbol gone is dropped as it always was).
With that, and with no partner's text in a .qetz's folio files (D3c),
saving a .qetz no longer builds the folios of the partners built folios
wait for; table chains still are (a table saves the table before it), and
a .qet saved as read (QET_LAZY_SAVE_KEPT) still builds them, its folio
files holding partners' texts.

industrial.qetz: an untouched save builds no folio (47 of 47 stay not
built; 19 before), after everyday edits on its busiest folio 29 stay not
built (7 before).

tst_lazyfolios compares the links an edited .qetz saved without building
holds, symbol by symbol in their order, with those the same edits saved
with every folio built -- it fails when waiting links are not saved (the
drawing alone cannot tell: links are kept on both sides, and a symbol
reread gets its partner's back, in another order). Corpus, 342 projects:
a .qetz saved without building, read and saved as .qet = eager = master.
ctest 96/96 with and without QET_LAZY_FOLIOS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail 8ad33f6281 Save a .qetz without building the folios not touched (stage D4)
With QET_LAZY_FOLIOS=1, a project opened from a .qetz's index saves a
.qetz without building the folios it never built, when building them
would write the same: their parts are copied as they were and their rows
of the document's engineering data from the project.sqlite they were read
from (QetContainerDb::extract() with kept_from, QetContainer::split() with
the old entries, pictures included). Whether a folio may be kept follows
the rules of stage 5.5 (QETProject::toXml() with kept_parts): nothing
project-wide changed since opening; a folio whose own data changed is
built with its partners; after an edit, folios holding a table filled
from the database are built; the partners still waiting of every built
folio are built. Trusted only for a project opened from a format 3 index
written by this version whose folios are named by their uuid.

Format 3 names a folio by its uuid in the database (doc_meta folio_ids =
uuid) when every folio has one of its own, so that a kept folio keeps its
rows whatever its place; copies keep the rows' order (ORDER BY rowid).
A folio not built keeps the name of its part until it is built
(Diagram::deferredPart()).

Measured, the 136-folio f2099 opened from its .qetz and saved as a .qetz:
4.75 s with no folio built, 33 s with every folio built; its 136 folio
parts copied byte for byte; a kept save of a kept save is the same bytes.
industrial.qetz untouched: 28 of its 47 folios not built are built to save
(link chains, as in 5.5); after everyday edits on its busiest folio, 7
stay not built.

tst_lazyfolios: an untouched .qetz saved without building, read back,
holds the project; edits saved without building hold, read back, what the
same edits saved with every folio built hold (read back too: a wire
numbered from its potential is saved with no sequential numbers whichever
the way -- reading it back renumbers it, a separate issue); fails when a
kept folio's symbol information is not copied. Corpus, 342 projects: a
.qetz saved without building, read and saved as .qet = eager = master on
all 342. ctest
96/96 with and without QET_LAZY_FOLIOS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail 8ea051acfc A .qetz keeps nothing QElectroTech computes in its folio files (stage D3c)
Format 3 folio files lose what building a folio computes
(QetContainer::dropComputed()): a wire's description of its ends
(element1_label, element1_name, element1_linked, terminalname1 and the
same for end 2), the content of a symbol text showing the symbol's
information or a composition of it, and -- when the project database's
index is written, every symbol with a uuid of its own -- a symbol's list
of its terminals.

What may go was found by test, not by reading: on 342 projects saved by
this version, each kind taken out of the file, the project opened and
saved: the wire ends came back in all 244 that had them, the symbol texts
in all 201, the terminal lists in 305 of 306 -- not in the file whose
symbols share a uuid (#1408), where wires find their ends by them; such a
project gets no index, so it keeps them.

Stays, for now: labels made from a formula and wire numbers made from one,
in the database (opening from the index and numbering read them), and a
table's size (the user's as much as computed).

tst_qetcontainer compares a joined document with the original less what is
computed, and computedNotKept checks a file holding both kinds (one filled
in by the test) loses them -- it fails when nothing is dropped. Through
QElectroTech, 342 projects: a .qetz opened from its index and saved as .qet
= eager = master; every folio of industrial and f2099 drawn and linked as
eager (f2099 but its 3 render-noise folios). ctest 96/96 with and without
QET_LAZY_FOLIOS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail 2f98872d7a .qetz format 3: one project database (stage D3a)
project.sqlite now holds both what QElectroTech keeps in its project
database (the index D2 wrote as index.sqlite) and the document's
engineering data, the latter's tables named doc_<name> beside the
former's -- element, element_info, link and conductor exist in both
(QetContainerDb's SQL names them as before; Database::sql() puts the
prefix on). The manifest says format 3, min-reader 3: a format 2 reader
would find no tables of its names. Format 2 files, with or without an
index.sqlite entry, are read as before: the prefix is found from the
file (doc_meta).

A .qetz with an index opens from project.sqlite. Measured: the 136-folio
f2099 1.8 s, 5.2 MB (the two table sets still overlap: D3b).

Checked: tst_qetcontainer with the format 3 names, 34/34; a format 2 file
of the last commit opens from its index.sqlite and saves the same; corpus
342: 340 .qetz carry the index in project.sqlite and open from it, saved
back = eager = master on all 342. ctest 96/96 with and without
QET_LAZY_FOLIOS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail e89ccf2a40 Open a .qetz from its database, each folio read when built (stage D2)
A .qetz now carries index.sqlite: the project database as QElectroTech
keeps it -- every table but the drawing items, which exist for built
folios only -- with each folio's own data (Diagram::headerXml()) and a
stamp of the schema it was written with (projectDataBase::indexFile(),
QETProject::writeContainerFile(): Save, --resave, --set-titleblock, a
script's and live mode's save). Written when every folio, symbol and wire
has a uuid of its own; a reader that does not know the entry skips it.

With QET_LAZY_FOLIOS=1, a .qetz with an index opens from it: the project
document is joined without its folios (QetContainer::join() with
folio_files), the database and the stores of symbol information and wire
properties are filled from the index (fillFromIndex()), each folio is made
from its own data and keeps the name of its part, read when the folio is
built (Diagram::deferredXml(), QETProject::readFolioPart(),
QetContainer::readFolio() -- pictures and engineering data back from
project.sqlite, QetContainerDb::restoreFolio()). A terminal strip finds its
terminals' folios in the database. Any other .qetz, or one whose index
cannot be used, is read whole as before.

The index is untrusted: refused unless written with this database's
schema, every table read a plain table (not a view), nothing in its schema
run (trusted_schema off); refused, the project is read whole. Checked: an
index of garbage, a wrong stamp, a table made a view -- each refused and
the project saved as a plain save.

The 136-folio f2099 as .qetz opens in 1.8 s (3.65 s read whole, 29 s
building every folio); what is left is mostly its 7 MB of symbol
definitions (0.8 s). The file grows from 2.6 to 5.2 MB, the index
overlapping project.sqlite until D3.

tst_lazyfolios: a .qetz opened from its index builds only the terminal
strip's folios, numbering read as when built, every folio shown in turn
draws and links as when built, the save as when built; fails when a folio
part's engineering data is not put back. Corpus, 342 projects: 340 .qetz
carry an index and open from it (2 have symbols sharing a uuid); saved
back as .qet = eager = master on all 342. Probe on industrial and f2099
.qetz: every folio as eager but the 3 render-noise folios of f2099. In a
real window the .qetz opens and pages building only shown folios and
partners. ctest 96/96 with and without QET_LAZY_FOLIOS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00
ispyisail ecc7a39131 Save as .qetz by default; a .qet opened is saved beside it (stage D1)
The zipped project (.qetz) is the format QElectroTech saves.

- Save As and the first Save of a new project offer .qetz first
  ("untitled.qetz"); .qet can still be chosen.
- A project read from a .qet is saved as a .qetz beside it, the .qet left
  as it is (QETProject::write(), so Save, autosave, a script's save and the
  assistant's live save all do it). The project then works on the .qetz.
  A path chosen afterwards (Save As, a script naming one) is saved to as it
  is. The editor says so before the first such save, and asks before
  replacing a .qetz already there.
- File > Save a copy for an older QElectroTech writes a .qet copy, for a
  version that cannot open a .qetz; the project keeps its own file.
- The operating systems know the file: a freedesktop MIME type
  application/x-qelectrotech-project+zip (*.qetz, and by its mimetype
  entry when the name says nothing), the desktop file, macOS's document
  types and the Windows installer's association.

Crash backups stay XML, written beside the project as before.

tst_resaveunchanged::qetSavedAsQetzBeside: a .qet saved where it is leaves
the .qet byte for byte and writes a .qetz beside it holding what a plain
save of the .qet holds; it fails when the switch is off. In a real window:
Ctrl+S on perceuse.qet shows the notice, writes perceuse.qetz and leaves
perceuse.qet's md5 unchanged; the copy for an older version (reached by
command search) equals a plain save but for time_spent; a new project's
Save offers untitled.qetz. xdg-mime finds the type by name and by content.
ctest 96/96 with and without QET_LAZY_FOLIOS=1; srclint R-C clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:33 +13:00
ispyisail 00e200b504 Remove references to notes that are not in this repository
Comments added with the lazy folios cited planning notes kept outside the
QElectroTech repository, which a reader of the source cannot open. The
sentences now stand on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:14 +13:00
ispyisail 1c4618c8dd Open with every folio built when the database must come from them
With QET_LAZY_FOLIOS=1, a project whose database cannot be filled from
the file (folios or symbols with no saved uuid, two symbols sharing one,
a folio number using %autonum, wires naming their ends the older way)
had its folios left unbuilt, then built all at once while the database
was filled from them. That is slower than building them as they are
read: a 17 MB project reported on the pull request took 24.8 s to open
instead of 19.2 s, and most files saved by an older version have no
folio uuids.

The file is now checked for those reasons before the folios are read;
when one applies the project opens as without QET_LAZY_FOLIOS. The rarer
reasons, which need the definitions or every folio's links, still fall
back as before.

tst_lazyfolios opens a copy of industrial.qet with one symbol's uuid
given to another, and checks no folio is built after reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:02:55 +13:00
ispyisail 1ad3c8e2b2 Speed up opening .qetz projects with large pictures
A .qetz keeps each folio picture as a PNG file. On opening, join() put
every picture back into the document as base64 text, and the folio then
decoded that text again, twice. On a project with 20 pictures of 7 MB
(140 MB of PNG, the shape of the project on PR #1462) that cost about
8 of the 12 seconds the open took:

  QDomDocument::createTextNode() on the base64   3.9 s
  DiagramImageItem::fromXml(), base64 decoded    3.9 s (2 x 1.9 s)
  the PNG decoding itself                        0.3 s

join() now takes an optional map. Given one, each picture stays a file:
the <image> keeps its qetz-file attribute and the bytes go in the map.
QETProject keeps the map for as long as the project is open, and
Diagram::fromXml() hands a picture's bytes to the new
DiagramImageItem::fromXml(element, picture). Kept, not cleared after
the load, so a folio built later finds its pictures too; the bytes are
shared with the PNG cache of the pictures built from them, so this costs
almost no memory. readDocument() without a map, as the automatic
numbering import uses it, still gives the whole document.

fromXml() of a .qet picture also decodes its base64 once instead of
twice: the first text node, which the PNG cache already used. e.text()
also collects <image_base>'s text, but loadFromData() stops at the end
of the first picture, so the picture shown is the same.

Measured, same file, 3 runs each, offscreen:
  .qetz  11.8 s -> 3.8 s, peak memory 1034 MB -> 655 MB
  .qet   15.7 s -> 13.6 s
Resaving either file with either binary gives the same bytes, a cropped
and colour-keyed picture included, and every folio exports to the same
PNG pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:02:47 +13:00
Laurent Trinques aa503aec81 Merge pull request #1468 from qelectrotech/resolve-1461
Speed up opening big projects with lazy folios
2026-10-13 11:10:10 +02:00
ispyisail c240e4e6f7 Fix the dates of the files inside a .qetz project
miniz is built with MINIZ_NO_TIME so that saving the same project twice
gives the same bytes. Without a clock it writes 0 as each entry's DOS
date: day 0 of month 0, 1980, which is not a date. Unzip tools show it
as 30 Nov 1979, others fail the conversion and show 13 Dec 1901
(reported on PR #1462).

Write 1 Jan 1980 00:00, the first valid DOS date, into the local and
central header of every entry once the archive is built. The bytes stay
reproducible, and miniz stays unmodified. Using the save time instead
was rejected: two saves would no longer give the same file, and miniz
converts through the local time zone, so even a fixed time_t would
differ between machines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:49:34 +13:00
Kellermorph 561ddc29bf Terminal: rename potential separation to function separation
The checkbox of a terminal element was an all-or-nothing switch: while
it was unticked every conductor property (function, wire colour,
section, number...) crossed to the other side of the terminal, and
once it was ticked nothing did at all.

Rename it to "Function separation" and let one property still cross:
the voltage / protocol, which describes the electrical node itself
rather than one side of it. Conductor::functionSeparatedConductors()
returns the conductors reachable only by crossing such a terminal --
the potential computed with the separations ignored, minus the one
that respects them -- and
ConductorProperties::applyTensionProtocolChange() gives them that one
property, in the conductor properties dialog, the selection panel
multi-edit, the script API and search/replace.

The stored element information stays "potential_isolating", so
existing symbols and projects keep their setting, and the existing
sub-case of terminals sharing a potential-group identifier is
untouched. The German translation of the new label is deliberately
left out of this commit and belongs with the other .ts updates.
2026-10-11 14:57:42 +02:00
257 changed files with 165984 additions and 88785 deletions
+35 -14
View File
@@ -117,7 +117,7 @@ jobs:
-DQT_VERSION_MAJOR=6 \
-DBUILD_WITH_KF=ON \
-DBUILD_KF=OFF \
-DPACKAGE_TESTS=ON \
-DPACKAGE_TESTS=OFF \
-DCMAKE_POLICY_DEFAULT_CMP0077=NEW \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DQET_EXPORT_PROJECT_DB=ON \
@@ -509,6 +509,21 @@ jobs:
# ---------------------------------------------------------------------------
# Job 2 : Publie les assets nightly (exe + zip) sur la release
# Ne tourne que sur push master (pas sur les PRs)
#
# Release tag: "nightly-qt6" (was "nightly").
# The old "nightly" release became IMMUTABLE after it was republished by
# this job while GitHub's "Release immutability" repo setting was briefly
# enabled (Oct. 2026). Turning the setting off again does not revert a
# release that was published while it was on: assets could no longer be
# added or deleted there, and its tag can never host a new release, even
# after the release itself was deleted. Hence a fresh tag. The old
# "nightly" release (with the frozen Qt5 legacy build) was deleted at the
# same time; its tag stays locked and must not be reused.
#
# Do NOT re-enable "Release immutability" while this single, rolling
# nightly release model is used: the next republish would lock
# "nightly-qt6" the same way. Immutability requires one new release per
# build (draft -> upload assets -> publish).
# ---------------------------------------------------------------------------
publish-nightly-assets:
needs: [build-windows-qt6]
@@ -516,6 +531,8 @@ jobs:
if: github.event_name != 'pull_request'
permissions:
contents: write
env:
NIGHTLY_TAG: nightly-qt6
steps:
- name: Download installer artifact
@@ -532,27 +549,33 @@ jobs:
path: downloaded/portable/
merge-multiple: true
# Only Qt6-tagged assets are deleted/replaced here. Assets without "qt6"
# in the name are the frozen Qt5 legacy build (last one ever published,
# before the Qt5 CI job was removed) — deliberately left untouched so
# they stay downloadable indefinitely instead of disappearing.
- name: Delete old nightly Qt6 assets (.exe and .zip)
# Remove the previous .exe/.zip before uploading the new ones.
# On the very first run the release does not exist yet: skip cleanly
# (the default bash shell runs with pipefail, so an unguarded
# `gh release view` on a missing release would fail the step).
- name: Delete old nightly assets (.exe and .zip)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
gh release view nightly --repo "$REPO" --json assets \
--jq '.assets[] | select(.name | test("qt6")) | select(.name | test("\\.(exe|zip)$")) | .name' \
if ! gh release view "$NIGHTLY_TAG" --repo "$REPO" > /dev/null 2>&1; then
echo "Release '$NIGHTLY_TAG' does not exist yet — it will be created by the next step."
exit 0
fi
gh release view "$NIGHTLY_TAG" --repo "$REPO" --json assets \
--jq '.assets[] | select(.name | test("\\.(exe|zip)$")) | .name' \
| while read -r name; do
echo "Deleting old Qt6 asset: $name"
gh release delete-asset nightly "$name" --repo "$REPO" --yes
echo "Deleting old asset: $name"
gh release delete-asset "$NIGHTLY_TAG" "$name" --repo "$REPO" --yes
done
echo "Old Qt6 .exe and .zip assets deleted (legacy Qt5 assets left untouched)."
echo "Old .exe and .zip assets deleted."
- name: Update nightly release
uses: softprops/action-gh-release@v3
with:
tag_name: nightly
tag_name: ${{ env.NIGHTLY_TAG }}
# Only used when the tag does not exist yet (first run).
target_commitish: ${{ github.sha }}
name: "Nightly Build – Windows"
body: |
🔧 **Automated nightly build** — may be unstable.
@@ -565,8 +588,6 @@ jobs:
> ⚠️ This is a development version; it introduces new features you want, but may cause bugs that have not yet been identified yet.
> For stable releases, see the [Releases page](https://github.com/${{ github.repository }}/releases).
> 🗄️ Files without `-qt6-` in the name are the last Qt5 build ever published (frozen, unmaintained). Files tagged `-qt6-` are the actively maintained build.
prerelease: true
make_latest: false
files: |
+38 -28
View File
@@ -14,6 +14,13 @@ on:
required: false
default: ""
# Rolling Qt6 nightly release, updated on every build. Replaces the old
# "nightly" tag, which became immutable while the "Release immutability"
# setting was briefly enabled (Oct. 2026) — see the note on
# publish-nightly-assets in windows-build.yml.
env:
NIGHTLY_TAG: nightly-qt6
jobs:
build-msi:
name: Build MSI with WiX v7 (${{ matrix.flavor }})
@@ -371,19 +378,32 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
# The release may not exist yet (e.g. a manual MSI run before
# windows-build.yml has published anything on NIGHTLY_TAG):
# nothing to delete then.
gh release view $env:NIGHTLY_TAG --repo $env:REPO *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "Release '$env:NIGHTLY_TAG' does not exist yet — nothing to delete."
exit 0
}
$pattern = "-qt6.*\\.msi$"
$names = gh release view nightly --repo $env:REPO --json assets --jq ".assets[] | select(.name | test(`"$pattern`")) | .name"
$names = gh release view $env:NIGHTLY_TAG --repo $env:REPO --json assets --jq ".assets[] | select(.name | test(`"$pattern`")) | .name"
foreach ($name in ($names -split "`n" | Where-Object { $_ })) {
Write-Host "Deleting old asset: $name"
gh release delete-asset nightly $name --repo $env:REPO --yes
gh release delete-asset $env:NIGHTLY_TAG $name --repo $env:REPO --yes
}
shell: pwsh
# prerelease / make_latest set explicitly: if this step happens to
# create the release (it does not exist yet), it must not become the
# repository's "Latest" release.
- name: Upload MSI to nightly release
if: always()
if: always() && hashFiles('dist/*.msi') != ''
uses: softprops/action-gh-release@v3
with:
tag_name: nightly
tag_name: ${{ env.NIGHTLY_TAG }}
prerelease: true
make_latest: false
files: dist/*.msi
fail_on_unmatched_files: true
env:
@@ -413,11 +433,11 @@ jobs:
runs-on: ubuntu-latest
# Regenerate the page whenever the MSI job actually ran (success OR
# packaging/signing failure) — not only on full success.
# generate-page.py queries the published assets on the "nightly"
# release itself (line 407: `gh release view nightly --json assets`),
# so it already handles a missing MSI natively (empty MSI_NAME -> no
# MSI button on the page). The page only needs the exe/zip already
# published by windows-build.yml, independent of the MSI's fate.
# The step below queries the published assets on the NIGHTLY_TAG
# release itself, so it already handles a missing MSI natively (empty
# MSI_NAME -> no MSI button on the page). The page only needs the
# exe/zip already published by windows-build.yml, independent of the
# MSI's fate.
# Only "skipped"/"cancelled" are excluded: if build-msi never ran at
# all (e.g. Windows Build itself failed), there is nothing new to
# publish and regenerating the page would be pointless.
@@ -447,33 +467,23 @@ jobs:
set -euo pipefail
REPO="${{ github.repository }}"
ASSETS=$(gh release view nightly --repo "$REPO" --json assets --jq '.assets[].name')
ASSETS=$(gh release view "$NIGHTLY_TAG" --repo "$REPO" --json assets --jq '.assets[].name')
EXE_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.exe$' | head -1)
ZIP_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.zip$' | head -1)
EXE_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.exe$' | head -1 || echo "")
ZIP_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.zip$' | head -1 || echo "")
MSI_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.msi$' | head -1 || echo "")
# Legacy Qt5 assets (no "qt6" in the name): last ever published,
# frozen — windows-build.yml/windows-msi.yml no longer delete or
# replace these, so they keep pointing at the same files release
# after release. Rendered as a separate "legacy" section if present.
LEGACY_EXE_NAME=$(echo "$ASSETS" | grep -v 'qt6' | grep '\.exe$' | head -1 || echo "")
LEGACY_ZIP_NAME=$(echo "$ASSETS" | grep -v 'qt6' | grep '\.zip$' | head -1 || echo "")
LEGACY_MSI_NAME=$(echo "$ASSETS" | grep -v 'qt6' | grep '\.msi$' | head -1 || echo "")
if [ -z "$EXE_NAME" ] || [ -z "$ZIP_NAME" ]; then
echo "::error::No Qt6 .exe/.zip found on release '$NIGHTLY_TAG' — refusing to publish a page with dead links."
exit 1
fi
BASE="https://github.com/$REPO/releases/download/nightly"
BASE="https://github.com/$REPO/releases/download/$NIGHTLY_TAG"
INSTALLER_URL="$BASE/$EXE_NAME"
PORTABLE_URL="$BASE/$ZIP_NAME"
MSI_URL=""
[ -n "$MSI_NAME" ] && MSI_URL="$BASE/$MSI_NAME"
LEGACY_INSTALLER_URL=""
LEGACY_PORTABLE_URL=""
LEGACY_MSI_URL=""
[ -n "$LEGACY_EXE_NAME" ] && LEGACY_INSTALLER_URL="$BASE/$LEGACY_EXE_NAME"
[ -n "$LEGACY_ZIP_NAME" ] && LEGACY_PORTABLE_URL="$BASE/$LEGACY_ZIP_NAME"
[ -n "$LEGACY_MSI_NAME" ] && LEGACY_MSI_URL="$BASE/$LEGACY_MSI_NAME"
SHA="${{ github.event.workflow_run.head_sha || github.sha }}"
SHORT="${SHA:0:7}"
DATE=$(date -u '+%Y-%m-%d %H:%M UTC')
@@ -482,7 +492,7 @@ jobs:
export DATE SHORT REPO SHA RUN_URL RUN_NUMBER
export INSTALLER_URL PORTABLE_URL MSI_URL
export LEGACY_INSTALLER_URL LEGACY_PORTABLE_URL LEGACY_MSI_URL
export NIGHTLY_TAG
python3 source/build-aux/generate-page.py
+13 -60
View File
@@ -7,30 +7,25 @@ Environment variables required:
DATE, SHORT, REPO, SHA, RUN_URL, RUN_NUMBER,
INSTALLER_URL, PORTABLE_URL, MSI_URL (optional)
Optional (frozen Qt5 legacy build — omitted entirely if empty):
LEGACY_INSTALLER_URL, LEGACY_PORTABLE_URL, LEGACY_MSI_URL
Optional:
NIGHTLY_TAG tag of the rolling nightly release (default: "nightly-qt6")
NOTE: Windows Qt5 CI build removed (Qt6 is now the sole track built and
signed going forward). INSTALLER_URL / PORTABLE_URL / MSI_URL point to the
Qt6 build artifacts. The LEGACY_* variables, when set, point to the last
Qt5 nightly assets that were ever published — kept downloadable but frozen
(windows-build.yml no longer regenerates or deletes them).
NOTE: Qt6 is the sole Windows track. The frozen Qt5 legacy build and the
old "nightly" release that held it were removed in Oct. 2026, after that
release became immutable (see windows-build.yml, publish-nightly-assets).
"""
import os
date = os.environ.get("DATE", "")
short = os.environ.get("SHORT", "")
repo = os.environ.get("REPO", "")
sha = os.environ.get("SHA", "")
run_url = os.environ.get("RUN_URL", "")
run_number = os.environ.get("RUN_NUMBER", "")
date = os.environ.get("DATE", "")
short = os.environ.get("SHORT", "")
repo = os.environ.get("REPO", "")
sha = os.environ.get("SHA", "")
run_url = os.environ.get("RUN_URL", "")
run_number = os.environ.get("RUN_NUMBER", "")
installer_url = os.environ.get("INSTALLER_URL", "")
portable_url = os.environ.get("PORTABLE_URL", "")
msi_url = os.environ.get("MSI_URL", "")
legacy_installer_url = os.environ.get("LEGACY_INSTALLER_URL", "")
legacy_portable_url = os.environ.get("LEGACY_PORTABLE_URL", "")
legacy_msi_url = os.environ.get("LEGACY_MSI_URL", "")
nightly_tag = os.environ.get("NIGHTLY_TAG", "") or "nightly-qt6"
msi_block = ""
if msi_url:
@@ -40,47 +35,6 @@ if msi_url:
<span class="btn-text">Windows Installer .msi<small>.msi &mdash; for enterprise / GPO deployment</small></span>
</a>"""
# Legacy Qt5 section — only rendered if at least one legacy asset exists.
legacy_block = ""
if legacy_installer_url or legacy_portable_url or legacy_msi_url:
legacy_installer_btn = ""
if legacy_installer_url:
legacy_installer_btn = f"""
<a class="btn btn-secondary" href="{legacy_installer_url}">
<span class="btn-icon">&#11015;</span>
<span class="btn-text">Windows Installer (Qt5, legacy)<small>.exe &mdash; frozen, no longer updated</small></span>
</a>"""
legacy_msi_btn = ""
if legacy_msi_url:
legacy_msi_btn = f"""
<a class="btn btn-secondary" href="{legacy_msi_url}">
<span class="btn-icon">&#11015;</span>
<span class="btn-text">Windows Installer .msi (Qt5, legacy)<small>.msi &mdash; frozen, no longer updated</small></span>
</a>"""
legacy_portable_btn = ""
if legacy_portable_url:
legacy_portable_btn = f"""
<a class="btn btn-secondary" href="{legacy_portable_url}">
<span class="btn-icon">&#128230;</span>
<span class="btn-text">Windows Portable (Qt5, legacy)<small>.zip &mdash; frozen, no longer updated</small></span>
</a>"""
legacy_block = f"""
<div class="card">
<h2>&#128451; Windows &mdash; x86_64 &mdash; Qt5 (legacy, unmaintained)</h2>
<div class="warning">
&#128451; <strong>Legacy build &mdash; frozen, no longer updated.</strong> This is the
last Qt5 build published before Windows CI switched to Qt6 only. Kept available for
anyone who still needs it, but it will not receive further fixes or security updates.
Please migrate to the Qt6 build above when you can.
</div>
<div class="downloads">
{legacy_installer_btn}
{legacy_msi_btn}
{legacy_portable_btn}
</div>
</div>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -146,13 +100,12 @@ For production use, download a <a href="https://github.com/{repo}/releases">stab
<span class="btn-icon">&#128230;</span>
<span class="btn-text">Windows Portable<small>.zip &mdash; no installation required, extract and run &quot;Lancer QET.bat&quot;</small></span>
</a>
<a class="btn btn-secondary" href="https://github.com/{repo}/releases/tag/nightly">
<a class="btn btn-secondary" href="https://github.com/{repo}/releases/tag/{nightly_tag}">
<span class="btn-icon">&#128230;</span>
<span class="btn-text">All nightly files on GitHub<small>Release page with checksums</small></span>
</a>
</div>
</div>
{legacy_block}
</main>
<footer>
Auto-generated by GitHub Actions &nbsp;&middot;&nbsp;
+4
View File
@@ -15,6 +15,10 @@ platforms:
amd64:
arm64:
layout:
/usr/share/qelectrotech:
symlink: $SNAP/usr/share/qelectrotech
apps:
qelectrotech:
command: usr/bin/qelectrotech
+4
View File
@@ -338,6 +338,9 @@ Section ""
WriteRegStr HKEY_CLASSES_ROOT "qet_diagram_file\DefaultIcon" "" "$final_project_ico"
WriteRegStr HKEY_CLASSES_ROOT "qet_diagram_file\shell\open\command" "" '"$final_qet_exe" "%1"'
; File associations - .qetz, the zipped project: the same file type
WriteRegStr HKEY_CLASSES_ROOT ".qetz" "" "qet_diagram_file"
; File associations - .elmt
WriteRegStr HKEY_CLASSES_ROOT ".elmt" "" "qet_element_file"
WriteRegStr HKEY_CLASSES_ROOT "qet_element_file" "" "Element QET"
@@ -486,6 +489,7 @@ Section "Uninstall"
; Remove file associations registry keys
DeleteRegKey HKEY_CLASSES_ROOT "Applications\qelectrotech.exe"
DeleteRegKey HKEY_CLASSES_ROOT ".qet"
DeleteRegKey HKEY_CLASSES_ROOT ".qetz"
DeleteRegKey HKEY_CLASSES_ROOT "qet_diagram_file"
DeleteRegKey HKEY_CLASSES_ROOT ".elmt"
DeleteRegKey HKEY_CLASSES_ROOT "qet_element_file"
@@ -69,6 +69,10 @@ set reg_file=qet_install_file_associations.reg
echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command]
echo @="\"%final_qet_exe%\" \"%%1\""
rem *.qetz (zipped project): the same file type as *.qet
echo [HKEY_CURRENT_USER\Software\Classes\.qetz]
echo @="qet_diagram_file"
rem association de fichier *.elmt
echo [HKEY_CURRENT_USER\Software\Classes\.elmt]
echo @="qet_element_file"
+19
View File
@@ -291,6 +291,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/qetmainwindow.h
${QET_DIR}/sources/qetmessagebox.cpp
${QET_DIR}/sources/qetmessagebox.h
${QET_DIR}/sources/projectlock.cpp
${QET_DIR}/sources/projectlock.h
${QET_DIR}/sources/qetproject.cpp
${QET_DIR}/sources/qetproject.h
${QET_DIR}/sources/qetregexpvalidator.cpp
@@ -384,6 +386,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/autobreakconductor.cpp
${QET_DIR}/sources/autobreakconductor.h
${QET_DIR}/sources/symboltextplacement.cpp
${QET_DIR}/sources/symboltextplacement.h
${QET_DIR}/sources/diagramevent/diagrameventaddelement.cpp
${QET_DIR}/sources/diagramevent/diagrameventaddelement.h
@@ -709,10 +713,13 @@ set(QET_SRC_FILES
${QET_DIR}/sources/TerminalStrip/physicalterminal.h
${QET_DIR}/sources/TerminalStrip/realterminal.cpp
${QET_DIR}/sources/TerminalStrip/realterminal.h
${QET_DIR}/sources/TerminalStrip/terminalcatalog.h
${QET_DIR}/sources/TerminalStrip/terminalstripbridge.cpp
${QET_DIR}/sources/TerminalStrip/terminalstripbridge.h
${QET_DIR}/sources/TerminalStrip/terminalstrip.cpp
${QET_DIR}/sources/TerminalStrip/terminalstrip.h
${QET_DIR}/sources/TerminalStrip/terminalstripautoimport.cpp
${QET_DIR}/sources/TerminalStrip/terminalstripautoimport.h
${QET_DIR}/sources/TerminalStrip/terminalstripdata.cpp
${QET_DIR}/sources/TerminalStrip/terminalstripdata.h
${QET_DIR}/sources/TerminalStrip/ui/addterminalstripitemdialog.cpp
@@ -757,6 +764,12 @@ set(QET_SRC_FILES
${QET_DIR}/sources/TerminalStrip/GraphicsItem/terminalstripitem.h
${QET_DIR}/sources/TerminalStrip/GraphicsItem/trueterminalstrip.cpp
${QET_DIR}/sources/TerminalStrip/GraphicsItem/trueterminalstrip.h
${QET_DIR}/sources/TerminalStrip/GraphicsItem/realisticterminalstripdrawer.cpp
${QET_DIR}/sources/TerminalStrip/GraphicsItem/realisticterminalstripdrawer.h
${QET_DIR}/sources/TerminalStrip/GraphicsItem/terminalstripdrawerhelpers.h
${QET_DIR}/sources/TerminalStrip/GraphicsItem/realisticterminalstripdrawer.cpp
${QET_DIR}/sources/TerminalStrip/GraphicsItem/realisticterminalstripdrawer.h
${QET_DIR}/sources/TerminalStrip/GraphicsItem/terminalstripdrawerhelpers.h
${QET_DIR}/sources/TerminalStrip/GraphicsItem/terminalstripdrawer.cpp
${QET_DIR}/sources/TerminalStrip/GraphicsItem/terminalstripdrawer.h
${QET_DIR}/sources/TerminalStrip/GraphicsItem/properties/terminalstriplayoutpattern.cpp
@@ -820,6 +833,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ui/elementinfopartwidget.h
${QET_DIR}/sources/ui/elementinfowidget.cpp
${QET_DIR}/sources/ui/elementinfowidget.h
${QET_DIR}/sources/ui/multielementinfowidget.cpp
${QET_DIR}/sources/ui/multielementinfowidget.h
${QET_DIR}/sources/ui/terminalnumberingdialog.cpp
${QET_DIR}/sources/ui/terminalnumberingdialog.h
${QET_DIR}/sources/ui/cablenumberingdialog.cpp
@@ -940,7 +955,10 @@ set(QET_SRC_FILES
${QET_DIR}/sources/utils/qetsettings.cpp
${QET_DIR}/sources/utils/qetsettings.h
${QET_DIR}/sources/utils/qetutils.cpp
${QET_DIR}/sources/utils/previousversions.cpp
${QET_DIR}/sources/utils/previousversions.h
${QET_DIR}/sources/utils/qetutils.h
${QET_DIR}/sources/utils/syncfolder.h
${QET_DIR}/sources/materiallist/materialentrydialog.cpp
${QET_DIR}/sources/materiallist/materialentrydialog.h
@@ -1107,4 +1125,5 @@ set(TS_FILES
${QET_DIR}/lang/qet_tr.ts
${QET_DIR}/lang/qet_uk.ts
${QET_DIR}/lang/qet_zh.ts
${QET_DIR}/lang/qet_zh_TW.ts
)
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- Paint brush handle -->
<path d="M19.7 4.3a2.5 2.5 0 0 0-3.5 0l-7.7 7.7a3.5 3.5 0 0 0-.9 1.6l-.6 2.4 2.4-.6a3.5 3.5 0 0 0 1.6-.9l7.7-7.7a2.5 2.5 0 0 0 0-3.5z"
fill="#f39c12" stroke="#d68910" stroke-width="0.8" stroke-linejoin="round"/>
<!-- Ferrule (metal band) -->
<path d="M14.5 9.5 l-2-2 l1.2-1.2 l2 2 z" fill="#bdc3c7"/>
<!-- Brush tip / bristles with active paint -->
<path d="M7 16c-1.5.5-3.5 1.5-4 4 1.5 0 3.5-.5 4.5-1.5s1-2.5-.5-2.5z"
fill="#27ae60" stroke="#1e8449" stroke-width="0.5"/>
<!-- Paint stroke / apply check badge in bottom-right -->
<circle cx="17.5" cy="17.5" r="5" fill="#27ae60" stroke="#ffffff" stroke-width="1.2"/>
<polyline points="15.2,17.5 16.8,19.2 20,15.8" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 916 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- Paint brush handle -->
<path d="M19.7 4.3a2.5 2.5 0 0 0-3.5 0l-7.7 7.7a3.5 3.5 0 0 0-.9 1.6l-.6 2.4 2.4-.6a3.5 3.5 0 0 0 1.6-.9l7.7-7.7a2.5 2.5 0 0 0 0-3.5z"
fill="#f39c12" stroke="#d68910" stroke-width="0.8" stroke-linejoin="round"/>
<!-- Ferrule (metal band) -->
<path d="M14.5 9.5 l-2-2 l1.2-1.2 l2 2 z" fill="#bdc3c7"/>
<!-- Brush tip / bristles -->
<path d="M7 16c-1.5.5-3.5 1.5-4 4 1.5 0 3.5-.5 4.5-1.5s1-2.5-.5-2.5z"
fill="#2980b9" stroke="#1f618d" stroke-width="0.5"/>
<!-- Copy badge: two overlapping small rectangles in bottom-right -->
<rect x="13" y="13" width="7" height="8" rx="1.2" fill="#ffffff" stroke="#2c3e50" stroke-width="1.2"/>
<rect x="16" y="16" width="7" height="8" rx="1.2" fill="#3498db" stroke="#2c3e50" stroke-width="1.2"/>
<line x1="18" y1="19" x2="21" y2="19" stroke="#ffffff" stroke-width="1" stroke-linecap="round"/>
<line x1="18" y1="21" x2="21" y2="21" stroke="#ffffff" stroke-width="1" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+2211 -1475
View File
File diff suppressed because it is too large Load Diff
+3835 -2911
View File
File diff suppressed because it is too large Load Diff
+4521 -3596
View File
File diff suppressed because it is too large Load Diff
+2192 -1469
View File
File diff suppressed because it is too large Load Diff
+2680 -1790
View File
File diff suppressed because it is too large Load Diff
+4855 -3924
View File
File diff suppressed because it is too large Load Diff
+2711 -1802
View File
File diff suppressed because it is too large Load Diff
+3447 -2539
View File
File diff suppressed because it is too large Load Diff
+2163 -1477
View File
File diff suppressed because it is too large Load Diff
+2284 -1396
View File
File diff suppressed because it is too large Load Diff
+2198 -1501
View File
File diff suppressed because it is too large Load Diff
+3962 -3093
View File
File diff suppressed because it is too large Load Diff
+5603 -4635
View File
File diff suppressed because it is too large Load Diff
+4993 -4055
View File
File diff suppressed because it is too large Load Diff
+4491 -3513
View File
File diff suppressed because it is too large Load Diff
+2182 -1473
View File
File diff suppressed because it is too large Load Diff
+2192 -1469
View File
File diff suppressed because it is too large Load Diff
+2185 -1462
View File
File diff suppressed because it is too large Load Diff
+4017 -3128
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -9936,6 +9936,62 @@ Available options:
</context>
<context>
<name>QETDiagramEditor</name>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="497"/>
<source>Place symbol texts</source>
<translation>Symbolteksten plaatsen</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="498"/>
<source>Place the label and the notes of the selected symbols as one block beside them</source>
<comment>status bar tip</comment>
<translation>Plaatst het label en de opmerkingen van de geselecteerde symbolen als één blok ernaast</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="507"/>
<source>Above, left</source>
<translation>Boven, links</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="508"/>
<source>Above</source>
<translation>Boven</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="509"/>
<source>Above, right</source>
<translation>Boven, rechts</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="510"/>
<source>Left</source>
<translation>Links</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="511"/>
<source>On the symbol</source>
<translation>Op het symbool</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="512"/>
<source>Right</source>
<translation>Rechts</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="513"/>
<source>Below, left</source>
<translation>Onder, links</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="514"/>
<source>Below</source>
<translation>Onder</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="515"/>
<source>Below, right</source>
<translation>Onder, rechts</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="169"/>
<location filename="../sources/qetdiagrameditor.cpp" line="3709"/>
@@ -10263,6 +10319,16 @@ Available options:
<source>Display or hide the grid of sheet</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="595"/>
<source>Folio-wide crosshair</source>
<translation>Dradenkruis over het hele blad</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="597"/>
<source>Show two lines across the whole folio under the mouse, to see whether parts are in line</source>
<translation>Toont twee lijnen over het hele blad onder de muis, om te zien of onderdelen uitgelijnd zijn</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="567"/>
<source>Text grid</source>
@@ -21739,4 +21805,12 @@ The following variables can be used:
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SymbolTextPlacement</name>
<message>
<location filename="../sources/symboltextplacement.cpp" line="120"/>
<source>Place symbol texts</source>
<translation>Symbolteksten plaatsen</translation>
</message>
</context>
</TS>
+2163 -1477
View File
File diff suppressed because it is too large Load Diff
+2760 -1854
View File
File diff suppressed because it is too large Load Diff
+6703 -5743
View File
File diff suppressed because it is too large Load Diff
+2270 -1407
View File
File diff suppressed because it is too large Load Diff
+2167 -1470
View File
File diff suppressed because it is too large Load Diff
+2170 -1477
View File
File diff suppressed because it is too large Load Diff
+4151 -3274
View File
File diff suppressed because it is too large Load Diff
+9803 -8706
View File
File diff suppressed because it is too large Load Diff
+2178 -1478
View File
File diff suppressed because it is too large Load Diff
+2170 -1477
View File
File diff suppressed because it is too large Load Diff
+2192 -1469
View File
File diff suppressed because it is too large Load Diff
+5111 -4232
View File
File diff suppressed because it is too large Load Diff
+4633 -3642
View File
File diff suppressed because it is too large Load Diff
+3922 -3025
View File
File diff suppressed because it is too large Load Diff
+24748
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -32,11 +32,64 @@
<key>CFBundleTypeExtensions</key>
<array>
<string>qet</string>
<string>qetz</string>
</array>
<key>LSItemContentTypes</key>
<array>
<string>org.qelectrotech.project</string>
<string>org.qelectrotech.project-zip</string>
</array>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.qelectrotech.project</string>
<key>UTTypeDescription</key>
<string>QElectroTech project</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeIconFile</key>
<string>qet</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>qet</string>
</array>
<key>public.mime-type</key>
<string>application/x-qet-project</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.qelectrotech.project-zip</string>
<key>UTTypeDescription</key>
<string>QElectroTech zipped project</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeIconFile</key>
<string>qet</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>qetz</string>
</array>
<key>public.mime-type</key>
<string>application/x-qet-project+zip</string>
</dict>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>qelectrotech</string>
<key>CFBundleIconFile</key>
+1 -1
View File
@@ -6,7 +6,7 @@ Exec=qelectrotech %F
Icon=qelectrotech
Terminal=false
Type=Application
MimeType=application/x-qet-project;application/x-qet-element;application/x-qet-titleblock;
MimeType=application/x-qet-project;application/x-qet-project+zip;application/x-qet-element;application/x-qet-titleblock;
Categories=Graphics;
Keywords=Graphics;Science;Electricity;Engineering;
Comment=Edit electrical diagrams.
+6
View File
@@ -37,6 +37,12 @@
<summary xml:lang="tr">Elektrik şeması düzenleyici</summary>
<summary xml:lang="uk">Редактор електричних схем</summary>
<summary xml:lang="zh">电气图编辑器</summary>
<provides>
<mediatype>application/x-qet-project</mediatype>
<mediatype>application/x-qet-project+zip</mediatype>
<mediatype>application/x-qet-element</mediatype>
<mediatype>application/x-qet-titleblock</mediatype>
</provides>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.100.1-dev" date="2026"/>
+10
View File
@@ -34,6 +34,16 @@
<comment xml:lang="zh">QElectroTech 项目文件</comment>
<glob pattern="*.qet" />
</mime-type>
<mime-type type="application/x-qet-project+zip">
<alias type="application/x-qelectrotech-project+zip"/>
<sub-class-of type="application/zip"/>
<comment>QElectroTech zipped project file</comment>
<glob pattern="*.qetz" />
<magic priority="60">
<match type="string" offset="30" value="mimetypeapplication/x-qet-project+zip"/>
<match type="string" offset="30" value="mimetypeapplication/x-qelectrotech-project+zip"/>
</magic>
</mime-type>
<mime-type type="application/x-qet-element">
<sub-class-of type="text/xml"/>
<comment>QElectroTech element file</comment>
+7
View File
@@ -25,6 +25,13 @@ here read the model.
## Tools
Every tool takes a `.qet` or a `.qetz` (the same project, zipped). The
tools that read a project's XML read the `.qet` QElectroTech saves from a
`.qetz`, made once per version of the file with the configured
QElectroTech (`QET_BINARY`); the first read of a big `.qetz` takes as long
as QElectroTech needs to save it. The tools that launch QElectroTech hand
it the `.qetz` itself, and write one when the output path ends in `.qetz`.
| Tool | What it answers |
|---|---|
| `qet_project_info` | title, format version, folios with their uuids, element and conductor counts |
+149 -22
View File
@@ -56,6 +56,7 @@ workspace policy applies exactly as it does over stdio.
from __future__ import annotations
import atexit
import datetime
import json
import re
@@ -97,11 +98,80 @@ def _root(path: str) -> ET.Element:
if not p.is_file():
raise ValueError(f"no such file: {p}")
try:
return ET.parse(p).getroot()
return ET.parse(_as_qet(p)).getroot()
except ET.ParseError as exc:
raise ValueError(f"{p.name} is not parseable XML: {exc}") from exc
# --------------------------------------------------------------------------
# zipped projects (.qetz)
# --------------------------------------------------------------------------
# A .qetz is QElectroTech's zipped project: a database and one file per
# folio. The tools that read a project's XML read the .qet QElectroTech
# saves from it, so they read what QElectroTech reads, whatever the zipped
# format holds; one copy per version of the file (path, size, time).
# Only the newest few are kept, and the server removes them when it exits.
_QETZ_COPIES: dict = {}
_QETZ_COPIES_MAX = 8
def _forget_qetz_copy(key) -> None:
out = _QETZ_COPIES.pop(key, None)
if out is not None:
shutil.rmtree(out.parent, ignore_errors=True)
@atexit.register
def _forget_qetz_copies() -> None:
for key in list(_QETZ_COPIES):
_forget_qetz_copy(key)
def _is_qetz(path) -> bool:
return str(path).lower().endswith(".qetz")
def _as_qet(path) -> Path:
"""The file the XML tools read for `path`: itself, or for a .qetz the
.qet QElectroTech saves from it (made with the configured binary)."""
p = Path(path).expanduser()
if not _is_qetz(p):
return p
if not p.is_file():
raise ValueError(f"no such file: {p}")
st = p.stat()
key = (str(p.resolve()), st.st_size, st.st_mtime_ns)
cached = _QETZ_COPIES.get(key)
if cached is not None and cached.is_file():
_QETZ_COPIES[key] = _QETZ_COPIES.pop(key) # newest last
return cached
binary = resolve_binary()
if not binary:
raise ValueError(f"{p.name} is a zipped project (.qetz): reading it needs "
"QElectroTech; set QET_BINARY")
folder = Path(tempfile.mkdtemp(prefix="qet-mcp-qetz-"))
out = folder / (p.stem + ".qet")
# Saving as .qet builds every folio: allow a big project its time
result = _run_qet(str(binary), ["--resave", str(p), str(out)], timeout=900)
# A run that timed out or failed may have left part of a .qet behind
if not result.get("ok") or not out.is_file() or out.stat().st_size == 0:
shutil.rmtree(folder, ignore_errors=True)
if result.get("timed_out"):
detail = f"timed out after {result.get('timeout_s')} s"
else:
detail = (result.get("stderr") or result.get("stdout") or "").strip()[-400:]
raise ValueError(f"QElectroTech could not read {p.name}"
+ (f": {detail}" if detail else ""))
# An older version of the same file is not read again
for old in [k for k in _QETZ_COPIES if k[0] == key[0]]:
_forget_qetz_copy(old)
_QETZ_COPIES[key] = out
while len(_QETZ_COPIES) > _QETZ_COPIES_MAX:
_forget_qetz_copy(next(iter(_QETZ_COPIES)))
return out
def _element_info(el: ET.Element) -> dict:
"""The <elementInformations> bag, as a plain dict."""
out = {}
@@ -795,23 +865,35 @@ def _num(v) -> float:
def tool_scan(directory: str, tag: str = "conductor",
attribute: str = "cable", recursive: bool = True) -> dict:
"""Sweep every .qet in a directory, counting how many <tag> carry a
attribute: str = "cable", recursive: bool = True,
max_qetz: int = 20) -> dict:
"""Sweep every .qet and .qetz in a directory, counting how many <tag> carry a
non-empty `attribute`.
This is the corpus question: "3190 conductors, 0 cable values across
25 projects" is exactly one call to this tool.
Each .qetz is read by QElectroTech saving it as a .qet, which can take
minutes for a big project, so only the first `max_qetz` are read; the
others are listed as skipped.
"""
d = Path(directory).expanduser()
if not d.is_dir():
raise ValueError(f"not a directory: {d}")
files = sorted(d.rglob("*.qet") if recursive else d.glob("*.qet"))
files = sorted([f for pattern in ("*.qet", "*.qetz")
for f in (d.rglob(pattern) if recursive else d.glob(pattern))])
total = non_empty = 0
per_file, values, unreadable = [], {}, []
per_file, values, unreadable, skipped = [], {}, [], []
qetz_read = 0
for f in files:
if _is_qetz(f):
if qetz_read >= max_qetz:
skipped.append(f.name)
continue
qetz_read += 1
try:
root = ET.parse(f).getroot()
except ET.ParseError as exc:
root = ET.parse(_as_qet(f)).getroot()
except (ET.ParseError, ValueError) as exc:
unreadable.append({"file": f.name, "error": str(exc)})
continue
n = ne = 0
@@ -826,6 +908,9 @@ def tool_scan(directory: str, tag: str = "conductor",
per_file.append({"file": f.name, tag: n, "non_empty": ne})
return {
"directory": str(d), "files": len(files), "unreadable": unreadable,
**({"skipped_qetz": skipped,
"note": f"only the first {max_qetz} .qetz were read (max_qetz)"}
if skipped else {}),
"tag": tag, "attribute": attribute,
"total": total, "non_empty": non_empty,
"distinct_values": sorted(values.items(), key=lambda kv: -kv[1])[:25],
@@ -987,6 +1072,23 @@ def _user_collections() -> dict:
return found
def _keep_tail(text: str, tail: int) -> str:
"""The last `tail` characters of a run's output, but every line an edit
script reported through its marker kept whole, wherever it stands.
A large qet_edit logs far more than `tail` characters; cut from the
front, it lost the capability report the script makes first, and the
tool then said the binary had never run the script."""
if len(text) <= tail:
return text
marked = [ln for ln in text.splitlines() if "QETEDIT " in ln]
rest = text[-tail:]
if not marked:
return rest
kept = set(rest.splitlines())
return "\n".join([ln for ln in marked if ln not in kept] + [rest])
def _run_qet(binary: str, args: list[str], timeout: int = 180,
elements_dir: str | None = None,
script: str | None = None, tail: int = 4000,
@@ -1065,7 +1167,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180,
"hint": "a modal dialog during load will hang a headless "
"run; check the project's format version"}
result = {"ok": p.returncode == 0, "exit_code": p.returncode,
"stdout": p.stdout[-tail:], "stderr": p.stderr[-tail:]}
"stdout": _keep_tail(p.stdout, tail), "stderr": _keep_tail(p.stderr, tail)}
# QElectroTech refuses --run when scripting is switched off, which
# it is by default. Its own message is clear but French, and it
# names a settings dialog that nobody driving this server is
@@ -1496,6 +1598,13 @@ OPS = {
"remove_folio": ("removeFolio", [("folio", "folio")]),
"set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"),
("value", "str")]),
# A cable drawn across conductors, as the cable tool draws it: every
# conductor the line crosses gets the next core and its colour.
"add_cable": ("addCable", [("folio", "folio"), ("designation", "str"),
("type", "str"), ("core_colors", "list"),
("length", "str"),
("x1", "num"), ("y1", "num"),
("x2", "num"), ("y2", "num")]),
"add_terminal_strip": ("addTerminalStrip", [("installation", "str"), ("location", "str"),
("name", "str")]),
"remove_terminal_strip": ("removeTerminalStrip", [("strip", "folio")]),
@@ -2687,22 +2796,34 @@ def tool_element_search(directory: str, query: str = "", link_type: str | None =
# either would bury the real findings.
# - "simple" elements are checked for duplicates too but only as a warning:
# industrial.qet reuses V1..V6 across folios on purpose.
#
# A label names one device per place: the same "-F1" in two distribution
# boards (+UV1-F1, +UV2-F1) is two devices, as in IEC 81346 / EPLAN. The
# place is the element's own plant/location, else its folio's -- what the
# label formulas %M/%LM show. element_nomenclature_view carries only the
# element's, so these two checks read the tables.
_LABELS_PER_PLACE_SQL = (
"SELECT ei.label AS label, "
"COALESCE(NULLIF(ei.plant,''), di.plant, '') AS plant, "
"COALESCE(NULLIF(ei.location,''), di.locmach, '') AS location, COUNT(*) AS n "
"FROM element_info ei JOIN element e ON ei.element_uuid = e.uuid "
"JOIN diagram_info di ON di.diagram_uuid = e.diagram_uuid "
"WHERE COALESCE(ei.label,'') <> '' AND e.type = '{type}' "
"AND COALESCE(LOWER(TRIM(ei.exclude_from_bom)), '') NOT IN ('true', '1', 'yes', 'on') "
"GROUP BY ei.label, 2, 3 HAVING n > 1 ORDER BY n DESC, ei.label")
CHECKS = {
"duplicate_master_labels": {
"severity": "error",
"note": "Two master elements with the same label are ambiguous in every "
"report that keys on it (BOM, cross-references, wiring list).",
"sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view "
"WHERE COALESCE(label,'') <> '' AND element_type = 'master' "
"GROUP BY label HAVING n > 1 ORDER BY n DESC, label",
"sql": _LABELS_PER_PLACE_SQL.format(type="master"),
},
"duplicate_simple_labels": {
"severity": "warning",
"note": "Legitimate when a label is reused on purpose across folios "
"(industrial.qet does); worth a look otherwise.",
"sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view "
"WHERE COALESCE(label,'') <> '' AND element_type = 'simple' "
"GROUP BY label HAVING n > 1 ORDER BY n DESC, label",
"sql": _LABELS_PER_PLACE_SQL.format(type="simple"),
},
"unlabelled_masters": {
"severity": "warning",
@@ -3919,8 +4040,10 @@ def scripts_dir() -> Path:
SERVER_INSTRUCTIONS = (
"This server works with QElectroTech, a free editor for electrical "
"diagrams. A project (.qet) holds folios (sheets) of symbols "
"(elements) joined by wires (conductors). Folio indexes count from 0.\n"
"diagrams. A project (.qet, or .qetz: the same project zipped) holds "
"folios (sheets) of symbols (elements) joined by wires (conductors). "
"Every tool takes either; a .qetz is read through QElectroTech, so the "
"first read of a big one takes a while. Folio indexes count from 0.\n"
"Start with qet_about: where QElectroTech keeps things, what is "
"switched on, the stored scripts, and house_style -- this "
"installation's own drawing conventions if the user set any. Follow "
@@ -4854,10 +4977,12 @@ TOOLS = [
},
{
"name": "qet_scan",
"description": "Sweep every .qet in a directory and count how many nodes of "
"a given tag carry a non-empty attribute, with the distinct "
"values found. For corpus questions such as how many "
"conductors in the shipped examples have a cable value.",
"description": "Sweep every .qet and .qetz in a directory and count how many "
"nodes of a given tag carry a non-empty attribute, with the "
"distinct values found. For corpus questions such as how many "
"conductors in the shipped examples have a cable value. Each "
".qetz is read through QElectroTech, which can take minutes "
"for a big project: only the first max_qetz are read.",
"inputSchema": {
"type": "object",
"properties": {
@@ -4865,12 +4990,14 @@ TOOLS = [
"tag": {"type": "string", "default": "conductor"},
"attribute": {"type": "string", "default": "cable"},
"recursive": {"type": "boolean", "default": True},
"max_qetz": {"type": "integer", "default": 20},
},
"required": ["directory"],
},
"handler": lambda a: tool_scan(a["directory"], a.get("tag", "conductor"),
a.get("attribute", "cable"),
a.get("recursive", True)),
a.get("recursive", True),
a.get("max_qetz", 20)),
},
{
"name": "qet_element_info",
@@ -5051,7 +5178,7 @@ TOOLS = [
"it to one element AFTER it is placed (add_element does not number "
"what it places); slaves and reports are refused, since they take "
"their label from their master. "
"Terminal strips: add_terminal_strip returns an index (name "
"Cables: add_cable (folio, designation -- empty for the next free name --, type, core_colors, length, x1, y1, x2, y2) draws a new cable's trunk line across the conductors between the two points, as the cable tool does: each crossed conductor, in order along the line, gets the next core and the colour core_colors lists at that place; a conductor another cable already holds makes the op fail. Returns the cable's uuid. Terminal strips: add_terminal_strip returns an index (name "
"it \"$id\"); add_to_strip puts a terminal-type element on "
"it, and refuses any other kind. group_terminals/bridge_terminals "
"take \"indices\" (at least two) into that strip's real-terminal "
+249
View File
@@ -437,6 +437,9 @@ class EditValidation(unittest.TestCase):
"set_element_text": el + [{"op": "set_element_text", "folio": "$f", "element": "$e", "index": 0,
"property": "x", "value": "5"}],
"delete_element_text": el + [{"op": "delete_element_text", "folio": "$f", "element": "$e", "index": 0}],
"add_cable": [f, {"op": "add_cable", "id": "k", "folio": "$f", "designation": "-W1",
"type": "NYM-J 3x1,5", "core_colors": ["BN", "BU", "GNYE"],
"length": "2 m", "x1": 0, "y1": 0, "x2": 100, "y2": 0}],
"add_terminal_strip": [{"op": "add_terminal_strip", "installation": "a", "location": "b", "name": "c"}],
"remove_terminal_strip": [{"op": "remove_terminal_strip", "strip": 0}],
"add_to_strip": el + [{"op": "add_to_strip", "strip": 0, "folio": "$f", "element": "$e"}],
@@ -4534,12 +4537,28 @@ class Integration(unittest.TestCase):
for i in range(4)],
{"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0},
{"op": "add_conductor", "folio": "$f", "from": "$e2", "from_terminal": 0, "to": "$e3", "to_terminal": 0},
{"op": "set_conductor", "folio": "$f", "element": "$e2", "terminal": 0, "property": "num", "value": "W2"},
{"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0},
], self.sb.p("conflict.qet"), elements_dir=ELEMENTS, timeout=60)
self.assertFalse(r.get("timed_out"), "the call hung on a modal dialog")
self.assertFalse(r["ok"])
self.assertEqual(r["operations"][-1]["result"], False)
def test_joining_two_agreeing_potentials_asks_nothing(self):
"""Two potentials with the same properties leave nothing to choose:
the wire joining them is drawn, headless as well."""
base = self.sb.new()
r = self.ok(self.sb.edit(base, [
{"op": "add_folio", "id": "f"},
*[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100}
for i in range(4)],
{"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0},
{"op": "set_conductor", "folio": "$f", "element": "$e0", "terminal": 0, "property": "num", "value": "N"},
{"op": "add_conductor", "folio": "$f", "from": "$e2", "from_terminal": 0, "to": "$e3", "to_terminal": 0},
{"op": "set_conductor", "folio": "$f", "element": "$e2", "terminal": 0, "property": "num", "value": "N"},
{"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}]))
self.assertEqual([c["num"] for c in m.tool_conductors(r["output"])["conductors"]], ["N"] * 3)
# ---- edit: conductor properties, potentials ----
def test_conductor_property_applies_to_the_whole_potential(self):
@@ -4555,6 +4574,53 @@ class Integration(unittest.TestCase):
rows = m.tool_conductors(r["output"])["conductors"]
self.assertEqual([c["num"] for c in rows], ["W7", "W7"])
def test_new_conductor_inherits_a_potential_through_terminal_and_report(self):
"""A wire drawn to the free side of a terminal element, or to a
report linked to a wired one, is on that potential already: it
takes the potential's number and line style, not the folio's
defaults (ConductorCreator::existingPotential)."""
base = self.sb.new()
ops = [{"op": "add_folio", "id": "f"}, {"op": "add_folio", "id": "g"},
{"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 100, "y": 100},
{"op": "add_element", "id": "t", "folio": "$f", "path": TERMINAL, "x": 300, "y": 300},
{"op": "add_element", "id": "b", "folio": "$f", "path": COIL, "x": 500, "y": 100},
{"op": "add_element", "id": "go", "folio": "$f", "path": NEXT_REPORT, "x": 700, "y": 100},
{"op": "add_element", "id": "in", "folio": "$g", "path": PREVIOUS_REPORT, "x": 100, "y": 100},
{"op": "add_element", "id": "c", "folio": "$g", "path": COIL, "x": 300, "y": 100},
{"op": "link_elements", "folio": "$f", "element": "$go", "to_folio": "$g", "to": "$in"},
{"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 0, "to": "$t", "to_terminal": 0},
{"op": "set_conductor", "folio": "$f", "element": "$a", "terminal": 0, "property": "num", "value": "W7"},
{"op": "set_conductor", "folio": "$f", "element": "$a", "terminal": 0, "property": "style", "value": "dashed"},
# the terminal's other side, then on through the report
{"op": "add_conductor", "folio": "$f", "from": "$t", "from_terminal": 1, "to": "$b", "to_terminal": 0},
{"op": "add_conductor", "folio": "$f", "from": "$b", "from_terminal": 0, "to": "$go", "to_terminal": 0},
{"op": "add_conductor", "folio": "$g", "from": "$in", "from_terminal": 0, "to": "$c", "to_terminal": 0}]
r = self.ok(self.sb.edit(base, ops))
rows = m.tool_conductors(r["output"])["conductors"]
self.assertEqual([c["num"] for c in rows], ["W7"] * 4)
styles = {c["value"] for c in m.tool_conductors(r["output"], attribute="style")["conductors"]}
self.assertEqual(len(styles), 1, styles)
self.assertIn("dash", styles.pop())
def test_nomenclature_takes_the_place_of_the_folio(self):
"""An element without its own plant / location is where its folio
is: element_nomenclature_view (BOM, nomenclature tables) shows the
folio's =plant +location; an element's own value still wins."""
base = self.sb.new()
ops = [{"op": "add_folio", "id": "f"},
{"op": "set_folio", "folio": "$f", "property": "plant", "value": "HAUS"},
{"op": "set_folio", "folio": "$f", "property": "locmach", "value": "UV1"},
{"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 100, "y": 100},
{"op": "set_label", "folio": "$f", "element": "$a", "label": "KA1"},
{"op": "add_element", "id": "b", "folio": "$f", "path": COIL, "x": 300, "y": 100},
{"op": "set_label", "folio": "$f", "element": "$b", "label": "KA2"},
{"op": "set_info", "folio": "$f", "element": "$b", "key": "location", "value": "UV2"}]
r = self.ok(self.sb.edit(base, ops))
q = m.tool_query(BINARY, r["output"], "SELECT label, plant, location FROM "
"element_nomenclature_view ORDER BY label")
self.assertEqual(q["rows"], [{"label": "KA1", "plant": "HAUS", "location": "UV1"},
{"label": "KA2", "plant": "HAUS", "location": "UV2"}])
# ---- conductor manual routing ----
def test_move_conductor_segment_reroutes_the_path(self):
@@ -5828,6 +5894,22 @@ class Integration(unittest.TestCase):
line = [l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("DB ")][0]
self.assertEqual(json.loads(line[3:]), [{"wire_number": "W1"}])
def test_duplicate_labels_are_counted_per_location(self):
"""-F1 in two distribution boards is two devices: the folio's
location (+UV1, +UV2) is part of the name, as %LM shows it. KM1 on
+UV1 and +UV2 is fine, a second KM1 on +UV2 is the finding."""
base = self.sb.new(folios=3)
ops = []
for folio, ort in enumerate(("UV1", "UV2", "UV2")):
ops += [{"op": "set_folio", "folio": folio, "property": "locmach", "value": ort},
{"op": "add_element", "id": f"k{folio}", "folio": folio, "path": COIL,
"x": 100, "y": 100},
{"op": "set_label", "folio": folio, "element": f"$k{folio}", "label": "KM1"}]
r = self.ok(self.sb.edit(base, ops))
found = {f["check"]: f for f in m.tool_check(BINARY, r["output"])["findings"]}
self.assertEqual(found["duplicate_master_labels"]["rows"],
[{"label": "KM1", "plant": "", "location": "UV2", "n": 2}])
def test_check_fires_on_a_project_with_known_defects(self):
"""Newly placed elements store NULL where a loaded file stores '', and
`col = ''` matches only the second: the first version of these checks
@@ -6476,6 +6558,173 @@ class CorpusIntegration(unittest.TestCase):
sb.close()
class ZippedProjectWithoutBinary(unittest.TestCase):
def test_reading_a_qetz_without_qelectrotech_says_what_to_set(self):
"""A .qetz is read through QElectroTech (m._as_qet()): with none
configured, the answer names QET_BINARY rather than failing to
parse a zip as XML."""
with tempfile.TemporaryDirectory() as tmp:
z = Path(tmp) / "p.qetz"
z.write_bytes(b"PK\x03\x04 not read")
with mock.patch.object(m, "resolve_binary", return_value=None):
with self.assertRaises(ValueError) as caught:
m.tool_project_info(str(z))
self.assertIn("QET_BINARY", str(caught.exception))
QET = '<project><diagram><conductors><conductor cable="W1"/></conductors></diagram></project>'
def fake_resave(self, ok=True, timed_out=False):
"""A _run_qet that writes a small .qet where --resave was asked to"""
def run(binary, args, timeout=180, **kw):
Path(args[2]).write_text(self.QET)
if timed_out:
return {"ok": False, "timed_out": True, "timeout_s": timeout}
return {"ok": ok, "exit_code": 0 if ok else 1, "stderr": "", "stdout": ""}
return run
def qetz_files(self, tmp, n):
files = []
for i in range(n):
z = Path(tmp) / f"p{i}.qetz"
z.write_bytes(b"PK")
files.append(z)
return files
def test_a_failed_or_timed_out_conversion_is_not_kept(self):
"""A .qet left behind by a run that did not finish is not read"""
for kind in ({"ok": False}, {"timed_out": True}):
with self.subTest(**kind), tempfile.TemporaryDirectory() as tmp:
m._forget_qetz_copies()
z, = self.qetz_files(tmp, 1)
with mock.patch.object(m, "resolve_binary", return_value="qet"), \
mock.patch.object(m, "_run_qet", side_effect=self.fake_resave(**kind)):
with self.assertRaises(ValueError):
m._as_qet(z)
self.assertEqual(m._QETZ_COPIES, {})
def test_only_the_newest_conversions_are_kept_on_disk(self):
with tempfile.TemporaryDirectory() as tmp:
m._forget_qetz_copies()
files = self.qetz_files(tmp, m._QETZ_COPIES_MAX + 2)
with mock.patch.object(m, "resolve_binary", return_value="qet"), \
mock.patch.object(m, "_run_qet", side_effect=self.fake_resave()):
copies = [m._as_qet(z) for z in files]
self.assertEqual(len(m._QETZ_COPIES), m._QETZ_COPIES_MAX)
self.assertFalse(copies[0].parent.exists())
self.assertTrue(copies[-1].is_file())
# A new version of a file replaces its old copy
os.utime(files[-1], ns=(1, 1))
newer = m._as_qet(files[-1])
self.assertFalse(copies[-1].parent.exists())
m._forget_qetz_copies()
self.assertFalse(newer.parent.exists())
def test_scan_reads_at_most_max_qetz(self):
with tempfile.TemporaryDirectory() as tmp:
m._forget_qetz_copies()
self.qetz_files(tmp, 3)
with mock.patch.object(m, "resolve_binary", return_value="qet"), \
mock.patch.object(m, "_run_qet", side_effect=self.fake_resave()) as run:
scan = m.tool_scan(tmp, max_qetz=2)
m._forget_qetz_copies()
self.assertEqual(run.call_count, 2)
self.assertEqual((scan["total"], scan["non_empty"]), (2, 2))
self.assertEqual(scan["skipped_qetz"], ["p2.qetz"])
@needs_examples
class ZippedProjects(unittest.TestCase):
"""Every tool reads and writes QElectroTech's zipped project (.qetz) as
it does the .qet: the XML tools read the .qet QElectroTech saves from
it, the tools that launch QElectroTech hand it the .qetz."""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
root = Path(cls.tmp.name)
cls.env = mock.patch.dict(os.environ, {"QET_BINARY": BINARY, "QET_ENABLE_SCRIPTING": "1",
"QET_MCP_SCRIPTS_DIR": str(root / "scripts"),
"QET_MCP_INFO_FILE": str(root / "none.json")})
cls.env.start()
cls.qet, cls.qetz = root / "p.qet", root / "p.qetz"
source = str(Path(EXAMPLES) / "industrial.qet")
for out in (cls.qet, cls.qetz):
r = m._run_qet(BINARY, ["--resave", source, str(out)], timeout=600)
assert out.is_file(), r
assert cls.qetz.read_bytes()[:2] == b"PK"
@classmethod
def tearDownClass(cls):
cls.env.stop()
cls.tmp.cleanup()
def test_read_tools_answer_as_for_the_qet(self):
def strip(d):
return {k: v for k, v in d.items() if k not in ("path", "file")}
for tool in (m.tool_project_info, m.tool_elements, m.tool_conductors, m.tool_items):
self.assertEqual(strip(tool(str(self.qetz))), strip(tool(str(self.qet))), tool.__name__)
def test_diff_finds_nothing_between_the_qet_and_the_qetz(self):
d = m.tool_diff(str(self.qet), str(self.qetz))
self.assertTrue(d.get("identical") or not any(
v.get("added") or v.get("removed") or v.get("changed")
for v in d.values() if isinstance(v, dict)), d)
def test_scan_counts_a_qetz(self):
folder = Path(self.tmp.name) / "scan"
folder.mkdir(exist_ok=True)
for f in (self.qet, self.qetz):
shutil.copy2(f, folder / f.name)
scan = m.tool_scan(str(folder), "conductor", "cable", recursive=False)
self.assertEqual(scan["files"], 2)
self.assertEqual(scan["unreadable"], [])
self.assertEqual(scan["per_file"][0]["conductor"], scan["per_file"][1]["conductor"])
def test_a_qetz_is_converted_once_while_it_does_not_change(self):
m._QETZ_COPIES.clear()
real = m._run_qet
with mock.patch.object(m, "_run_qet", side_effect=real) as run:
m.tool_project_info(str(self.qetz))
m.tool_elements(str(self.qetz))
self.assertEqual(run.call_count, 1)
def test_query_reads_the_qetz_itself(self):
sql = "SELECT COUNT(*) AS n FROM element"
self.assertEqual(m.tool_query(BINARY, str(self.qetz), sql)["rows"],
m.tool_query(BINARY, str(self.qet), sql)["rows"])
def test_edit_writes_a_qetz(self):
first = m.tool_elements(str(self.qetz))["elements"][0]
out = Path(self.tmp.name) / "edited.qetz"
r = m.tool_edit(BINARY, str(self.qetz),
[{"op": "rotate_element", "folio": first["folio"] - 1,
"element": first["uuid"], "angle": 90}],
str(out), ELEMENTS or None)
self.assertTrue(r.get("ok"), r)
self.assertEqual(out.read_bytes()[:2], b"PK")
# The rotation is what the diff of the two .qetz finds
rotated = m.tool_diff(str(self.qetz), str(out))["elements"]["rotated"]
self.assertEqual([r["uuid"] for r in rotated], [first["uuid"]])
self.assertNotEqual(*rotated[0]["orientation"])
class TestKeepTail(unittest.TestCase):
"""A large edit logs more than the kept tail; the script's own report
lines must survive the cut, or qet_edit says the script never ran."""
def test_short_output_is_untouched(self):
self.assertEqual(m._keep_tail("a\nb", 100), "a\nb")
def test_marker_lines_survive_the_cut(self):
first = 'QETEDIT {"kind": "capabilities", "missing": []}'
text = first + "\n" + "x" * 1000 + "\n" + 'QETEDIT {"kind": "save", "result": true}'
kept = m._keep_tail(text, 100)
self.assertIn(first, kept)
self.assertIn('"kind": "save"', kept)
self.assertEqual(kept.count('"kind": "save"'), 1)
self.assertEqual(m._parse_script_output(kept).get("missing_methods"), [])
if __name__ == "__main__":
print(f"binary : {BINARY or '(none: integration tests skipped)'}")
print(f"elements : {ELEMENTS or '(none)'}")
+4
View File
@@ -69,6 +69,10 @@ set reg_file=qet_install_file_associations.reg
echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command]
echo @="\"%final_qet_exe%\" \"%%1\""
rem *.qetz (zipped project): the same file type as *.qet
echo [HKEY_CURRENT_USER\Software\Classes\.qetz]
echo @="qet_diagram_file"
rem association de fichier *.elmt
echo [HKEY_CURRENT_USER\Software\Classes\.elmt]
echo @="qet_element_file"
+2
View File
@@ -467,5 +467,7 @@
<file>licenses/QElectroTech.LICENSE</file>
<file>licenses/QET-Elements.NOTICE</file>
<file>licenses/QET-Elements.LICENSE</file>
<file>ico/format-copy.svg</file>
<file>ico/format-apply.svg</file>
</qresource>
</RCC>
@@ -526,6 +526,13 @@ void ElementsCollectionModel::removeProject(QETProject *project)
if (!m_project_list.contains(project))
return;
//A loading may be setting up this project's items on worker
//threads: let it end, and keep the items about to be deleted out of
//the list applied when it is finished.
m_future.waitForFinished();
for (ElementCollectionItem *eci : projectItems(project))
m_items_list_to_setUp.removeOne(eci);
int row = m_project_list.indexOf(project);
if (removeRows(row, 1, QModelIndex())) {
m_project_list.removeOne(project);
@@ -141,6 +141,11 @@ void ElementsCollectionWidget::addProject(QETProject *project)
}
void ElementsCollectionWidget::removeProject(QETProject *project) {
//Before the first load and during a load, the project is also
//waiting to be loaded, or in the model being loaded
m_waiting_project.removeAll(project);
if (m_new_model)
m_new_model->removeProject(project);
if (m_model)
m_model->removeProject(project);
}
@@ -19,6 +19,9 @@
#include "ui_propertieseditordockwidget.h"
#include "propertieseditorwidget.h"
#include <QMainWindow>
#include <QTimer>
/**
@brief PropertiesEditorDockWidget::PropertiesEditorDockWidget
Constructor
@@ -48,6 +51,9 @@ PropertiesEditorDockWidget::~PropertiesEditorDockWidget()
*/
void PropertiesEditorDockWidget::clear()
{
if (!m_editor_list.isEmpty())
rememberHeight();
foreach (PropertiesEditorWidget *editor, m_editor_list)
{
m_editor_list.removeOne(editor);
@@ -94,8 +100,11 @@ bool PropertiesEditorDockWidget::addEditor(PropertiesEditorWidget *editor,
if (!editor) return false;
if (m_editor_list.contains(editor)) return true;
const bool was_empty = m_editor_list.isEmpty();
ui -> m_main_vlayout -> insertWidget(index, editor);
m_editor_list << editor;
if (was_empty)
restoreHeight();
return true;
}
@@ -118,9 +127,84 @@ QList<PropertiesEditorWidget *> PropertiesEditorDockWidget::editors() const
*/
bool PropertiesEditorDockWidget::removeEditor(PropertiesEditorWidget *editor)
{
if (m_editor_list.size() == 1 && m_editor_list.first() == editor)
rememberHeight();
bool result = m_editor_list.removeOne(editor);
if (result)
ui -> m_main_vlayout -> removeWidget(editor);
return result;
}
/**
@brief PropertiesEditorDockWidget::rememberHeight
The dock shrinks to its title bar while it has no editor, and the
other docks of its area take the room. Keep the heights the docks
of the area have now, the heights the user gave them, to give them
back when an editor comes back.
*/
void PropertiesEditorDockWidget::rememberHeight()
{
// Still the shrunk height: the last editor came and went
// before the heights were given back
if (m_restore_pending || isFloating() || !isVisible())
return;
auto main_window = qobject_cast<QMainWindow *>(parentWidget());
if (!main_window)
return;
m_area_docks.clear();
m_area_heights.clear();
const Qt::DockWidgetArea area = main_window->dockWidgetArea(this);
for (QDockWidget *dock : main_window->findChildren<QDockWidget *>())
{
if (dock->parentWidget() == main_window && !dock->isFloating()
&& dock->isVisible()
&& main_window->dockWidgetArea(dock) == area)
{
m_area_docks << dock;
m_area_heights << dock->height();
}
}
}
/**
@brief PropertiesEditorDockWidget::restoreHeight
Give the docks of the area back the heights they had before this
dock shrank. Without this the area is split again from the docks'
size hints, and giving this dock alone its height back takes the
room only from its neighbours.
*/
void PropertiesEditorDockWidget::restoreHeight()
{
if (m_area_docks.isEmpty() || m_restore_pending)
return;
m_restore_pending = true;
// Once the layout has given the dock its maximum height back
QTimer::singleShot(0, this, [this]() {
m_restore_pending = false;
auto main_window = qobject_cast<QMainWindow *>(parentWidget());
if (!main_window || isFloating() || !isVisible()
|| m_editor_list.isEmpty())
return;
QList<QDockWidget *> docks;
QList<int> heights;
const Qt::DockWidgetArea area = main_window->dockWidgetArea(this);
for (int i = 0 ; i < m_area_docks.size() ; ++i)
{
QDockWidget *dock = m_area_docks.at(i);
// Closed, moved or deleted since
if (dock && !dock->isFloating() && dock->isVisible()
&& main_window->dockWidgetArea(dock) == area)
{
docks << dock;
heights << m_area_heights.at(i);
}
}
if (docks.contains(this))
main_window->resizeDocks(docks, heights, Qt::Vertical);
});
}
@@ -19,6 +19,7 @@
#define SELECTIONPROPERTIESDOCKWIDGET_H
#include <QDockWidget>
#include <QPointer>
class PropertiesEditorWidget;
@@ -45,7 +46,13 @@ class PropertiesEditorDockWidget : public QDockWidget
QList <PropertiesEditorWidget *> m_editor_list;
private:
void rememberHeight();
void restoreHeight();
Ui::PropertiesEditorDockWidget *ui;
QList<QPointer<QDockWidget>> m_area_docks;
QList<int> m_area_heights;
bool m_restore_pending = false;
};
#endif // SELECTIONPROPERTIESDOCKWIDGET_H
@@ -226,9 +226,12 @@ void SearchAndReplaceWorker::replaceConductor(QList<Conductor *> list)
project_->undoStack()->beginMacro(QObject::tr("Search / replace conductors properties."));
for (Conductor *c : list)
{
ConductorProperties cp = applyChange(c->properties(), m_conductor_properties);
//Captured before the pushes below: QPropertyUndoCommand applies
//on push, so reading it afterwards would give back @a cp.
const ConductorProperties old_properties = c->properties();
ConductorProperties cp = applyChange(old_properties, m_conductor_properties);
if (cp != c->properties())
if (cp != old_properties)
{
QSet <Conductor *> conductors_list = c->relatedPotentialConductors(true);
conductors_list << c;
@@ -239,6 +242,26 @@ void SearchAndReplaceWorker::replaceConductor(QList<Conductor *> list)
new_value.setValue(cp);
project_->undoStack()->push(new QPropertyUndoCommand(cc, "properties", old_value, new_value));
}
//The other side of a function separated terminal element
//only receives the voltage / protocol, and only when this
//replacement really changed it: the comparison is this
//replacement's own before / after (@a old_properties vs
//@a cp), never the far wire's own value, which would make
//every replacement overwrite it (@see
//Conductor::functionSeparatedConductors).
for (Conductor *cc : c->functionSeparatedConductors(true))
{
ConductorProperties separated_cp = cc->properties();
separated_cp.applyTensionProtocolChange(old_properties, cp);
if (separated_cp == cc->properties())
continue;
QVariant old_value, new_value;
old_value.setValue(cc->properties());
new_value.setValue(separated_cp);
project_->undoStack()->push(new QPropertyUndoCommand(cc, "properties", old_value, new_value));
}
}
}
project_->undoStack()->endMacro();
@@ -351,6 +374,25 @@ void SearchAndReplaceWorker::replaceAdvanced(
new_value.setValue(new_properties);
project_->undoStack()->push(new QPropertyUndoCommand(c, "properties", old_value, new_value));
}
//The other side of a function separated terminal element
//only receives the voltage / protocol, and only when
//this replacement really changed it: the comparison is
//this replacement's own before / after, never the far
//wire's own value (@see
//Conductor::functionSeparatedConductors).
for (Conductor *c : conductor->functionSeparatedConductors(true))
{
ConductorProperties separated_properties = c->properties();
separated_properties.applyTensionProtocolChange(old_properties, new_properties);
if (separated_properties == c->properties())
continue;
QVariant old_value, new_value;
old_value.setValue(c->properties());
new_value.setValue(separated_properties);
project_->undoStack()->push(new QPropertyUndoCommand(c, "properties", old_value, new_value));
}
}
}
}
@@ -307,7 +307,7 @@ void SearchAndReplaceWidget::fillItemsList()
m_indi_text_qtwi->sortChildren(0, Qt::AscendingOrder);
for (Conductor *c : dc.m_potential_conductors)
for (Conductor *c : dc.potentialConductors())
{
QTreeWidgetItem *qtwi = new QTreeWidgetItem(m_conductor_qtwi);
qtwi->setText(0, c->properties().text);
@@ -171,6 +171,32 @@ namespace
}
}
/**
* @brief TerminalStripLayoutPattern::drawStyleToString
* @return the string used to save @a style in the project xml (terminal strip item)
*/
QString TerminalStripLayoutPattern::drawStyleToString(DrawStyle style)
{
switch (style) {
case DrawStyle::RealisticPushIn : return QStringLiteral("realistic_push_in");
case DrawStyle::RealisticScrew : return QStringLiteral("realistic_screw");
default : return QStringLiteral("schematic");
}
}
/**
* @brief TerminalStripLayoutPattern::drawStyleFromString
* @return the style saved as @a string, or @a default_style if @a string is unknown or empty
* (item saved before the drawing styles : the schematic style is kept).
*/
TerminalStripLayoutPattern::DrawStyle TerminalStripLayoutPattern::drawStyleFromString(const QString &string, DrawStyle default_style)
{
if (string == QLatin1String("schematic")) return DrawStyle::Schematic;
if (string == QLatin1String("realistic_push_in") || string == QLatin1String("realistic")) return DrawStyle::RealisticPushIn;
if (string == QLatin1String("realistic_screw")) return DrawStyle::RealisticScrew;
return default_style;
}
/**
* @brief TerminalStripLayoutPattern::xmlTagName
* @return the tag name of the xml element used by toXml / fromXml
@@ -228,6 +254,11 @@ QDomElement TerminalStripLayoutPattern::toXml(QDomDocument &document) const
element.setAttribute(QStringLiteral("bridge_point_y_offset_%1").arg(i), m_bridge_point_y_offset.at(i));
}
element.setAttribute(QStringLiteral("realistic_px_per_mm"), m_realistic_px_per_mm);
element.setAttribute(QStringLiteral("realistic_length_mm"), m_realistic_length_mm);
element.setAttribute(QStringLiteral("realistic_end_stops"), m_realistic_end_stops ? 1 : 0);
element.setAttribute(QStringLiteral("realistic_group_marker"), m_realistic_group_marker ? 1 : 0);
return element;
}
@@ -290,4 +321,19 @@ void TerminalStripLayoutPattern::fromXml(const QDomElement &layout_element)
for (auto i = 0; i < m_bridge_point_y_offset.size(); ++i) {
m_bridge_point_y_offset[i] = realFromXml(layout_element, QStringLiteral("bridge_point_y_offset_%1").arg(i), m_bridge_point_y_offset.at(i));
}
const auto px_per_mm_ = realFromXml(layout_element, QStringLiteral("realistic_px_per_mm"), m_realistic_px_per_mm);
if (px_per_mm_ > 0) {
m_realistic_px_per_mm = px_per_mm_;
}
const auto length_mm_ = realFromXml(layout_element, QStringLiteral("realistic_length_mm"), m_realistic_length_mm);
if (length_mm_ > 0) {
m_realistic_length_mm = length_mm_;
}
if (layout_element.hasAttribute(QStringLiteral("realistic_end_stops"))) {
m_realistic_end_stops = layout_element.attribute(QStringLiteral("realistic_end_stops")).toInt() != 0;
}
if (layout_element.hasAttribute(QStringLiteral("realistic_group_marker"))) {
m_realistic_group_marker = layout_element.attribute(QStringLiteral("realistic_group_marker")).toInt() != 0;
}
}
@@ -44,6 +44,37 @@ class TerminalStripLayoutPattern
public:
TerminalStripLayoutPattern();
/**
* @brief The DrawStyle enum
* How the terminals of a strip are drawn.
* The style is chosen for each terminal strip item, when the item is
* added to a folio (see TerminalStripItem::setDrawStyle), the layout
* only hold the values used by the styles.
* Schematic : the classic drawing (header, xrefs, symbols, connections, cables).
* RealisticPushIn : the strip seen from above, at scale, like the mounting
* drawing of a cabinet : end stops, group marker, push-in wire entries.
* RealisticScrew : same as RealisticPushIn, with screw clamps.
* The schematic style use most of the values of this class,
* the realistic styles use the values prefixed by m_realistic_.
*/
enum class DrawStyle {
Schematic = 0,
RealisticPushIn = 1,
RealisticScrew = 2
};
static QString drawStyleToString(DrawStyle style);
static DrawStyle drawStyleFromString(const QString &string, DrawStyle default_style);
//Realistic style
//Scale of the drawing : number of pixels for one millimeter
qreal m_realistic_px_per_mm{4.0};
//Length (mm) of a terminal along the wires, from the top to the bottom
qreal m_realistic_length_mm{60.0};
//Draw an end stop at each end of the strip
bool m_realistic_end_stops{true};
//Draw a group marker carrier with the name of the strip, after the first end stop
bool m_realistic_group_marker{true};
//Header of terminal strip
QRectF m_header_rect{0,30,50,146};
Qt::Orientation m_header_text_orientation{Qt::Horizontal};
@@ -0,0 +1,800 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "realisticterminalstripdrawer.h"
#include "terminalstripdrawerhelpers.h"
#include "../terminalcatalog.h"
#include <QFontMetricsF>
#include <QHash>
#include <QPainter>
#include <QPainterPath>
#include <QPair>
#include <QScopedPointer>
#include <algorithm>
namespace TerminalStripDrawer {
namespace Realistic {
namespace {
//Width (mm) of the accessories. Values of usual products, enough for a drawing.
constexpr qreal end_stop_width_mm{8.0};
constexpr qreal group_marker_width_mm{11.0};
constexpr qreal separator_width_mm{1.5};
//Biggest radius of a wire entry (mm), for the wide terminals
constexpr qreal max_entry_radius_mm{1.9};
//Vertical layout of a terminal, in fraction of its length (top = 0, bottom = 1).
//The wire entries are in [entries_top, band_top] and [band_bottom, 1 - entries_top],
//the middle band (marker tag, bridge channels) is in [band_top, band_bottom].
constexpr qreal entries_top{0.04};
constexpr qreal band_top{0.40};
constexpr qreal band_bottom{0.60};
constexpr qreal channels_top{0.405};
constexpr qreal channels_bottom{0.445};
//Colors of the accessories, close to the real products
const QColor end_stop_color{232, 228, 220};
const QColor end_stop_grip_color{35, 35, 35};
const QColor group_marker_color{168, 168, 168};
const QColor group_marker_text_color{200, 0, 0};
const QColor band_color{85, 85, 85};
const QColor channel_color{55, 55, 55};
const QColor bridge_color{200, 30, 30};
const QColor separator_color{196, 196, 198};
const QColor fuse_holder_color{38, 38, 40};
const QColor knife_color{240, 120, 0};
const QColor led_color{220, 20, 20};
/**
* @brief The PhysicalGeometry struct
* What is needed to draw a physical terminal.
*/
struct PhysicalGeometry
{
qreal width_mm{0};
bool separator_after{false}; //A separator plate is mounted after this terminal
QVector<QSharedPointer<AbstractRealTerminalInterface>> reals;
//Index of the physical terminal in the strip, and of each real terminal
//in the physical terminal : the same indexes than the schematic style,
//used to find the element of a clicked terminal.
int physical_index{-1};
QVector<int> real_indexes;
};
/**
* @return the physical terminals of @a strip with their width, in the drawing order.
* The width of a physical terminal is the biggest width of its real terminals,
* or the reference width if no width is known.
*/
QVector<PhysicalGeometry> geometry(const AbstractTerminalStripInterface *strip)
{
QVector<PhysicalGeometry> geometry_;
if (!strip) {
return geometry_;
}
int physical_index_{-1};
for (const auto &physical_t : strip->physicalTerminal())
{
++physical_index_;
if (!physical_t) {
continue;
}
PhysicalGeometry g_;
g_.physical_index = physical_index_;
int real_index_{-1};
for (const auto &real_t : physical_t->realTerminals())
{
++real_index_;
if (!real_t) {
continue;
}
g_.reals.append(real_t);
g_.real_indexes.append(real_index_);
g_.width_mm = std::max(g_.width_mm, real_t->widthMm());
g_.separator_after = g_.separator_after || real_t->hasSeparatorAfter();
if (g_.reals.size() == 4) { //Same limit than the schematic style
break;
}
}
if (g_.reals.isEmpty()) {
continue;
}
if (g_.width_mm <= 0) {
g_.width_mm = Helpers::referenceTerminalWidthMm();
}
geometry_.append(g_);
}
return geometry_;
}
qreal endStopWidthMm(const TerminalStripLayoutPattern &pattern) {
if (!pattern.m_realistic_end_stops) {
return 0;
}
return end_stop_width_mm;
}
qreal groupMarkerWidthMm(const TerminalStripLayoutPattern &pattern) {
return pattern.m_realistic_group_marker ? group_marker_width_mm : 0;
}
/**
* @brief fitFont
* Reduce the pixel size of the font of @a painter so that @a text fit in
* @a length (along the text) and @a thickness (across the text).
* The font is never enlarged.
*/
void fitFont(QPainter *painter, const QString &text, qreal length, qreal thickness)
{
auto font_{painter->font()};
qreal size_{font_.pixelSize() > 0 ? qreal(font_.pixelSize()) : font_.pointSizeF()};
const QFontMetricsF metrics_{font_};
qreal factor_{1};
if (metrics_.horizontalAdvance(text) > length && length > 0) {
factor_ = std::min(factor_, length / metrics_.horizontalAdvance(text));
}
if (metrics_.height() > thickness && thickness > 0) {
factor_ = std::min(factor_, thickness / metrics_.height());
}
if (factor_ < 1)
{
size_ = std::max<qreal>(2.0, size_ * factor_);
if (font_.pixelSize() > 0) {
font_.setPixelSize(std::max(1, static_cast<int>(size_)));
} else {
font_.setPointSizeF(size_);
}
painter->setFont(font_);
}
}
/**
* @brief drawVerticalText
* Draw @a text from the bottom to the top, centered in @a rect.
* The font is reduced if needed.
*/
void drawVerticalText(QPainter *painter, const QRectF &rect, const QString &text, const QColor &color)
{
if (text.isEmpty() || rect.isEmpty()) {
return;
}
painter->save();
fitFont(painter, text, rect.height() - 2, rect.width() - 1);
painter->setPen(color);
painter->translate(rect.left(), rect.bottom());
painter->rotate(270);
painter->drawText(QRectF{0, 0, rect.height(), rect.width()},
Qt::AlignCenter, text);
painter->restore();
}
/**
* @brief drawGrip
* Draw a black ribbed grip (end of an end stop).
*/
void drawGrip(QPainter *painter, const QRectF &rect)
{
painter->save();
painter->setBrush(end_stop_grip_color);
painter->drawRect(rect);
painter->setPen(QPen(QColor(150, 150, 150), 0));
const qreal step_{std::max<qreal>(1.5, rect.height() / 5)};
for (qreal y = rect.top() + step_ ; y < rect.bottom() - 0.5 ; y += step_) {
painter->drawLine(QPointF{rect.left() + 1, y}, QPointF{rect.right() - 1, y});
}
painter->restore();
}
/**
* @brief drawEndStop
* Draw an end stop seen from above : a light body with a black grip at each end,
* the fixing screw, the key slot and the locking part.
* @param rect : the whole end stop
*/
void drawEndStop(QPainter *painter, const QRectF &rect)
{
painter->save();
painter->setBrush(end_stop_color);
painter->drawRect(rect);
const qreal w_{rect.width()};
const qreal h_{rect.height()};
const qreal cx_{rect.center().x()};
const qreal grip_h_{h_ * 0.08};
drawGrip(painter, QRectF{rect.left(), rect.top(), w_, grip_h_});
drawGrip(painter, QRectF{rect.left(), rect.bottom() - grip_h_, w_, grip_h_});
//Fixing screw
const qreal screw_r_{std::min(w_ * 0.3, h_ * 0.05)};
const QPointF screw_c_{cx_, rect.top() + h_ * 0.25};
painter->setBrush(QColor(60, 60, 60));
painter->drawEllipse(screw_c_, screw_r_, screw_r_);
painter->setBrush(QColor(20, 20, 20));
painter->drawEllipse(screw_c_, screw_r_ * 0.65, screw_r_ * 0.65);
//Key slot : a narrow slot with a wider middle
painter->setBrush(QColor(210, 205, 195));
const qreal slot_w_{w_ * 0.22};
painter->drawRoundedRect(QRectF{cx_ - slot_w_ / 2, rect.top() + h_ * 0.36, slot_w_, h_ * 0.26}, 1, 1);
painter->drawRect(QRectF{cx_ - slot_w_, rect.top() + h_ * 0.45, slot_w_ * 2, h_ * 0.06});
//Locking part
painter->setBrush(QColor(20, 20, 20));
const qreal lock_w_{w_ * 0.36};
painter->drawRect(QRectF{cx_ - lock_w_ / 2, rect.top() + h_ * 0.68, lock_w_, h_ * 0.07});
painter->drawEllipse(QPointF{cx_, rect.top() + h_ * 0.79}, lock_w_ * 0.35, lock_w_ * 0.35);
painter->restore();
}
/**
* @brief leverZone
* Vertical zone (in fraction of the length of the terminal) of the lever of a
* fuse terminal or of the knife of a disconnect terminal, centered on the middle
* band. For a terminal with one level, the lever goes from a wire entry to the other
* (a bit shorter with screw clamps, whose screw heads are nearer the middle),
* for a terminal with levels it stay on the middle band, between the wire entries.
*/
QPair<qreal, qreal> leverZone(int levels, bool screw)
{
if (levels > 1) {
return qMakePair(qreal(0.395), qreal(0.605));
}
return screw ? qMakePair(qreal(0.345), qreal(0.655))
: qMakePair(qreal(0.315), qreal(0.685));
}
/**
* @brief drawFuseHolder
* Draw the hinged fuse holder of a fuse terminal, seen from above : a black lever
* on the middle of the terminal with its hinge pin, a window on the fuse,
* a ribbed grip at the other end and, for a fuse with led, the led.
* The marker tag and the symbol are drawn after, on the lever.
* @param x : left of the terminal
* @param w : width of the terminal
* @param length : length of the terminal
*/
void drawFuseHolder(QPainter *painter, qreal x, qreal w, qreal length, int levels, bool screw, bool led)
{
const auto zone_{leverZone(levels, screw)};
const qreal top_{length * zone_.first};
const qreal bottom_{length * zone_.second};
const qreal inset_{w * 0.08};
const QRectF lever_{x + inset_, top_, w - 2 * inset_, bottom_ - top_};
const qreal cx_{x + w / 2};
painter->save();
painter->setPen(QPen(Qt::black, 0.5));
painter->setBrush(fuse_holder_color);
painter->drawRoundedRect(lever_, w * 0.12, w * 0.12);
//Hinge pin
const qreal pin_r_{w * 0.12};
painter->setPen(QPen(QColor(90, 90, 90), 0.4));
painter->setBrush(QColor(150, 150, 150));
painter->drawEllipse(QPointF{cx_, top_ + pin_r_ * 2}, pin_r_, pin_r_);
//Window on the fuse, under the hinge pin and above the marker tag,
//only when there is enough room
const qreal window_top_{std::max(length * 0.38, top_ + pin_r_ * 3.4)};
const qreal window_bottom_{length * 0.445};
if (levels == 1 && window_bottom_ - window_top_ > length * 0.03)
{
painter->setPen(QPen(QColor(110, 110, 110), 0.4));
painter->setBrush(QColor(205, 205, 200));
painter->drawRoundedRect(QRectF{cx_ - w * 0.25, window_top_, w * 0.5, window_bottom_ - window_top_}, 1, 1);
//The fuse in the window
painter->setPen(QPen(QColor(120, 90, 40), std::max<qreal>(0.5, w * 0.08)));
const qreal margin_{(window_bottom_ - window_top_) * 0.1};
painter->drawLine(QPointF{cx_, window_top_ + margin_}, QPointF{cx_, window_bottom_ - margin_});
}
//Ribbed grip at the other end
painter->setPen(QPen(QColor(110, 110, 110), 0.4));
const qreal grip_top_{bottom_ - (bottom_ - top_) * 0.13};
for (int i = 0 ; i < 3 ; ++i) {
const qreal y_{grip_top_ + i * (bottom_ - grip_top_) / 3};
painter->drawLine(QPointF{lever_.left() + w * 0.12, y_}, QPointF{lever_.right() - w * 0.12, y_});
}
//Led : lit when the fuse is blown. Between the type symbol
//(bottom of the middle band) and the grip, whatever the length of the lever.
const qreal symbol_bottom_{length * 0.595};
if (led && grip_top_ > symbol_bottom_)
{
const qreal led_r_{std::min({w * 0.13, length * 0.012, (grip_top_ - symbol_bottom_) * 0.45})};
painter->setPen(QPen(led_color.darker(160), 0.4));
painter->setBrush(led_color);
painter->drawEllipse(QPointF{cx_, (symbol_bottom_ + grip_top_) / 2}, led_r_, led_r_);
}
painter->restore();
}
/**
* @brief drawDisconnectKnife
* Draw the orange knife of a disconnect terminal, seen from above :
* a narrow lever on the middle of the terminal with its hinge and its grip.
*/
void drawDisconnectKnife(QPainter *painter, qreal x, qreal w, qreal length, int levels, bool screw)
{
const auto zone_{leverZone(levels, screw)};
const qreal top_{length * zone_.first};
const qreal bottom_{length * zone_.second};
const qreal cx_{x + w / 2};
const qreal knife_w_{w * 0.56};
painter->save();
painter->setPen(QPen(knife_color.darker(170), 0.5));
painter->setBrush(knife_color);
painter->drawRoundedRect(QRectF{cx_ - knife_w_ / 2, top_, knife_w_, bottom_ - top_},
knife_w_ * 0.3, knife_w_ * 0.3);
//Hinge
const qreal pin_r_{knife_w_ * 0.22};
painter->setBrush(knife_color.darker(150));
painter->drawEllipse(QPointF{cx_, top_ + pin_r_ * 2}, pin_r_, pin_r_);
//Grip
painter->setPen(QPen(knife_color.darker(190), 0.5));
const qreal grip_top_{bottom_ - (bottom_ - top_) * 0.12};
for (int i = 0 ; i < 3 ; ++i) {
const qreal y_{grip_top_ + i * (bottom_ - grip_top_) / 3};
painter->drawLine(QPointF{cx_ - knife_w_ * 0.3, y_}, QPointF{cx_ + knife_w_ * 0.3, y_});
}
painter->restore();
}
/**
* @brief drawTestSockets
* Draw the two test sockets of a test terminal : a brass ring with a black hole.
* For a terminal with one level they are between the wire entries and the middle band,
* for a terminal with levels (no room between the entries) they are in the middle band,
* in place of the bridge channels and of the type symbol.
*/
void drawTestSockets(QPainter *painter, qreal x, qreal w, qreal length, qreal s, int levels, bool screw, const QColor &fill)
{
const qreal r_{std::min({w * 0.22, 1.2 * s, length * 0.018})};
const qreal offset_{levels > 1 ? 0.425 : (screw ? 0.37 : 0.355)};
painter->save();
for (const qreal f_ : {offset_, 1 - offset_})
{
const QPointF c_{x + w / 2, length * f_};
painter->setPen(QPen(fill.darker(200), 0.4));
painter->setBrush(QColor(200, 170, 60)); //Brass
painter->drawEllipse(c_, r_, r_);
painter->setPen(Qt::NoPen);
painter->setBrush(Qt::black);
painter->drawEllipse(c_, r_ * 0.55, r_ * 0.55);
}
painter->restore();
}
/**
* @brief drawSeparator
* Draw a separator plate (partition plate) seen from above :
* a thin plate as long as the terminals, with rounded ends and a light edge.
*/
void drawSeparator(QPainter *painter, const QRectF &rect)
{
painter->save();
painter->setPen(QPen(separator_color.darker(170), 0.5));
painter->setBrush(separator_color);
const qreal r_{std::min(rect.width() / 2, qreal(2.0))};
painter->drawRoundedRect(rect, r_, r_);
//Light edge, the plate is a bit higher than the terminals
painter->setPen(QPen(separator_color.lighter(118), std::max<qreal>(0.5, rect.width() * 0.25)));
painter->drawLine(QPointF{rect.left() + rect.width() * 0.35, rect.top() + r_},
QPointF{rect.left() + rect.width() * 0.35, rect.bottom() - r_});
painter->restore();
}
/**
* @brief drawGroupMarker
* Draw the group marker carrier with the name of the strip on a white label.
*/
void drawGroupMarker(QPainter *painter, const QRectF &rect, const QString &text)
{
painter->save();
painter->setBrush(group_marker_color);
painter->drawRect(rect);
if (!text.isEmpty())
{
//White label : as long as the text, centered on the carrier
const qreal label_w_{rect.width() * 0.6};
fitFont(painter, text, rect.height() * 0.8, label_w_ - 2);
const qreal text_len_{QFontMetricsF{painter->font()}.horizontalAdvance(text)};
const qreal label_h_{std::min(rect.height() * 0.85, text_len_ + label_w_ * 0.6)};
const QRectF label_{rect.center().x() - label_w_ / 2,
rect.center().y() - label_h_ / 2,
label_w_, label_h_};
painter->setBrush(Qt::white);
painter->drawRect(label_);
drawVerticalText(painter, label_, text, group_marker_text_color);
}
painter->restore();
}
/**
* @brief drawWireEntry
* Draw one wire entry of a terminal.
* Push-in : a black hole.
* Screw : the head of the clamping screw, with the hole of the wire next to it.
* @param center : center of the entry
* @param width : width of the terminal
* @param row_h : height available for the entry
* @param max_r : biggest radius of the entry (a wide terminal don't have a huge hole)
* @param outer : -1 if the end of the terminal is above the entry (top half), +1 if it is under (bottom half)
* @param screw : true for a screw clamp
*/
void drawWireEntry(QPainter *painter, const QPointF &center, qreal width, qreal row_h,
qreal max_r, qreal outer, const QColor &fill, bool screw)
{
painter->save();
if (!screw)
{
const qreal r_{std::min({width * 0.32, row_h * 0.3, max_r})};
painter->setBrush(Qt::black);
painter->drawEllipse(center, r_, r_);
}
else
{
//Hole of the wire, toward the end of the terminal
const qreal hole_{std::min({width * 0.45, row_h * 0.22, max_r * 1.6})};
const qreal hole_y_{outer < 0 ? center.y() - row_h * 0.38 : center.y() + row_h * 0.38 - hole_};
painter->setBrush(Qt::black);
painter->drawRect(QRectF{center.x() - hole_ / 2, hole_y_, hole_, hole_});
//Screw head, toward the middle of the terminal
const qreal r_{std::min({width * 0.36, row_h * 0.26, max_r * 1.1})};
const QPointF c_{center.x(), center.y() - outer * row_h * 0.08};
painter->setBrush(fill.darker(125));
painter->drawEllipse(c_, r_ * 1.15, r_ * 1.15);
painter->setBrush(QColor(175, 175, 175));
painter->drawEllipse(c_, r_, r_);
painter->setPen(QPen(Qt::black, std::max<qreal>(0.6, r_ * 0.25)));
painter->drawLine(QPointF{c_.x() - r_ * 0.7, c_.y()}, QPointF{c_.x() + r_ * 0.7, c_.y()});
}
painter->restore();
}
/**
* @brief drawEntriesHalf
* Draw the rows of wire entries of one half of a terminal (top or bottom).
* One row by level. Each row have a few ribs at its outer side and a line
* at its inner side, like the real terminals.
* @param outer : y of the outer side of the half (end of the terminal)
* @param inner : y of the inner side of the half (middle band). For the bottom half outer > inner.
* @param max_r : biggest radius of a wire entry
*/
void drawEntriesHalf(QPainter *painter, const QRectF &body, qreal outer, qreal inner,
int levels, qreal max_r, const QColor &fill, bool screw)
{
const qreal row_h_{(inner - outer) / levels}; //Negative for the bottom half
const qreal abs_row_h_{std::abs(row_h_)};
const QPen rib_pen_{fill.darker(170), 0};
for (int k = 0 ; k < levels ; ++k)
{
const qreal a_{outer + k * row_h_};
//Ribs
painter->save();
painter->setPen(rib_pen_);
for (const qreal f_ : {0.12, 0.22, 0.32}) {
const qreal y_{a_ + f_ * row_h_};
painter->drawLine(QPointF{body.left(), y_}, QPointF{body.right(), y_});
}
//Limit of the row
if (k > 0) {
painter->drawLine(QPointF{body.left(), a_}, QPointF{body.right(), a_});
}
painter->restore();
drawWireEntry(painter, QPointF{body.center().x(), a_ + 0.67 * row_h_},
body.width(), abs_row_h_, max_r, row_h_ > 0 ? -1 : 1, fill, screw);
}
}
/**
* @brief The BridgePoint struct
* Where a bridge is plugged.
*/
struct BridgePoint
{
QPointF point;
qreal width{0};
};
} //End anonymous namespace
QRectF boundingRect(const AbstractTerminalStripInterface *strip,
const TerminalStripLayoutPattern &pattern)
{
qreal width_mm_{2 * endStopWidthMm(pattern) + groupMarkerWidthMm(pattern)};
for (const auto &g_ : geometry(strip)) {
width_mm_ += g_.width_mm;
if (g_.separator_after) {
width_mm_ += separator_width_mm;
}
}
const qreal s_{pattern.m_realistic_px_per_mm};
return QRectF{0, 0, width_mm_ * s_, pattern.m_realistic_length_mm * s_};
}
void paint(QPainter *painter,
const AbstractTerminalStripInterface &strip,
const TerminalStripLayoutPattern &pattern,
TerminalStripLayoutPattern::DrawStyle style,
bool preview_draw,
const QPointF &hover_pos,
QVector<XrefRect> *xref_rects)
{
//Clamp of the layout, used for the terminals not chosen in the catalog
const bool layout_screw_{style == TerminalStripLayoutPattern::DrawStyle::RealisticScrew};
const qreal s_{pattern.m_realistic_px_per_mm};
const qreal length_{pattern.m_realistic_length_mm * s_};
if (s_ <= 0 || length_ <= 0) {
return;
}
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setFont(pattern.font());
painter->setPen(QPen(Qt::black, 0.5));
if (preview_draw)
{
painter->save();
painter->setPen(Qt::blue);
painter->setBrush(Qt::NoBrush);
painter->drawRect(boundingRect(&strip, pattern));
painter->restore();
}
qreal x_{0};
//The accessories are a bit shorter than the terminals, centered on them
const qreal end_stop_w_{endStopWidthMm(pattern) * s_};
const qreal end_stop_h_{length_ * 0.88};
const qreal end_stop_y_{(length_ - end_stop_h_) / 2};
auto draw_end_stop = [&]()
{
if (end_stop_w_ <= 0) {
return;
}
const QRectF rect_{x_, end_stop_y_, end_stop_w_, end_stop_h_};
drawEndStop(painter, rect_);
x_ += end_stop_w_;
};
draw_end_stop();
if (pattern.m_realistic_group_marker)
{
const qreal w_{group_marker_width_mm * s_};
const qreal h_{length_ * 0.72};
drawGroupMarker(painter, QRectF{x_, (length_ - h_) / 2, w_, h_}, strip.name());
x_ += w_;
}
QHash<QUuid, QVector<BridgePoint>> bridges_;
for (const auto &g_ : geometry(&strip))
{
const qreal w_{g_.width_mm * s_};
const QRectF body_{x_, 0, w_, length_};
const auto first_{g_.reals.first()};
const int levels_{static_cast<int>(g_.reals.size())};
//A terminal chosen in the catalog is drawn with its own clamp, so that a strip
//mixing screw and spring terminals is drawn as it is. The spring-cage clamps
//(opened with a screwdriver) are drawn like the push-in ones.
const auto *catalog_entry_{TerminalCatalog::entryByLabel(first_->catalogEntry())};
const bool screw_{catalog_entry_ ? catalog_entry_->clamp == TerminalCatalog::Clamp::Screw
: layout_screw_};
QColor fill_, stripe_;
Helpers::terminalColors(first_, fill_, stripe_);
if (!fill_.isValid()) {
fill_ = QColor(150, 150, 150);
}
//Body
painter->save();
painter->setPen(QPen(fill_.darker(220), 0.5));
painter->setBrush(fill_);
painter->drawRect(body_);
if (stripe_.isValid())
{
//Two-tone terminal (PE) : the stripe color on the left part, like the real ones
painter->setPen(Qt::NoPen);
painter->setBrush(stripe_);
painter->drawRect(QRectF{body_.left(), body_.top(), w_ * 0.6, length_});
}
painter->restore();
//Wire entries : one row by level, at the top and at the bottom
const qreal max_r_{max_entry_radius_mm * s_};
drawEntriesHalf(painter, body_, length_ * entries_top, length_ * band_top, levels_, max_r_, fill_, screw_);
drawEntriesHalf(painter, body_, length_ * (1 - entries_top), length_ * band_bottom, levels_, max_r_, fill_, screw_);
//Middle band, with one bridge channel by level
painter->save();
painter->setPen(Qt::NoPen);
painter->setBrush(band_color);
painter->drawRect(QRectF{x_, length_ * band_top, w_, length_ * (band_bottom - band_top)});
painter->setBrush(channel_color);
const qreal channel_h_{length_ * (channels_bottom - channels_top) / levels_};
for (int k = 0 ; k < levels_ ; ++k) {
painter->drawRect(QRectF{x_ + w_ * 0.15, length_ * channels_top + (k + 0.2) * channel_h_,
w_ * 0.7, channel_h_ * 0.6});
}
painter->restore();
//Specific parts of some types of terminal
const auto type_{first_->type()};
switch (type_)
{
case ElementData::TTFuse:
drawFuseHolder(painter, x_, w_, length_, levels_, screw_, first_->isLed());
break;
case ElementData::TTSectional:
drawDisconnectKnife(painter, x_, w_, length_, levels_, screw_);
break;
case ElementData::TTTestSocket:
drawTestSockets(painter, x_, w_, length_, s_, levels_, screw_, fill_);
break;
default:
break;
}
//Symbol of the type (fuse, led...), at the bottom of the band
//A test terminal with levels have its test sockets in place of the symbol
const bool have_symbol_{(first_->type() != ElementData::TTGeneric || first_->isLed())
&& !(type_ == ElementData::TTTestSocket && levels_ > 1)};
const qreal tag_top_{length_ * 0.455};
const bool sockets_in_band_{type_ == ElementData::TTTestSocket && levels_ > 1};
const qreal tag_bottom_{length_ * (have_symbol_ || sockets_in_band_ ? 0.535 : 0.59)};
if (have_symbol_)
{
painter->save();
//Black on the orange knife, white on the dark band and on the fuse holder
painter->setPen(QPen(type_ == ElementData::TTSectional ? QColor(Qt::black) : QColor(Qt::white), 0.5));
Helpers::drawTypeSymbol(painter, QRectF{x_, length_ * 0.54, w_, length_ * 0.055},
first_->type(), first_->isLed());
painter->restore();
}
//Marker tag with the number(s) of the terminal
QStringList numbers_;
for (const auto &real_t : g_.reals) {
numbers_ << Helpers::terminalNumber(real_t->label());
}
const bool sectional_{first_->type() == ElementData::TTSectional};
const QRectF tag_{x_ + std::min(0.6 * s_, w_ * 0.12), tag_top_,
w_ - 2 * std::min(0.6 * s_, w_ * 0.12), tag_bottom_ - tag_top_};
painter->save();
painter->setPen(QPen(QColor(120, 120, 120), 0.3));
painter->setBrush(sectional_ ? QColor(Qt::black) : QColor(Qt::white));
painter->drawRect(tag_);
painter->restore();
drawVerticalText(painter, tag_, numbers_.join(QStringLiteral(" / ")),
sectional_ ? QColor(Qt::white) : QColor(Qt::black));
//Clickable zones : the whole terminal for a terminal with one level,
//the rows of wire entries of the level (top and bottom) for a terminal with levels.
//Only the terminals with an xref (element placed on a folio) are clickable.
QVector<XrefRect> terminal_rects_;
for (int k = 0 ; k < levels_ ; ++k)
{
if (g_.reals.at(k)->xref().isEmpty()) {
continue;
}
const int physical_{g_.physical_index};
const int real_{g_.real_indexes.at(k)};
if (levels_ == 1) {
terminal_rects_.append({body_, physical_, real_});
} else {
const qreal top_h_{length_ * (band_top - entries_top) / levels_};
terminal_rects_.append({QRectF{x_, length_ * entries_top + k * top_h_, w_, top_h_}, physical_, real_});
terminal_rects_.append({QRectF{x_, length_ * (1 - entries_top) - (k + 1) * top_h_, w_, top_h_}, physical_, real_});
}
}
if (xref_rects) {
*xref_rects << terminal_rects_;
}
//Highlight the terminal under the mouse, like the xref of the schematic style
if (!hover_pos.isNull())
{
int hovered_real_{-1};
for (const auto &r_ : std::as_const(terminal_rects_)) {
if (r_.rect.contains(hover_pos)) {
hovered_real_ = r_.real;
break;
}
}
if (hovered_real_ >= 0)
{
painter->save();
painter->setPen(QPen(QColor(0, 90, 255), std::max<qreal>(1.0, 0.4 * s_)));
painter->setBrush(Qt::NoBrush);
for (const auto &r_ : std::as_const(terminal_rects_)) {
if (r_.real == hovered_real_) {
painter->drawRect(r_.rect.adjusted(0.5, 0.5, -0.5, -0.5));
}
}
painter->restore();
}
}
//Bridge points : one channel by level
for (int k = 0 ; k < levels_ ; ++k)
{
const auto &real_t{g_.reals.at(k)};
if (!real_t->isBridged()) {
continue;
}
QScopedPointer<AbstractBridgeInterface> bridge_{real_t->bridge()};
if (!bridge_) {
continue;
}
const qreal channel_h_{length_ * (channels_bottom - channels_top) / levels_};
const qreal y_{length_ * channels_top + (k + 0.5) * channel_h_};
bridges_[bridge_->uuid()].append(BridgePoint{QPointF{x_ + w_ / 2, y_}, w_});
}
x_ += w_;
//Separator plate after the terminal
if (g_.separator_after)
{
const qreal sep_w_{separator_width_mm * s_};
drawSeparator(painter, QRectF{x_, 0, sep_w_, length_});
x_ += sep_w_;
}
}
//Bridges : a red comb plugged in the channels
for (const auto &points_ : std::as_const(bridges_))
{
if (points_.size() < 2) {
continue;
}
painter->save();
const qreal thickness_{std::max<qreal>(1.0, 0.9 * s_)};
painter->setPen(QPen(bridge_color, thickness_, Qt::SolidLine, Qt::FlatCap));
painter->drawLine(points_.first().point, points_.last().point);
painter->setPen(Qt::NoPen);
painter->setBrush(bridge_color.darker(140));
for (const auto &p_ : points_) {
const qreal r_{std::min(p_.width * 0.18, thickness_ * 0.8)};
painter->drawEllipse(p_.point, r_, r_);
}
painter->restore();
}
draw_end_stop();
painter->restore();
}
} //End namespace Realistic
} //End namespace TerminalStripDrawer
@@ -0,0 +1,57 @@
/*
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 REALISTICTERMINALSTRIPDRAWER_H
#define REALISTICTERMINALSTRIPDRAWER_H
#include "terminalstripdrawer.h"
class QPainter;
/**
* Drawing of a terminal strip at scale, seen from above, like the
* mounting drawing of a cabinet. Used by the styles
* RealisticPushIn and RealisticScrew of TerminalStripLayoutPattern.
*
* The strip is drawn from the left to the right :
* end stop, group marker carrier (with the name of the strip), terminals, end stop.
* The width of each terminal is its real width (mm) multiplied by the scale of the layout.
* A terminal with several levels is drawn with one row of wire entries by level,
* at the top and at the bottom.
*
* paint() fill @a xref_rects with the zone of each terminal which have an xref,
* with the same indexes than the schematic style : the item use them to go to the
* element on double click, to show the xref in a tool tip and to make clickable
* links in an exported PDF. The terminal under @a hover_pos is outlined in blue.
*/
namespace TerminalStripDrawer
{
namespace Realistic
{
QRectF boundingRect(const AbstractTerminalStripInterface *strip,
const TerminalStripLayoutPattern &pattern);
void paint(QPainter *painter,
const AbstractTerminalStripInterface &strip,
const TerminalStripLayoutPattern &pattern,
TerminalStripLayoutPattern::DrawStyle style,
bool preview_draw = false,
const QPointF &hover_pos = QPointF(),
QVector<XrefRect> *xref_rects = nullptr);
}
}
#endif // REALISTICTERMINALSTRIPDRAWER_H
@@ -16,8 +16,12 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "terminalstripdrawer.h"
#include "terminalstripdrawerhelpers.h"
#include "realisticterminalstripdrawer.h"
#include <QPainter>
#include <QPolygonF>
#include <QPainterPath>
#include <QFontMetricsF>
#include <QHash>
@@ -405,14 +409,491 @@ void drawCables(QPainter *painter, const QVector<CableCell> &cells, const Termin
}
}
/*
* Width and color of a terminal.
*/
/**
* The width of the terminals of the layout is the width of a terminal
* of 5.2 mm (the usual width of the terminals of 2.5 mm2).
* The width set by the user, in mm, is converted according to this reference.
*/
constexpr qreal reference_terminal_width_mm{5.2};
/**
* @brief terminalRect
* @param pattern
* @param index : level of the terminal (0 to 3)
* @param real_t : can be null
* @return The rectangle of the terminal : the one of the layout,
* with the width adapted to the width (in mm) of @a real_t if it is set.
*/
QRectF terminalRect(const TerminalStripLayoutPattern &pattern,
int index,
const QSharedPointer<AbstractRealTerminalInterface> &real_t)
{
auto rect_{pattern.m_terminal_rect[index]};
if (real_t && real_t->widthMm() > 0) {
rect_.setWidth(rect_.width() * real_t->widthMm() / reference_terminal_width_mm);
}
return rect_;
}
/**
* @brief The TerminalColors struct
* Colors used to fill the rectangle of a terminal.
* An invalid fill color means that the rectangle is not filled.
* The stripe color (if valid) is drawn as stripes over the fill color.
*/
struct TerminalColors
{
QColor fill;
QColor stripe;
};
/**
* @brief terminalColors
* @param real_t : can be null
* @return The colors of the terminal : the color set by the user,
* else the default color of the type of the terminal.
*/
TerminalColors terminalColors(const QSharedPointer<AbstractRealTerminalInterface> &real_t)
{
if (!real_t) {
return {};
}
if (real_t->color().isValid()) {
return {real_t->color(), real_t->stripeColor()};
}
switch (real_t->type()) {
case ElementData::TTGround: return {QColor(255, 216, 0), QColor(46, 158, 46)}; //Green/Yellow
default: return {QColor(150, 150, 150), QColor()}; //Grey housing, like a real terminal block
}
}
/**
* @brief terminalBodyPath
* @param rect : rectangle of the terminal
* @return The outline of a terminal block seen from the front : a body of the full width
* with, at the top and at the bottom, a slightly narrower cap (the wire entry),
* linked to the body by a small slope.
* Two neighbouring terminals touch each other by their bodies and leave a small notch between their caps.
*/
QPainterPath terminalBodyPath(const QRectF &rect)
{
QPainterPath path_;
if (rect.width() <= 0 || rect.height() <= 0) {
return path_;
}
const qreal inset_ = qMin(qreal(1.0), rect.width() * 0.1);
const qreal cap_ = qMin(qreal(6.0), rect.height() * 0.06);
const qreal slope_ = qMin(qreal(2.0), rect.height() * 0.03);
const qreal l_ = rect.left(), r_ = rect.right(), t_ = rect.top(), b_ = rect.bottom();
QPolygonF outline_;
outline_ << QPointF(l_ + inset_, t_)
<< QPointF(r_ - inset_, t_)
<< QPointF(r_ - inset_, t_ + cap_)
<< QPointF(r_, t_ + cap_ + slope_)
<< QPointF(r_, b_ - cap_ - slope_)
<< QPointF(r_ - inset_, b_ - cap_)
<< QPointF(r_ - inset_, b_)
<< QPointF(l_ + inset_, b_)
<< QPointF(l_ + inset_, b_ - cap_)
<< QPointF(l_, b_ - cap_ - slope_)
<< QPointF(l_, t_ + cap_ + slope_)
<< QPointF(l_ + inset_, t_ + cap_);
path_.addPolygon(outline_);
path_.closeSubpath();
return path_;
}
/**
* @brief The Zone struct
* A vertical zone (relative to the top of a terminal) already used by a text or a symbol.
*/
struct Zone
{
qreal top{0};
qreal bottom{0};
};
/**
* @brief drawTerminalBody Draw a terminal housing seen from the front,
* like in a terminal line-up diagram : a grey body with wire entries (caps) at both ends,
* and clamping screws drawn in the free zones of the body.
* @param painter
* @param rect : rectangle of the terminal
* @param used_zones : zones used by the texts and the symbols (in the frame of the strip),
* the screws are drawn out of them.
* @param bottom_line : draw the line between the body and the lower wire entry.
* Must be false if a symbol (fuse, led...) is drawn over this line.
* The details are deliberately schematic and scale with the terminal width.
*/
void drawTerminalBody(QPainter *painter, const QRectF &rect, const QVector<Zone> &used_zones, bool bottom_line)
{
if (rect.width() <= 0 || rect.height() <= 0) {
return;
}
painter->drawPath(terminalBodyPath(rect));
if (rect.width() < 5.0 || rect.height() < 12.0) {
return;
}
const qreal cap_ = qMin(qreal(6.0), rect.height() * 0.06);
const qreal slope_ = qMin(qreal(2.0), rect.height() * 0.03);
const qreal body_top_ = rect.top() + cap_ + slope_;
const qreal body_bottom_ = rect.bottom() - cap_ - slope_;
//Limit between the body and the wire entries
painter->drawLine(QPointF(rect.left(), body_top_),
QPointF(rect.right(), body_top_));
if (bottom_line) {
painter->drawLine(QPointF(rect.left(), body_bottom_),
QPointF(rect.right(), body_bottom_));
}
//Free zones of the body : what remains when the used zones are removed
QVector<Zone> free_zones_;
free_zones_.append(Zone{body_top_, body_bottom_});
const qreal margin_ = 2.5;
for (const auto &used_ : used_zones)
{
QVector<Zone> new_free_;
for (const auto &free_ : std::as_const(free_zones_))
{
if (used_.bottom + margin_ <= free_.top || used_.top - margin_ >= free_.bottom) {
new_free_.append(free_);
continue;
}
if (used_.top - margin_ > free_.top) {
new_free_.append(Zone{free_.top, used_.top - margin_});
}
if (used_.bottom + margin_ < free_.bottom) {
new_free_.append(Zone{used_.bottom + margin_, free_.bottom});
}
}
free_zones_ = new_free_;
}
//In each free zone, draw the clamping parts like on a terminal block seen from the front :
//screw, castle tab, big center circle, castle tab, screw.
//If the zone is too small, only the center circle or one screw is drawn.
const qreal screw_r_ = qMin(qreal(5.0), rect.width() * 0.3);
const qreal center_r_ = qMin(screw_r_ * 1.5, rect.width() * 0.4);
if (screw_r_ < 1.0) {
return;
}
const qreal screw_h_ = screw_r_ * 2 + 3;
const qreal center_h_ = center_r_ * 2 + 2;
const qreal tab_h_ = 4.0;
const qreal tab_w_ = qMin(rect.width() * 0.3, qreal(5.0));
const qreal cx_ = rect.center().x();
auto draw_screw = [&](qreal y_, qreal r_, bool window_)
{
const QPointF c_{cx_, y_};
if (window_) {
const qreal half_{qMin(r_ + 1.5, rect.width() / 2 - 0.8)};
if (half_ > r_ + 0.3) {
painter->drawRoundedRect(QRectF(cx_ - half_, c_.y() - half_, half_ * 2, half_ * 2), 1.0, 1.0);
}
}
painter->drawEllipse(c_, r_, r_);
painter->drawLine(QPointF(c_.x() - r_ * 0.6, c_.y()), QPointF(c_.x() + r_ * 0.6, c_.y()));
};
auto draw_tab = [&](qreal y_)
{
painter->drawRect(QRectF(cx_ - tab_w_, y_ - tab_h_ / 2, tab_w_ * 2, tab_h_));
};
for (const auto &free_ : std::as_const(free_zones_))
{
const qreal h_ = free_.bottom - free_.top;
if (h_ >= screw_h_ * 2 + center_h_ + tab_h_ * 2 + 2)
{
const qreal top_y_ = free_.top + screw_h_ / 2;
const qreal bottom_y_ = free_.bottom - screw_h_ / 2;
const qreal middle_y_ = (top_y_ + bottom_y_) / 2;
draw_screw(top_y_, screw_r_, true);
draw_tab((top_y_ + screw_h_ / 2 + middle_y_ - center_h_ / 2) / 2);
draw_screw(middle_y_, center_r_, false);
draw_tab((middle_y_ + center_h_ / 2 + bottom_y_ - screw_h_ / 2) / 2);
draw_screw(bottom_y_, screw_r_, true);
}
else if (h_ >= center_h_ + 2) {
draw_screw((free_.top + free_.bottom) / 2, center_r_, false);
}
else if (h_ >= screw_h_) {
draw_screw((free_.top + free_.bottom) / 2, screw_r_, true);
}
}
}
/**
* @brief markerTagLength
* Like the real marker tags, every tag of a strip have the same size,
* whatever the text written on it ("1", "10"...).
* The length is the one needed by the longest number of the strip
* (at least two digits), limited to the text zone.
* @param metrics : metrics of the font used to draw the text
* @param longest_number : the longest number of the strip
* @param zone_length : length of the text zone, along the text
* @return the length of the marker tag along the text
*/
qreal markerTagLength(const QFontMetricsF &metrics, const QString &longest_number, qreal zone_length)
{
const qreal text_ = qMax(metrics.horizontalAdvance(QStringLiteral("00")),
metrics.horizontalAdvance(longest_number));
return qMin(zone_length, text_ + 4.0);
}
/**
* @brief markerTagZone
* @return The vertical zone (in the frame of the strip) really covered by the marker tag of a text,
* which is smaller than the rectangle of the text.
* @param metrics : metrics of the font used to draw the text
* @param text_y : y of the rectangle of the text
* @param text_height : height of the rectangle of the text
* @param text : text drawn
* @param option : option used to draw the text
* @param vertical : true if the text is rotated
* @param tag_length : length of the marker tags of the strip, along the text
*/
Zone markerTagZone(const QFontMetricsF &metrics, qreal text_y, qreal text_height,
const QString &text, const QTextOption &option, bool vertical,
qreal tag_length)
{
if (!vertical)
{
const qreal cross_ = qMin(text_height, metrics.height() + 2);
return Zone{text_y + (text_height - cross_) / 2, text_y + (text_height + cross_) / 2};
}
Q_UNUSED(text)
//Same length for every terminal, so the body of every terminal is drawn the same way
const qreal along_ = qMin(text_height, tag_length);
if (option.alignment() & Qt::AlignRight) {
return Zone{text_y, text_y + along_};
}
if (option.alignment() & Qt::AlignHCenter) {
return Zone{text_y + (text_height - along_) / 2, text_y + (text_height + along_) / 2};
}
return Zone{text_y + text_height - along_, text_y + text_height};
}
/**
* @brief drawMarkerTag
* Draw the marker tag of a terminal (white, or black and ribbed for an isolating terminal)
* behind a text. The tag fits the text and follows its alignment.
* Must be called with the painter in the frame of @a text_rect (the one used to draw the text).
* @param painter
* @param text_rect : rectangle used to draw the text
* @param text : text drawn
* @param option : option used to draw the text
* @param vertical : true if the text is rotated
* @param sectional : true for an isolating terminal
* @param tag_length : length of the marker tags of the strip, along the text
*/
void drawMarkerTag(QPainter *painter, const QRectF &text_rect, const QString &text,
const QTextOption &option, bool vertical, bool sectional,
qreal tag_length)
{
if (text.isEmpty()) {
return;
}
const QFontMetricsF metrics_{painter->font()};
//Vertical text : fixed length, like a real marker tag.
//Horizontal text : the tag take the whole width of the terminal.
const qreal along_ = vertical ? qMin(text_rect.width(), tag_length) : text_rect.width();
const qreal cross_ = vertical ?
text_rect.height() - 2 * qMin(qreal(1.2), text_rect.height() * 0.12) :
qMin(text_rect.height(), metrics_.height() + 2);
qreal x_{text_rect.left()};
if (option.alignment() & Qt::AlignRight) {
x_ = text_rect.right() - along_;
} else if (option.alignment() & Qt::AlignHCenter) {
x_ = text_rect.left() + (text_rect.width() - along_) / 2;
}
const QRectF tag_rect_{x_, text_rect.top() + (text_rect.height() - cross_) / 2, along_, cross_};
painter->save();
painter->setBrush(sectional ? Qt::black : Qt::white);
painter->drawRoundedRect(tag_rect_, 0.8, 0.8);
if (sectional)
{
//Ribbed grip of the isolating terminals
painter->setPen(QPen(QColor(90, 90, 90), 0));
for (auto rib_x_ = tag_rect_.left() + 1.5 ; rib_x_ < tag_rect_.right() - 0.5 ; rib_x_ += 1.5) {
painter->drawLine(QPointF(rib_x_, tag_rect_.top() + 0.5),
QPointF(rib_x_, tag_rect_.bottom() - 0.5));
}
}
painter->restore();
painter->setPen(sectional ? Qt::white : Qt::black);
//The text is longer than the tag ("100", "PE"...) : reduce the font, like on a real tag.
const qreal available_ = along_ - 2.0;
const qreal text_length_ = metrics_.horizontalAdvance(text);
if (text_length_ > available_ && available_ > 0)
{
auto font_{painter->font()};
const auto size_{font_.pixelSize() > 0 ? qreal(font_.pixelSize()) : font_.pointSizeF()};
const auto new_size_{std::max<qreal>(3.0, size_ * available_ / text_length_)};
if (font_.pixelSize() > 0) {
font_.setPixelSize(std::max(1, static_cast<int>(new_size_)));
} else {
font_.setPointSizeF(new_size_);
}
painter->setFont(font_);
}
}
/**
* @brief terminalNumber
* @param label : label of a terminal, for example "XAUZ1:1"
* @return The number of the terminal, the part after the last ':' ("1").
* The strip name is already written in the header of the strip.
* If the label have no ':', return the label.
*/
QString terminalNumber(const QString &label)
{
const auto index_ = label.lastIndexOf(QLatin1Char(':'));
return index_ < 0 ? label : label.mid(index_ + 1).trimmed();
}
/**
* @brief fitTextToCell
* The text of a terminal is drawn vertically (or not) inside the width of the terminal.
* If the font is too big for a narrow terminal, the text overlaps the next ones.
* Clip the painter to the width of the terminal and reduce the font if needed.
* Must be called with the painter at the left of the terminal, before any rotation.
* @param painter
* @param cell_width : width of the terminal
* @param text_is_vertical : true if the text is rotated (the height of the font is then along the width of the terminal)
*/
void fitTextToCell(QPainter *painter, qreal cell_width, bool text_is_vertical)
{
painter->setClipRect(QRectF(0, -100000, cell_width, 200000), Qt::IntersectClip);
if (!text_is_vertical || cell_width <= 0) {
return;
}
auto font_{painter->font()};
const auto available_{std::max<qreal>(1.0, cell_width - 1.0)};
const QFontMetricsF metrics_{font_};
if (metrics_.height() > available_ && metrics_.height() > 0)
{
const auto size_{font_.pixelSize() > 0 ? qreal(font_.pixelSize()) : font_.pointSizeF()};
const auto new_size_{std::max<qreal>(3.0, size_ * available_ / metrics_.height())};
if (font_.pixelSize() > 0) {
font_.setPixelSize(static_cast<int>(new_size_));
} else {
font_.setPointSizeF(new_size_);
}
painter->setFont(font_);
}
}
/**
* @brief drawStripes
* Draw diagonal stripes of @a color inside @a rect.
*/
void drawStripes(QPainter *painter, const QRectF &rect, const QColor &color)
{
painter->save();
painter->setClipPath(terminalBodyPath(rect));
painter->setPen(Qt::NoPen);
painter->setBrush(color);
const auto band_{std::max<qreal>(2, rect.width() / 2)};
for (auto x = rect.left() - rect.height() ; x < rect.right() ; x += 2 * band_)
{
QPolygonF polygon_;
polygon_ << QPointF(x, rect.bottom())
<< QPointF(x + band_, rect.bottom())
<< QPointF(x + band_ + rect.height(), rect.top())
<< QPointF(x + rect.height(), rect.top());
painter->drawPolygon(polygon_);
}
painter->restore();
}
/**
* @return The color (black or white) the more readable on @a background.
* If @a background is invalid, return black.
*/
QColor contrastColor(const QColor &background)
{
if (!background.isValid()) {
return Qt::black;
}
const auto luminance_{0.299 * background.redF() + 0.587 * background.greenF() + 0.114 * background.blueF()};
return luminance_ < 0.5 ? QColor(Qt::white) : QColor(Qt::black);
}
} //End anonymous namespace
/*
* Helpers : give to the other drawing styles access to
* the functions of the anonymous namespace.
*/
qreal Helpers::referenceTerminalWidthMm() {
return reference_terminal_width_mm;
}
void Helpers::terminalColors(const QSharedPointer<AbstractRealTerminalInterface> &real_t,
QColor &fill, QColor &stripe)
{
const auto colors_{::TerminalStripDrawer::terminalColors(real_t)};
fill = colors_.fill;
stripe = colors_.stripe;
}
QColor Helpers::contrastColor(const QColor &background) {
return ::TerminalStripDrawer::contrastColor(background);
}
QString Helpers::terminalNumber(const QString &label) {
return ::TerminalStripDrawer::terminalNumber(label);
}
void Helpers::drawTypeSymbol(QPainter *painter, const QRectF &box,
ElementData::TerminalType type, bool led) {
::TerminalStripDrawer::drawTypeSymbol(painter, box, type, led);
}
/**
* @brief TerminalStripDrawer::paint
* @param painter
*/
void TerminalStripDrawer::paint(QPainter *painter)
{
if (m_strip && m_pattern && m_draw_style != TerminalStripLayoutPattern::DrawStyle::Schematic)
{
//The realistic styles don't write the xrefs, but each terminal
//is a clickable zone, like the xref text of the schematic style.
m_united_xref_text_rect = QRectF();
m_xref_rects.clear();
m_hovered_xref = hoverTerminal{};
Realistic::paint(painter, *m_strip, *m_pattern, m_draw_style, m_preview_draw,
m_mouse_hover_pos, &m_xref_rects);
for (const auto &xref_rect : std::as_const(m_xref_rects))
{
m_united_xref_text_rect = m_united_xref_text_rect.isNull() ?
xref_rect.rect :
m_united_xref_text_rect.united(xref_rect.rect);
if (m_hovered_xref.physical < 0 && !m_mouse_hover_pos.isNull()
&& xref_rect.rect.contains(m_mouse_hover_pos)) {
m_hovered_xref.physical = xref_rect.physical;
m_hovered_xref.real = xref_rect.real;
}
}
return;
}
if (m_strip && m_pattern)
{
m_united_xref_text_rect = QRectF();
@@ -492,6 +973,42 @@ void TerminalStripDrawer::paint(QPainter *painter)
QVector<CableCell> cable_cells;
m_hovered_xref = hoverTerminal{};
//Length of the longest xref of the strip, used to reserve the same
//zone in the body of every terminal.
qreal xref_zone_length_{0};
{
const QFontMetricsF xref_metrics_{painter->font()};
for (const auto &physical_t : m_strip->physicalTerminal()) {
for (const auto &real_t : physical_t->realTerminals()) {
if (real_t && !real_t->xref().isEmpty()) {
xref_zone_length_ = qMax(xref_zone_length_,
xref_metrics_.horizontalAdvance(real_t->xref()) + 4.0);
}
}
}
xref_zone_length_ = qMin(xref_zone_length_, qreal(xref_text_height));
}
//Length of the marker tags of the strip, calibrated on the longest number
qreal tag_length_{0};
{
QString longest_number_;
const QFontMetricsF tag_metrics_{painter->font()};
for (const auto &physical_t : m_strip->physicalTerminal()) {
for (const auto &real_t : physical_t->realTerminals()) {
if (!real_t) {
continue;
}
const auto number_{terminalNumber(real_t->label())};
if (tag_metrics_.horizontalAdvance(number_) > tag_metrics_.horizontalAdvance(longest_number_)) {
longest_number_ = number_;
}
}
}
tag_length_ = markerTagLength(tag_metrics_, longest_number_, terminals_text_height);
}
int physical_index = 0;
//Loop over physical terminals
for (const auto &physical_t : m_strip->physicalTerminal())
@@ -509,7 +1026,7 @@ void TerminalStripDrawer::paint(QPainter *painter)
break;
}
terminal_rect = m_pattern->m_terminal_rect[index_];
terminal_rect = terminalRect(*m_pattern, index_, real_terminal_vector[i]);
//Cable (hose) of this terminal
CableCell cable_cell;
@@ -523,17 +1040,71 @@ void TerminalStripDrawer::paint(QPainter *painter)
}
const bool have_cable{m_pattern->m_cable_wire_length > 0 && !cable_cell.cable.isEmpty()};
cable_cells.append(cable_cell);
//Draw terminal rect
painter->drawRect(terminal_rect);
//Draw terminal rect, filled with the color of the terminal (if any)
const auto colors_{terminalColors(real_terminal_vector[i])};
const auto fill_color_{colors_.fill};
const auto text_color_{contrastColor(fill_color_)};
painter->save();
if (fill_color_.isValid()) {
painter->setBrush(QBrush(fill_color_));
}
QVector<Zone> used_zones_;
if (real_terminal_vector[i])
{
const QFontMetricsF metrics_{painter->font()};
used_zones_.append(markerTagZone(metrics_, terminals_text_y, terminals_text_height,
terminalNumber(real_terminal_vector[i]->label()),
terminals_text_option,
terminals_text_orientation == Qt::Vertical,
tag_length_));
//The zone of the xref have the length of the longest xref of the strip,
//so the body of every terminal is drawn the same way.
if (xref_text_orientation == Qt::Vertical)
{
if (xref_text_option.alignment() & Qt::AlignRight) {
used_zones_.append(Zone{xref_text_y, xref_text_y + xref_zone_length_});
} else if (xref_text_option.alignment() & Qt::AlignHCenter) {
used_zones_.append(Zone{xref_text_y + (xref_text_height - xref_zone_length_) / 2,
xref_text_y + (xref_text_height + xref_zone_length_) / 2});
} else {
used_zones_.append(Zone{xref_text_y + xref_text_height - xref_zone_length_,
xref_text_y + xref_text_height});
}
} else {
const qreal cross_ = qMin(xref_text_height, metrics_.height() + 2);
used_zones_.append(Zone{xref_text_y + (xref_text_height - cross_) / 2,
xref_text_y + (xref_text_height + cross_) / 2});
}
}
const bool have_symbol_{m_pattern->m_type_symbol_height > 0 && real_terminal_vector[i]
&& (real_terminal_vector[i]->type() != ElementData::TTGeneric
|| real_terminal_vector[i]->isLed())};
if (have_symbol_) {
used_zones_.append(Zone{m_pattern->m_type_symbol_y,
m_pattern->m_type_symbol_y + m_pattern->m_type_symbol_height});
}
drawTerminalBody(painter, terminal_rect, used_zones_, !have_symbol_);
if (colors_.stripe.isValid())
{
drawStripes(painter, terminal_rect, colors_.stripe);
painter->setBrush(Qt::NoBrush);
drawTerminalBody(painter, terminal_rect, used_zones_, !have_symbol_); //Draw again the outline and the details, hidden by stripes
}
painter->restore();
//Draw the symbol of the terminal type (fuse, ground, led...)
if (m_pattern->m_type_symbol_height > 0 && real_terminal_vector[i])
{
painter->save();
if (fill_color_.isValid()) {
painter->setPen(text_color_);
}
drawTypeSymbol(painter,
QRectF{0, m_pattern->m_type_symbol_y,
terminal_rect.width(), m_pattern->m_type_symbol_height},
real_terminal_vector[i]->type(),
real_terminal_vector[i]->isLed());
painter->restore();
}
//Draw the connections, above and under the terminal
@@ -570,6 +1141,10 @@ void TerminalStripDrawer::paint(QPainter *painter)
//Draw text
painter->save();
if (fill_color_.isValid()) {
painter->setPen(text_color_);
}
fitTextToCell(painter, terminal_rect.width(), terminals_text_orientation == Qt::Vertical);
text_rect.setRect(0, terminals_text_y, terminal_rect.width(), terminals_text_height);
if (terminals_text_orientation == Qt::Vertical)
{
@@ -579,8 +1154,14 @@ void TerminalStripDrawer::paint(QPainter *painter)
}
const auto shared_real_terminal{real_terminal_vector[i]};
if (shared_real_terminal && terminal_rect.width() >= 4.0) {
drawMarkerTag(painter, text_rect, terminalNumber(shared_real_terminal->label()),
terminals_text_option, terminals_text_orientation == Qt::Vertical,
shared_real_terminal->type() == ElementData::TTSectional,
tag_length_);
}
painter->drawText(text_rect,
shared_real_terminal ? shared_real_terminal->label() : QLatin1String(),
shared_real_terminal ? terminalNumber(shared_real_terminal->label()) : QString(),
terminals_text_option);
if (m_preview_draw)
@@ -594,6 +1175,10 @@ void TerminalStripDrawer::paint(QPainter *painter)
//Draw xref
xref_rect.setRect(0, xref_text_y, terminal_rect.width(), xref_text_height);
painter->save();
if (fill_color_.isValid()) {
painter->setPen(text_color_);
}
fitTextToCell(painter, terminal_rect.width(), xref_text_orientation == Qt::Vertical);
if (xref_text_orientation == Qt::Vertical)
{
painter->translate(xref_rect.bottomLeft());
@@ -664,6 +1249,25 @@ void TerminalStripDrawer::paint(QPainter *painter)
painter->translate(terminal_rect.width(),0);
x_offset += terminal_rect.width();
}
//Separator plate after the physical terminal : a thick line
//on the full height of the terminal, after its last level
const bool separator_after_ = std::any_of(real_terminal_vector.cbegin(), real_terminal_vector.cend(),
[](const QSharedPointer<AbstractRealTerminalInterface> &real_t) {
return real_t && real_t->hasSeparatorAfter();
});
if (separator_after_ && real_t_count > 0 && offset_ >= 0 && offset_ < 4)
{
const auto &rect_{m_pattern->m_terminal_rect.at(offset_)};
painter->save();
QPen separator_pen_{painter->pen()};
separator_pen_.setColor(Qt::black);
separator_pen_.setWidthF(3);
separator_pen_.setCapStyle(Qt::FlatCap);
painter->setPen(separator_pen_);
painter->drawLine(QPointF{0, rect_.top() - 3}, QPointF{0, rect_.top() + rect_.height() + 3});
painter->restore();
}
physical_index++;
}
painter->restore();
@@ -694,6 +1298,10 @@ void TerminalStripDrawer::paint(QPainter *painter)
QRectF TerminalStripDrawer::boundingRect() const
{
if (m_pattern && m_draw_style != TerminalStripLayoutPattern::DrawStyle::Schematic) {
return Realistic::boundingRect(m_strip.data(), *m_pattern);
}
QRectF rect_{0, 0, width(), height()};
if (m_pattern)
@@ -761,6 +1369,35 @@ void TerminalStripDrawer::setPreviewDraw(bool draw) {
m_preview_draw = draw;
}
/**
* @brief TerminalStripDrawer::setDrawStyle
* Set how the terminals are drawn (schematic, realistic...).
* The values used by each style come from the layout.
*/
void TerminalStripDrawer::setDrawStyle(TerminalStripLayoutPattern::DrawStyle style) {
m_draw_style = style;
}
TerminalStripLayoutPattern::DrawStyle TerminalStripDrawer::drawStyle() const {
return m_draw_style;
}
/**
* @brief TerminalStripDrawer::xrefAt
* @param pos : position in the coordinates of the item
* @return the physical and real index of the terminal whose xref zone
* (drawn by the last call of paint()) contains @a pos, or -1 if none.
*/
hoverTerminal TerminalStripDrawer::xrefAt(const QPointF &pos) const
{
for (const auto &xref_rect : m_xref_rects) {
if (xref_rect.rect.contains(pos)) {
return hoverTerminal{xref_rect.physical, xref_rect.real};
}
}
return hoverTerminal{};
}
void TerminalStripDrawer::setMouseHoverPos(const QPointF &pos)
{
m_last_mouse_pos_in_xrefs_rect = m_united_xref_text_rect.contains(m_mouse_hover_pos);
@@ -848,7 +1485,7 @@ qreal TerminalStripDrawer::width() const
break;
}
width_ += m_pattern->m_terminal_rect[index_].width();
width_ += terminalRect(*m_pattern, index_, real_terminal_vector[i]).width();
}
}
}
@@ -18,6 +18,7 @@
#ifndef TERMINALSTRIPDRAWER_H
#define TERMINALSTRIPDRAWER_H
#include <QColor>
#include <QPointer>
#include <QUuid>
@@ -77,6 +78,18 @@ namespace TerminalStripDrawer
virtual QString cable() const { return QString(); }
virtual QString cableWire() const { return QString(); }
virtual bool isShield() const { return false; }
//Color and width (mm) of the terminal, only used to draw the terminal.
//An invalid color means "no override" : the default color of the type is used.
//A width <= 0 means "unknown" : the default width of the layout is used.
virtual QColor color() const { return QColor(); }
//Second color of a two-tone color (like green/yellow), drawn as stripes over color()
virtual QColor stripeColor() const { return QColor(); }
virtual qreal widthMm() const { return 0; }
//Label of the terminal chosen in the catalog (TerminalCatalog), empty if unknown.
//The realistic styles draw its clamp (screw or push-in) instead of the one of the layout.
virtual QString catalogEntry() const { return QString(); }
//A separator plate is mounted after the terminal (drawn by the realistic styles)
virtual bool hasSeparatorAfter() const { return false; }
};
class AbstractPhysicalTerminalInterface
@@ -113,10 +126,14 @@ namespace TerminalStripDrawer
void setPreviewDraw(bool draw = true);
void setDrawStyle(TerminalStripLayoutPattern::DrawStyle style);
TerminalStripLayoutPattern::DrawStyle drawStyle() const;
void setMouseHoverPos(const QPointF &pos);
bool mouseHoverXref() const;
bool needUpdate();
hoverTerminal hoveredXref() const;
hoverTerminal xrefAt(const QPointF &pos) const;
QVector<XrefRect> xrefRects() const;
private:
@@ -127,6 +144,7 @@ namespace TerminalStripDrawer
QSharedPointer <AbstractTerminalStripInterface> m_strip;
QSharedPointer<TerminalStripLayoutPattern> m_pattern;
bool m_preview_draw { false };
TerminalStripLayoutPattern::DrawStyle m_draw_style { TerminalStripLayoutPattern::DrawStyle::Schematic };
QPointF m_mouse_hover_pos;
QRectF m_united_xref_text_rect;
bool m_last_mouse_pos_in_xrefs_rect{false};
@@ -0,0 +1,48 @@
/*
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 TERMINALSTRIPDRAWERHELPERS_H
#define TERMINALSTRIPDRAWERHELPERS_H
#include "terminalstripdrawer.h"
class QPainter;
/**
* Functions shared by the drawing styles of a terminal strip
* (schematic style in terminalstripdrawer.cpp, realistic style in realisticterminalstripdrawer.cpp).
*/
namespace TerminalStripDrawer
{
namespace Helpers
{
//Width (mm) of the reference terminal (2.5 mm2), used when the width of a terminal is unknown
qreal referenceTerminalWidthMm();
//Fill color and stripe color (can be invalid) of a terminal
void terminalColors(const QSharedPointer<AbstractRealTerminalInterface> &real_t,
QColor &fill, QColor &stripe);
//Black or white, the more readable on background
QColor contrastColor(const QColor &background);
//Number of a terminal : "1" for "XAUZ1:1"
QString terminalNumber(const QString &label);
//Symbol of the type of the terminal (fuse, diode, led...) scaled to fit in box
void drawTypeSymbol(QPainter *painter, const QRectF &box,
ElementData::TerminalType type, bool led);
}
}
#endif // TERMINALSTRIPDRAWERHELPERS_H
@@ -22,6 +22,7 @@
#include "../../project/projectpropertieshandler.h"
#include "../../qetgraphicsitem/qgraphicsitemutility.h"
#include "../terminalstrip.h"
#include "../terminalstripbridge.h"
#include "../physicalterminal.h"
#include "../realterminal.h"
#include "../ui/terminalstripeditorwindow.h"
@@ -38,6 +39,7 @@ TerminalStripItem::TerminalStripItem(QPointer<TerminalStrip> strip,
setFlags(QGraphicsItem::ItemIsMovable | QGraphicsItem::ItemIsSelectable);
setAcceptHoverEvents(true);
setDefaultLayout();
connectStrip(strip);
}
TerminalStripItem::TerminalStripItem(QGraphicsItem *parent) :
@@ -50,6 +52,7 @@ TerminalStripItem::TerminalStripItem(QGraphicsItem *parent) :
void TerminalStripItem::setTerminalStrip(TerminalStrip *strip)
{
m_strip = strip;
connectStrip(strip);
m_drawer.setStrip(QSharedPointer<TerminalStripDrawer::TrueTerminalStrip> {
new TerminalStripDrawer::TrueTerminalStrip { strip }});
m_pending_strip_uuid = QUuid();
@@ -142,12 +145,43 @@ void TerminalStripItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event)
if (m_drawer.needUpdate()) {
update();
}
//The realistic styles don't write the xref : show it in a tool tip
if (m_drawer.drawStyle() != TerminalStripLayoutPattern::DrawStyle::Schematic) {
setToolTip(xrefToolTip(m_drawer.xrefAt(hoverMousePos())));
}
}
void TerminalStripItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event)
{
QetGraphicsItem::hoverLeaveEvent(event);
m_drawer.setMouseHoverPos(QPointF{});
setToolTip(QString());
//Remove the highlight of the xref (or of the terminal) under the mouse
update();
}
/**
* @brief TerminalStripItem::xrefToolTip
* @param terminal : physical and real index of a terminal of the strip
* @return the text of the tool tip of @a terminal : its label and its xref,
* or an empty string if @a terminal is invalid.
*/
QString TerminalStripItem::xrefToolTip(const TerminalStripDrawer::hoverTerminal &terminal) const
{
if (!m_strip || terminal.physical < 0 || terminal.real < 0) {
return QString();
}
const auto physical_terminal{m_strip->physicalTerminal(terminal.physical)};
if (physical_terminal.isNull()) {
return QString();
}
const auto real_terminal{physical_terminal->realTerminal(terminal.real)};
if (real_terminal.isNull() || real_terminal->Xref().isEmpty()) {
return QString();
}
return tr("%1 : %2\nDouble-click to go to the element")
.arg(real_terminal->label(), real_terminal->Xref());
}
void TerminalStripItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
@@ -205,6 +239,60 @@ void TerminalStripItem::setLayout(QSharedPointer<TerminalStripLayoutPattern> lay
m_drawer.setLayout(layout);
}
/**
* @brief TerminalStripItem::setDrawStyle
* Set how this item draw the terminals (schematic, realistic...).
* The style is chosen when the item is added to a folio and saved with the item.
* @param style
*/
void TerminalStripItem::setDrawStyle(TerminalStripLayoutPattern::DrawStyle style)
{
if (style == m_drawer.drawStyle()) {
return;
}
prepareGeometryChange();
m_drawer.setDrawStyle(style);
update();
}
TerminalStripLayoutPattern::DrawStyle TerminalStripItem::drawStyle() const {
return m_drawer.drawStyle();
}
/**
* @brief TerminalStripItem::connectStrip
* Redraw this item as soon as @a strip or one of its terminals changes
* (terminal strip editor applied, undo, redo...).
* The connections to the previous strip are removed.
* @param strip
*/
void TerminalStripItem::connectStrip(TerminalStrip *strip)
{
for (const auto &connection_ : std::as_const(m_strip_connections)) {
disconnect(connection_);
}
m_strip_connections.clear();
if (strip)
{
m_strip_connections << connect(strip, &TerminalStrip::orderChanged, this, &TerminalStripItem::stripChanged)
<< connect(strip, &TerminalStrip::bridgeChanged, this, &TerminalStripItem::stripChanged)
<< connect(strip, &TerminalStrip::bridgeColorChanged, this, &TerminalStripItem::stripChanged)
<< connect(strip, &TerminalStrip::dataChanged, this, &TerminalStripItem::stripChanged);
}
}
/**
* @brief TerminalStripItem::stripChanged
* The strip or one of its terminals changed : the size of the drawing
* can change (width of the terminals, number of terminals...), redraw it.
*/
void TerminalStripItem::stripChanged()
{
prepareGeometryChange();
update();
}
void TerminalStripItem::setDefaultLayout()
{
if (m_strip && m_strip->project()) {
@@ -54,6 +54,8 @@ class TerminalStripItem : public QetGraphicsItem
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
void refreshPending();
void setLayout(QSharedPointer<TerminalStripLayoutPattern> layout);
void setDrawStyle(TerminalStripLayoutPattern::DrawStyle style);
TerminalStripLayoutPattern::DrawStyle drawStyle() const;
/**
* @brief The PdfRef struct
@@ -73,9 +75,13 @@ class TerminalStripItem : public QetGraphicsItem
private:
void setDefaultLayout();
QString xrefToolTip(const TerminalStripDrawer::hoverTerminal &terminal) const;
void connectStrip(TerminalStrip *strip);
void stripChanged();
private:
QPointer<TerminalStrip> m_strip;
QList<QMetaObject::Connection> m_strip_connections;
TerminalStripDrawer::TerminalStripDrawer m_drawer;
QUuid m_pending_strip_uuid;
};
@@ -153,6 +153,31 @@ namespace TerminalStripDrawer
return m_real ? m_real->isShield() : false;
}
QColor TrueRealTerminal::color() const
{
return m_real ? m_real->color() : QColor();
}
QColor TrueRealTerminal::stripeColor() const
{
return m_real ? m_real->stripeColor() : QColor();
}
qreal TrueRealTerminal::widthMm() const
{
return m_real ? m_real->widthMm() : 0;
}
QString TrueRealTerminal::catalogEntry() const
{
return m_real ? m_real->catalogEntry() : QString();
}
bool TrueRealTerminal::hasSeparatorAfter() const
{
return m_real ? m_real->hasSeparatorAfter() : false;
}
TrueBridge::TrueBridge(QSharedPointer<TerminalStripBridge> bridge) :
m_bridge { bridge }
{}
@@ -64,6 +64,11 @@ namespace TerminalStripDrawer
QString cable() const override;
QString cableWire() const override;
bool isShield() const override;
QColor color() const override;
QColor stripeColor() const override;
qreal widthMm() const override;
QString catalogEntry() const override;
bool hasSeparatorAfter() const override;
private:
QSharedPointer<RealTerminal> m_real;
@@ -19,6 +19,7 @@
#include "../../qetgraphicsitem/terminalelement.h"
#include "../realterminal.h"
#include "../physicalterminal.h"
#include "../terminalstrip.h"
/**
* @brief AddTerminalToStripCommand::AddTerminalToStripCommand
@@ -234,3 +235,72 @@ void MoveTerminalCommand::redo()
m_new_strip->addTerminals(m_terminal);
}
}
/**
* @brief AddSpareTerminalsCommand::AddSpareTerminalsCommand
* @param strip : the strip where the spare terminals are inserted
* @param pos : position of the first spare terminal in the strip,
* -1 to add them at the end of the strip
* @param spare_terminals : spare terminals made by RealTerminal::createSpare()
* @param parent : parent undo command
*/
AddSpareTerminalsCommand::AddSpareTerminalsCommand(TerminalStrip *strip, int pos,
const QVector<QSharedPointer<RealTerminal>> &spare_terminals,
QUndoCommand *parent) :
QUndoCommand{parent},
m_strip{strip},
m_pos{pos},
m_spare_terminals{spare_terminals}
{
const auto count = spare_terminals.size();
const auto strip_name = strip ? strip->name() : QString();
setText(strip_name.isEmpty()
? QObject::tr("Add %n spare terminals", "", count)
: QObject::tr("Add %n spare terminals to terminal group %1", "", count).arg(strip_name));
}
void AddSpareTerminalsCommand::undo()
{
if (m_strip) {
m_strip->removeTerminals(m_physical_terminals);
}
}
void AddSpareTerminalsCommand::redo()
{
if (!m_strip) {
return;
}
//The first time the physical terminals are made,
//after an undo the same ones are inserted again
if (m_physical_terminals.isEmpty()) {
m_physical_terminals = m_strip->insertSpareTerminals(m_pos, m_spare_terminals);
} else {
m_strip->insertTerminals(m_pos, m_physical_terminals);
}
}
/**
* @brief ChangeSpareTerminalDataCommand::ChangeSpareTerminalDataCommand
* @param spare_terminal : the spare terminal to change
* @param new_data : the new data of the terminal
* @param parent : parent undo command
*/
ChangeSpareTerminalDataCommand::ChangeSpareTerminalDataCommand(const QSharedPointer<RealTerminal> &spare_terminal,
const ElementData &new_data,
QUndoCommand *parent) :
QUndoCommand{parent},
m_spare_terminal{spare_terminal},
m_old_data{spare_terminal->spareData()},
m_new_data{new_data}
{
setText(QObject::tr("Modify the spare terminal %1").arg(spare_terminal->label()));
}
void ChangeSpareTerminalDataCommand::undo() {
m_spare_terminal->setSpareData(m_old_data);
}
void ChangeSpareTerminalDataCommand::redo() {
m_spare_terminal->setSpareData(m_new_data);
}
@@ -21,6 +21,8 @@
#include <QUndoCommand>
#include <QPointer>
#include <QVector>
#include <QSharedPointer>
#include "../../properties/elementdata.h"
class TerminalElement;
class TerminalStrip;
@@ -89,4 +91,45 @@ class MoveTerminalCommand : public QUndoCommand
};
/**
* @brief The AddSpareTerminalsCommand class
* Insert spare terminals (on no folio) in a terminal strip,
* at a position of the strip.
*/
class AddSpareTerminalsCommand : public QUndoCommand
{
public:
AddSpareTerminalsCommand(TerminalStrip *strip, int pos,
const QVector<QSharedPointer<RealTerminal>> &spare_terminals,
QUndoCommand *parent = nullptr);
void undo() override;
void redo() override;
private:
QPointer<TerminalStrip> m_strip;
int m_pos = -1;
QVector<QSharedPointer<RealTerminal>> m_spare_terminals;
QVector<QSharedPointer<PhysicalTerminal>> m_physical_terminals;
};
/**
* @brief The ChangeSpareTerminalDataCommand class
* Change the data (label, type, section...) of a spare terminal
*/
class ChangeSpareTerminalDataCommand : public QUndoCommand
{
public:
ChangeSpareTerminalDataCommand(const QSharedPointer<RealTerminal> &spare_terminal,
const ElementData &new_data,
QUndoCommand *parent = nullptr);
void undo() override;
void redo() override;
private:
QSharedPointer<RealTerminal> m_spare_terminal;
ElementData m_old_data, m_new_data;
};
#endif // ADDTERMINALTOSTRIPCOMMAND_H
+295 -14
View File
@@ -19,7 +19,10 @@
#include "terminalstrip.h"
#include "../qetgraphicsitem/terminalelement.h"
#include "physicalterminal.h"
#include "terminalcolor.h"
#include "terminalcatalog.h"
#include "../qetgraphicsitem/conductor.h"
#include "../qetinformation.h"
/**
* @brief RealTerminal
@@ -30,6 +33,118 @@ RealTerminal::RealTerminal(Element *terminal) :
m_element(terminal)
{}
/**
* @brief RealTerminal::createSpare
* Create a spare terminal (a reserved terminal) : a terminal of a strip
* which is not drawn on any folio. Its data (label, type, section,
* width...) are its own, stored with the terminal strip.
* @param data : the data of the terminal, only the terminal type,
* function, led and the informations are used.
* @param uuid : the uuid of the spare terminal
* @return the new spare terminal, not yet added to a strip
*/
QSharedPointer<RealTerminal> RealTerminal::createSpare(const ElementData &data, const QUuid &uuid)
{
auto raw_ = new RealTerminal(nullptr);
raw_->m_spare = true;
raw_->m_spare_uuid = uuid.isNull() ? QUuid::createUuid() : uuid;
raw_->m_spare_data.m_type = ElementData::Terminal;
raw_->m_spare_data.setTerminalType(data.terminalType());
raw_->m_spare_data.setTerminalFunction(data.terminalFunction());
raw_->m_spare_data.setTerminalLED(data.terminalLed());
raw_->m_spare_data.m_informations = data.m_informations;
return raw_->sharedRef();
}
/**
* @brief RealTerminal::spareFromXml
* @param xml_element : a real terminal saved by toXml()
* @return the spare terminal saved in @a xml_element,
* or a null pointer if @a xml_element is not a spare terminal.
*/
QSharedPointer<RealTerminal> RealTerminal::spareFromXml(const QDomElement &xml_element)
{
if (xml_element.tagName() != xmlTagName() ||
!xml_element.hasAttribute(QStringLiteral("spare_uuid"))) {
return QSharedPointer<RealTerminal>();
}
ElementData data_;
data_.setTerminalType(ElementData::terminalTypeFromString(
xml_element.attribute(QStringLiteral("type"))));
data_.setTerminalFunction(ElementData::terminalFunctionFromString(
xml_element.attribute(QStringLiteral("function"))));
data_.setTerminalLED(QET::infoFlagIsTrue(xml_element.attribute(QStringLiteral("led"))));
data_.m_informations.fromXml(xml_element.firstChildElement(QStringLiteral("informations")),
QStringLiteral("information"));
return createSpare(data_, QUuid(xml_element.attribute(QStringLiteral("spare_uuid"))));
}
/**
* @brief RealTerminal::isSpare
* @return true if this terminal is a spare terminal, not drawn on any folio
* @sa createSpare
*/
bool RealTerminal::isSpare() const {
return m_spare;
}
/**
* @brief RealTerminal::spareData
* @return the data of this spare terminal, an empty ElementData
* if this terminal is not a spare terminal.
*/
ElementData RealTerminal::spareData() const {
return m_spare_data;
}
/**
* @brief RealTerminal::setSpareData
* Set the data of this spare terminal (nothing is done if this terminal
* is not a spare terminal) and tell the strip, so that it is redrawn.
* @param data
*/
void RealTerminal::setSpareData(const ElementData &data)
{
if (!m_spare) {
return;
}
m_spare_data.setTerminalType(data.terminalType());
m_spare_data.setTerminalFunction(data.terminalFunction());
m_spare_data.setTerminalLED(data.terminalLed());
m_spare_data.m_informations = data.m_informations;
if (auto strip_ = parentStrip()) {
strip_->terminalDataChanged();
}
}
/**
* @brief RealTerminal::hasData
* @return true if this terminal has data : it is linked to an element,
* or it is a spare terminal.
*/
bool RealTerminal::hasData() const {
return m_spare || !m_element.isNull();
}
/**
* @brief RealTerminal::terminalData
* @return the data of this terminal : the data of its element,
* or its own data if it is a spare terminal.
*/
ElementData RealTerminal::terminalData() const
{
if (m_spare) {
return m_spare_data;
}
if (m_element) {
return m_element->elementData();
}
return ElementData();
}
RealTerminal::~RealTerminal()
{
if (m_physical_terminal) {
@@ -79,8 +194,22 @@ QWeakPointer<RealTerminal> RealTerminal::weakRef() {
QDomElement RealTerminal::toXml(QDomDocument &parent_document) const
{
auto root_elmt = parent_document.createElement(this->xmlTagName());
if (m_element)
if (m_spare)
{
//A spare terminal is on no folio : its data are saved here
root_elmt.setAttribute(QStringLiteral("spare_uuid"), m_spare_uuid.toString());
root_elmt.setAttribute(QStringLiteral("type"), ElementData::terminalTypeToString(m_spare_data.terminalType()));
root_elmt.setAttribute(QStringLiteral("function"), ElementData::terminalFunctionToString(m_spare_data.terminalFunction()));
if (m_spare_data.terminalLed()) {
root_elmt.setAttribute(QStringLiteral("led"), QStringLiteral("true"));
}
auto xml_info = parent_document.createElement(QStringLiteral("informations"));
m_spare_data.m_informations.toXml(xml_info, QStringLiteral("information"));
root_elmt.appendChild(xml_info);
}
else if (m_element) {
root_elmt.setAttribute(QStringLiteral("element_uuid"), m_element->uuid().toString());
}
return root_elmt;
}
@@ -134,7 +263,9 @@ int RealTerminal::level() const
* @return the label of this real terminal
*/
QString RealTerminal::label() const {
if (!m_element.isNull()) {
if (m_spare) {
return m_spare_data.m_informations.value(QStringLiteral("label")).toString();
} else if (!m_element.isNull()) {
return m_element->actualLabel();
} else {
return QLatin1String();
@@ -161,8 +292,8 @@ QString RealTerminal::Xref() const
* The value is stored in the informations of the element.
*/
QString RealTerminal::cable() const {
if (m_element) {
return m_element->elementData().m_informations.value(cableInfoKey()).toString();
if (hasData()) {
return terminalData().m_informations.value(cableInfoKey()).toString();
}
return QString();
}
@@ -172,8 +303,8 @@ QString RealTerminal::cable() const {
* @return The color / number of the wire of the cable, connected to this terminal.
*/
QString RealTerminal::cableWire() const {
if (m_element) {
return m_element->elementData().m_informations.value(cableWireInfoKey()).toString();
if (hasData()) {
return terminalData().m_informations.value(cableWireInfoKey()).toString();
}
return QString();
}
@@ -184,9 +315,156 @@ QString RealTerminal::cableWire() const {
* and not one of its wires.
*/
bool RealTerminal::isShield() const {
if (m_element) {
if (hasData()) {
return QET::infoFlagIsTrue(
m_element->elementData().m_informations.value(shieldInfoKey()).toString());
terminalData().m_informations.value(shieldInfoKey()).toString());
}
return false;
}
/**
* @brief RealTerminal::color
* @return The color set by the user for this terminal, drawn in the
* terminal strip. An invalid color mean that the user did not set a color.
* The value is stored in the informations of the element.
* @sa stripeColor
*/
QColor RealTerminal::color() const
{
QColor main_, stripe_;
if (hasData()) {
TerminalColor::parse(terminalData().m_informations.value(colorInfoKey()).toString(),
&main_, &stripe_);
}
return main_;
}
/**
* @brief RealTerminal::stripeColor
* @return The second color of a two-tone color (green/yellow for example)
* or an invalid color if the color of this terminal is plain or not set.
*/
QColor RealTerminal::stripeColor() const
{
QColor main_, stripe_;
if (hasData()) {
TerminalColor::parse(terminalData().m_informations.value(colorInfoKey()).toString(),
&main_, &stripe_);
}
return stripe_;
}
/**
* @brief RealTerminal::section
* @return The section in mm2 set by the user for this terminal
* or 0 if no section was set.
* The value is stored in the informations of the element,
* with the dot as decimal separator, whatever the locale.
*/
qreal RealTerminal::section() const
{
if (hasData())
{
bool ok_ = false;
const auto section_ = terminalData().m_informations.value(sectionInfoKey()).toString().toDouble(&ok_);
if (ok_ && section_ > 0) {
return section_;
}
}
return 0;
}
/**
* @brief RealTerminal::widthMm
* @return The width in mm of this terminal (the width of the terminal
* sold by the manufacturer), set by the user, or 0 if no width was set.
* The value is stored in the informations of the element,
* with the dot as decimal separator, whatever the locale.
*/
qreal RealTerminal::widthMm() const
{
if (hasData())
{
bool ok_ = false;
const auto width_ = terminalData().m_informations.value(widthInfoKey()).toString().toDouble(&ok_);
if (ok_ && width_ > 0) {
return width_;
}
}
return 0;
}
/**
* @brief RealTerminal::catalogEntry
* @return the label of the terminal chosen in the catalog
* (TerminalCatalog::WidthEntry::label), for example "Phoenix Contact ST 4",
* or an empty string if the width was typed or not set.
* Several terminals of the catalog can have the same width,
* the width alone doesn't say which one was chosen.
*/
QString RealTerminal::catalogEntry() const
{
if (hasData()) {
return terminalData().m_informations.value(catalogEntryInfoKey()).toString();
}
return QString();
}
/**
* @brief RealTerminal::applyCatalogEntry
* Store in @a informations the terminal chosen in the catalog,
* and write its manufacturer and its reference in the manufacturer and
* manufacturer reference informations (used by the bill of materials).
* These two informations are only written when they are empty,
* or when they still hold what the previous entry of the catalog wrote :
* a value typed by the user is never overwritten.
* @param informations : the informations of the element
* @param entry_label : label of an entry of the catalog,
* an empty string (or a label unknown by the catalog) removes the entry.
*/
void RealTerminal::applyCatalogEntry(DiagramContext &informations, const QString &entry_label)
{
const auto *old_entry = TerminalCatalog::entryByLabel(
informations.value(catalogEntryInfoKey()).toString());
const auto *new_entry = TerminalCatalog::entryByLabel(entry_label);
if (new_entry) {
informations.addValue(catalogEntryInfoKey(), QString::fromUtf8(new_entry->label));
} else if (informations.contains(catalogEntryInfoKey())) {
informations.remove(catalogEntryInfoKey());
}
const auto update_ = [&informations](const QString &key, const QString &old_value, const QString &new_value)
{
const auto current_ = informations.value(key).toString();
if (!current_.isEmpty() && current_ != old_value) {
return; //Typed by the user, keep it
}
if (!new_value.isEmpty()) {
informations.addValue(key, new_value);
} else if (!current_.isEmpty()) {
informations.addValue(key, QString());
}
};
update_(QETInformation::ELMT_MANUFACTURER,
old_entry ? QString::fromUtf8(old_entry->manufacturer) : QString(),
new_entry ? QString::fromUtf8(new_entry->manufacturer) : QString());
update_(QETInformation::ELMT_MANUFACTURER_REF,
old_entry ? TerminalCatalog::modelName(*old_entry) : QString(),
new_entry ? TerminalCatalog::modelName(*new_entry) : QString());
}
/**
* @brief RealTerminal::hasSeparatorAfter
* @return true if a separator plate (partition plate) is mounted
* after this terminal, between this terminal and the next one of the strip.
* Used to separate groups of terminals (voltages, functions...).
*/
bool RealTerminal::hasSeparatorAfter() const {
if (hasData()) {
return QET::infoFlagIsTrue(
terminalData().m_informations.value(separatorInfoKey()).toString());
}
return false;
}
@@ -211,8 +489,8 @@ QString RealTerminal::conductor() const {
* @return
*/
ElementData::TerminalType RealTerminal::type() const {
if (m_element) {
return m_element->elementData().terminalType();
if (hasData()) {
return terminalData().terminalType();
} else {
return ElementData::TTGeneric;
}
@@ -223,8 +501,8 @@ ElementData::TerminalType RealTerminal::type() const {
* @return
*/
ElementData::TerminalFunction RealTerminal::function() const {
if (m_element) {
return m_element->elementData().terminalFunction();
if (hasData()) {
return terminalData().terminalFunction();
} else {
return ElementData::TFGeneric;
}
@@ -235,8 +513,8 @@ ElementData::TerminalFunction RealTerminal::function() const {
* @return
*/
bool RealTerminal::isLed() const {
if (m_element) {
return m_element->elementData().terminalLed();
if (hasData()) {
return terminalData().terminalLed();
} else {
return false;
}
@@ -293,6 +571,9 @@ Element *RealTerminal::element() const {
* else return a null uuid.
*/
QUuid RealTerminal::elementUuid() const {
if (m_spare) {
return m_spare_uuid; //Used to find the terminal (bridges...) like an element uuid
}
if (!m_element.isNull()) {
return m_element->uuid();
} else {
+31
View File
@@ -19,7 +19,9 @@
#define REALTERMINAL_H
#include <QSharedPointer>
#include <QColor>
#include <QDomElement>
#include <QUuid>
#include "../properties/elementdata.h"
class TerminalStrip;
@@ -55,6 +57,12 @@ class RealTerminal
public:
~RealTerminal();
static QSharedPointer<RealTerminal> createSpare(const ElementData &data, const QUuid &uuid = QUuid());
static QSharedPointer<RealTerminal> spareFromXml(const QDomElement &xml_element);
bool isSpare() const;
ElementData spareData() const;
void setSpareData(const ElementData &data);
ElementData terminalData() const;
TerminalStrip *parentStrip() const noexcept;
QSharedPointer<PhysicalTerminal> physicalTerminal() const noexcept;
@@ -66,11 +74,28 @@ class RealTerminal
QString cable() const;
QString cableWire() const;
bool isShield() const;
QColor color() const;
QColor stripeColor() const;
qreal section() const;
qreal widthMm() const;
QString catalogEntry() const;
bool hasSeparatorAfter() const;
//Keys used to store the cable data in the element informations
static QString cableInfoKey() { return QStringLiteral("terminal_cable"); }
static QString cableWireInfoKey() { return QStringLiteral("terminal_cable_wire"); }
static QString shieldInfoKey() { return QStringLiteral("terminal_shield"); }
//Keys used to store the color and the section (mm2) of the terminal
static QString colorInfoKey() { return QStringLiteral("terminal_color"); }
static QString sectionInfoKey() { return QStringLiteral("terminal_section"); }
//Key used to store the width (mm) of the terminal, drawn in the terminal strip
static QString widthInfoKey() { return QStringLiteral("terminal_width"); }
//Key used to store the terminal chosen in the catalog (TerminalCatalog::WidthEntry::label)
static QString catalogEntryInfoKey() { return QStringLiteral("terminal_catalog_entry"); }
//Key used to store if a separator plate is mounted after the terminal
static QString separatorInfoKey() { return QStringLiteral("terminal_separator"); }
static void applyCatalogEntry(DiagramContext &informations, const QString &entry_label);
QString conductor() const;
@@ -89,7 +114,13 @@ class RealTerminal
static QString xmlTagName();
private :
bool hasData() const;
QPointer<Element> m_element;
//A spare terminal has no element : it is on no folio, its data are its own
bool m_spare = false;
QUuid m_spare_uuid;
ElementData m_spare_data;
QWeakPointer<RealTerminal> m_this_weak;
QSharedPointer<PhysicalTerminal> m_physical_terminal;
};
+186
View File
@@ -0,0 +1,186 @@
/*
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 TERMINALCATALOG_H
#define TERMINALCATALOG_H
#include <QLocale>
#include <QString>
#include <QStringList>
#include <QVector>
/**
* @brief The TerminalCatalog namespace
* Data shared between the terminal strip editor and the element editor :
* usual sections (mm2) and widths (mm) of the terminals sold by the manufacturers.
* Only the values found in the catalogs of the manufacturers are listed,
* any other value can be typed by the user.
* For Legrand and ABB Entrelec the value is the pitch ("pas") of the catalog.
*/
namespace TerminalCatalog
{
/**
* How the wire is clamped in the terminal.
* Used by the realistic drawing of the terminal strip.
*/
enum class Clamp {
Screw, ///< Screw clamp (Phoenix Contact UT, Weidmüller WDU...)
Spring, ///< Spring-cage clamp, opened with a screwdriver (Phoenix Contact ST)
PushIn ///< Push-in clamp (WAGO TOPJOB S)
};
/**
* Width (mm) of a terminal sold by a manufacturer.
* For ABB Entrelec the value is the spacing (pitch) given by the catalog.
*/
struct WidthEntry { double mm; const char *label; double section; const char *manufacturer; Clamp clamp; };
inline const QVector<double> &sections()
{
static const QVector<double> sections_ {0.5, 0.75, 1, 1.5, 2.5, 4, 6, 10, 16, 25, 35, 50, 70, 95, 120, 150, 185, 240, 300};
return sections_;
}
/**
* @brief awgForSection
* @param mm2 : a section in mm2
* @return the nearest AWG / kcmil size, as text ("14", "1/0", "250 kcmil"),
* or an empty string if the section has no usual equivalent.
* The correspondence is approximate (the usual table of the manufacturers),
* the two systems don't have the same steps.
*/
inline QString awgForSection(double mm2)
{
struct Awg { double mm2; const char *awg; };
static const Awg table_[] {
{0.5, "20"}, {0.75, "18"}, {1, "17"},
{1.5, "16"}, {2.5, "14"}, {4, "12"},
{6, "10"}, {10, "8"}, {16, "6"},
{25, "4"}, {35, "2"}, {50, "1/0"},
{70, "2/0"}, {95, "3/0"}, {120, "250 kcmil"},
{150, "300 kcmil"}, {185, "350 kcmil"}, {240, "500 kcmil"},
{300, "600 kcmil"}
};
for (const auto &entry : table_) {
if (qAbs(entry.mm2 - mm2) < 0.0001) {
return QString::fromLatin1(entry.awg);
}
}
return QString();
}
/**
* @brief sectionText
* @param mm2 : a section in mm2
* @param unit_text : the text of the section with its unit, for example "2,5 mm²"
* @return @a unit_text followed by the AWG size if there is one : "2,5 mm² (AWG 14)"
*/
inline QString sectionText(double mm2, const QString &unit_text)
{
const auto awg_ = awgForSection(mm2);
if (awg_.isEmpty()) {
return unit_text;
}
return awg_.contains(QLatin1String("kcmil"))
? unit_text + QStringLiteral(" (%1)").arg(awg_)
: unit_text + QStringLiteral(" (AWG %1)").arg(awg_);
}
inline const QVector<WidthEntry> &widthEntries()
{
static const QVector<WidthEntry> entries_ {
{4.2, "Phoenix Contact ST 1,5", 1.5, "Phoenix Contact", Clamp::Spring},
{5.0, "ABB Entrelec MA 2.5/5", 2.5, "ABB Entrelec", Clamp::Screw},
{5.1, "Weidmüller WDU 2.5", 2.5, "Weidmüller", Clamp::Screw},
{5.2, "WAGO 2002 (TOPJOB S)", 2.5, "WAGO", Clamp::PushIn},
{5.2, "Phoenix Contact ST 2,5 / ST 2,5-QUATTRO", 2.5, "Phoenix Contact", Clamp::Spring},
{6.0, "ABB Entrelec M 4/6", 4.0, "ABB Entrelec", Clamp::Screw},
{6.0, "Legrand Viking 3 (4 mm²)", 4.0, "Legrand", Clamp::Screw},
{6.1, "Weidmüller WDU 4", 4.0, "Weidmüller", Clamp::Screw},
{6.2, "Phoenix Contact UT 4", 4.0, "Phoenix Contact", Clamp::Screw},
{6.2, "Phoenix Contact ST 4", 4.0, "Phoenix Contact", Clamp::Spring},
{7.5, "WAGO 2006 (TOPJOB S)", 6.0, "WAGO", Clamp::PushIn},
{7.9, "Weidmüller WDU 6", 6.0, "Weidmüller", Clamp::Screw},
{8.0, "ABB Entrelec M 6/8", 6.0, "ABB Entrelec", Clamp::Screw},
{8.0, "ABB Entrelec M 4/8", 4.0, "ABB Entrelec", Clamp::Screw},
{8.2, "Phoenix Contact UT 6", 6.0, "Phoenix Contact", Clamp::Screw},
{10.0, "ABB Entrelec M 10/10", 10.0, "ABB Entrelec", Clamp::Screw},
{10.2, "Phoenix Contact UT 10", 10.0, "Phoenix Contact", Clamp::Screw},
{11.9, "Weidmüller WDU 16/ZA", 16.0, "Weidmüller", Clamp::Screw},
{12.0, "ABB Entrelec M 16/12", 16.0, "ABB Entrelec", Clamp::Screw},
{12.0, "Legrand Viking 3 (16 mm²)", 16.0, "Legrand", Clamp::Screw},
{12.2, "Phoenix Contact UT 16", 16.0, "Phoenix Contact", Clamp::Screw},
{16.0, "ABB Entrelec M 35/16", 35.0, "ABB Entrelec", Clamp::Screw},
{16.0, "Phoenix Contact UT 35", 35.0, "Phoenix Contact", Clamp::Screw},
{16.0, "Weidmüller WDU 35N", 35.0, "Weidmüller", Clamp::Screw},
{26.0, "ABB Entrelec M 35/26.FF", 35.0, "ABB Entrelec", Clamp::Screw},
{31.0, "ABB Entrelec M 70/31.AF", 70.0, "ABB Entrelec", Clamp::Screw},
{35.0, "ABB Entrelec M 120/35.F", 120.0, "ABB Entrelec", Clamp::Screw},
{42.0, "ABB Entrelec M 300/42.FF", 300.0, "ABB Entrelec", Clamp::Screw}
};
return entries_;
}
/// Manufacturers of the catalog, in the order of the first entry.
inline QStringList manufacturers()
{
QStringList list_;
for (const auto &entry : widthEntries()) {
const auto name_ = QString::fromUtf8(entry.manufacturer);
if (!list_.contains(name_)) {
list_ << name_;
}
}
return list_;
}
/**
* @brief entryByLabel
* @param label : the label of an entry, as stored in the element
* informations (RealTerminal::catalogEntryInfoKey())
* @return the entry of the catalog with this label, or nullptr
*/
inline const WidthEntry *entryByLabel(const QString &label)
{
if (label.isEmpty()) {
return nullptr;
}
for (const auto &entry : widthEntries()) {
if (label == QString::fromUtf8(entry.label)) {
return &entry;
}
}
return nullptr;
}
/**
* @brief modelName
* @param entry
* @return the label of @a entry without the name of its manufacturer :
* "ST 4" for "Phoenix Contact ST 4"
*/
inline QString modelName(const WidthEntry &entry)
{
const auto label_ = QString::fromUtf8(entry.label);
const auto manufacturer_ = QString::fromUtf8(entry.manufacturer);
if (label_.startsWith(manufacturer_ + QLatin1Char(' '))) {
return label_.mid(manufacturer_.size() + 1);
}
return label_;
}
}
#endif // TERMINALCATALOG_H
+184
View File
@@ -0,0 +1,184 @@
/*
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 TERMINALCOLOR_H
#define TERMINALCOLOR_H
#include <QColor>
#include <QCoreApplication>
#include <QPainter>
#include <QPixmap>
#include <QPolygonF>
#include <QString>
#include <QVector>
/**
* Colors of the terminals of a terminal strip.
*
* A color is stored as a string : "#rrggbb" for a plain color
* or "#rrggbb/#rrggbb" for a two-tone color (the second color is drawn
* as stripes over the first one, like the green/yellow of the earth).
* An empty string means "no color set".
*/
namespace TerminalColor
{
struct Entry
{
const char *english; //Source text of the translation
const char *french; //Also accepted when typed or pasted
const char *main;
const char *stripe; //nullptr if the color is plain
};
inline const QVector<Entry> &palette()
{
static const QVector<Entry> palette_ {
{"Gray", "gris", "#9e9e9e", nullptr},
{"Blue", "bleu", "#4a90d9", nullptr},
{"Green/Yellow", "vert/jaune", "#ffd800", "#2e9e2e"},
{"Brown", "marron", "#8b5a2b", nullptr},
{"Black", "noir", "#303030", nullptr},
{"Red", "rouge", "#d32f2f", nullptr},
{"Orange", "orange", "#f57c00", nullptr},
{"Yellow", "jaune", "#ffd800", nullptr},
{"Green", "vert", "#2e9e2e", nullptr},
{"Violet", "violet", "#7b3fa0", nullptr},
{"White", "blanc", "#ffffff", nullptr}
};
return palette_;
}
inline QString translatedName(const Entry &entry) {
return QCoreApplication::translate("TerminalColor", entry.english);
}
inline QString toSpec(const QColor &main, const QColor &stripe = QColor())
{
if (!main.isValid()) {
return QString();
}
return stripe.isValid() ? main.name() + QLatin1Char('/') + stripe.name()
: main.name();
}
inline QString specOf(const Entry &entry) {
return toSpec(QColor(QLatin1String(entry.main)),
entry.stripe ? QColor(QLatin1String(entry.stripe)) : QColor());
}
/**
* @brief parse
* Read a color from a text : a name of the palette (in english, in french
* or translated), a color "#rrggbb" or "#rrggbb/#rrggbb".
* An empty text is valid and gives invalid colors (no color).
* @return true if the text was understood
*/
inline bool parse(const QString &text, QColor *main, QColor *stripe)
{
*main = QColor();
*stripe = QColor();
const auto text_ = text.trimmed();
if (text_.isEmpty()) {
return true;
}
for (const auto &entry : palette())
{
if (text_.compare(QLatin1String(entry.english), Qt::CaseInsensitive) == 0 ||
text_.compare(QLatin1String(entry.french), Qt::CaseInsensitive) == 0 ||
text_.compare(translatedName(entry), Qt::CaseInsensitive) == 0)
{
*main = QColor(QLatin1String(entry.main));
if (entry.stripe) {
*stripe = QColor(QLatin1String(entry.stripe));
}
return true;
}
}
const auto parts_ = text_.split(QLatin1Char('/'));
if (parts_.size() == 2)
{
const QColor c1(parts_.at(0).trimmed());
const QColor c2(parts_.at(1).trimmed());
if (c1.isValid() && c2.isValid()) {
*main = c1;
*stripe = c2;
return true;
}
return false;
}
const QColor color_(text_);
if (color_.isValid()) {
*main = color_;
return true;
}
return false;
}
/**
* @return The name to display for a color : the translated name
* if the color is in the palette, else the color as text.
*/
inline QString displayName(const QColor &main, const QColor &stripe)
{
const auto spec_ = toSpec(main, stripe);
if (spec_.isEmpty()) {
return QString();
}
for (const auto &entry : palette()) {
if (specOf(entry) == spec_) {
return translatedName(entry);
}
}
return spec_;
}
/**
* @return A little pixmap of the color (the stripe color
* fills the upper right half for a two-tone color)
*/
inline QPixmap swatch(const QColor &main, const QColor &stripe, int size = 16)
{
QPixmap pixmap_(size, size);
pixmap_.fill(Qt::transparent);
if (!main.isValid()) {
return pixmap_;
}
QPainter painter_(&pixmap_);
painter_.setRenderHint(QPainter::Antialiasing);
painter_.setPen(Qt::NoPen);
painter_.setBrush(main);
painter_.drawRect(0, 0, size, size);
if (stripe.isValid())
{
painter_.setBrush(stripe);
QPolygonF polygon_;
polygon_ << QPointF(0, 0) << QPointF(size, 0) << QPointF(size, size);
painter_.drawPolygon(polygon_);
}
painter_.setPen(QPen(Qt::gray, 1));
painter_.setBrush(Qt::NoBrush);
painter_.drawRect(0, 0, size - 1, size - 1);
return pixmap_;
}
}
#endif // TERMINALCOLOR_H
+89 -5
View File
@@ -100,6 +100,17 @@ TerminalStripData TerminalStrip::data() const {
*/
void TerminalStrip::setData(const TerminalStripData &data) {
m_data = data;
emit dataChanged();
}
/**
* @brief TerminalStrip::terminalDataChanged
* Called when the data of the element of one of the terminals of this strip
* is changed (Element::setElementData(), Element::setElementInformations()) :
* the items which draw this strip on the folios are redrawn.
*/
void TerminalStrip::terminalDataChanged() {
emit dataChanged();
}
bool TerminalStrip::addTerminal(QSharedPointer<RealTerminal> real_t)
@@ -381,6 +392,60 @@ void TerminalStrip::removeTerminals(QVector<QSharedPointer<PhysicalTerminal> > p
}
}
/**
* @brief TerminalStrip::insertSpareTerminals
* Insert in this strip, at position @a pos, one physical terminal
* for each spare terminal of @a spare_terminals.
* @param pos : position of the first inserted terminal,
* out of range (-1...) to add the terminals at the end of the strip.
* @param spare_terminals : spare terminals not owned by a strip
* (RealTerminal::createSpare())
* @return the inserted physical terminals
*/
QVector<QSharedPointer<PhysicalTerminal>> TerminalStrip::insertSpareTerminals(int pos, const QVector<QSharedPointer<RealTerminal>> &spare_terminals)
{
QVector<QSharedPointer<PhysicalTerminal>> phy_vector;
for (const auto &real_t : spare_terminals)
{
if (real_t && real_t->isSpare() && !real_t->parentStrip()) {
auto raw_phy_ptr = new PhysicalTerminal(nullptr, QVector<QSharedPointer<RealTerminal>>{real_t});
phy_vector.append(raw_phy_ptr->sharedRef());
}
}
insertTerminals(pos, phy_vector);
return phy_vector;
}
/**
* @brief TerminalStrip::insertTerminals
* Insert @a phy_t_vector in this strip at position @a pos, in this order.
* The physical terminals already owned by a strip are ignored.
* @param pos : position of the first inserted terminal,
* out of range (-1...) to add the terminals at the end of the strip.
* @param phy_t_vector
*/
void TerminalStrip::insertTerminals(int pos, const QVector<QSharedPointer<PhysicalTerminal>> &phy_t_vector)
{
if (pos < 0 || pos > m_physical_terminals.size()) {
pos = m_physical_terminals.size();
}
bool added_{false};
for (const auto &phy_t : phy_t_vector)
{
if (!phy_t || phy_t->terminalStrip()) {
continue;
}
m_physical_terminals.insert(pos++, phy_t);
phy_t->setParentStrip(this);
added_ = true;
}
if (added_) {
emit orderChanged();
}
}
/**
* @brief TerminalStrip::pos
* @param terminal
@@ -1011,17 +1076,23 @@ bool TerminalStrip::fromXml(QDomElement &xml_element)
auto xml_layout = xml_element.firstChildElement(QStringLiteral("layout"));
if (!xml_layout.isNull())
{
//The folios holding its terminals are built (QET_LAZY_FOLIOS):
//The folios holding its terminals are built (lazy folios):
//a strip is read with the project, before the database could
//tell where any symbol is
QSet<QUuid> terminals;
const QDomNodeList xml_reals = xml_layout.elementsByTagName(RealTerminal::xmlTagName());
for (int i = 0 ; i < xml_reals.size() ; ++i)
terminals.insert(QUuid(xml_reals.at(i).toElement().attribute(QStringLiteral("element_uuid"))));
for (int i = 0 ; i < xml_reals.size() ; ++i) {
//A spare terminal is on no folio
const auto element_uuid_ = xml_reals.at(i).toElement().attribute(QStringLiteral("element_uuid"));
if (!element_uuid_.isEmpty()) {
terminals.insert(QUuid(element_uuid_));
}
}
m_project->buildFoliosHoldingSymbols(terminals);
//Get all free elements terminal of the project
const ElementProvider ep(m_project);
//Get all free elements terminal of the project: its own are on
//the folios just built
const ElementProvider ep(m_project->builtFolios());
auto free_terminals = ep.freeTerminal();
//Read each physical terminal
@@ -1032,7 +1103,14 @@ bool TerminalStrip::fromXml(QDomElement &xml_element)
//Read each real terminal of the current physical terminal of the loop
for (auto &xml_real : QETXML::findInDomElement(xml_physical, RealTerminal::xmlTagName()))
{
//A spare terminal (on no folio) has its own data
if (auto spare_ = RealTerminal::spareFromXml(xml_real)) {
real_t_vector.append(spare_);
continue;
}
const auto uuid_ = QUuid(xml_real.attribute(QStringLiteral("element_uuid")));
bool found = false;
for (auto terminal_elmt : std::as_const(free_terminals))
{
if (terminal_elmt->uuid() == uuid_)
@@ -1041,9 +1119,15 @@ bool TerminalStrip::fromXml(QDomElement &xml_element)
//Remove the actual terminal element from the vector, they dicrease the size
//of the vector and so each iteration have less terminal element to check
free_terminals.removeOne(terminal_elmt);
found = true;
break;
}
}
if (!found) {
qWarning() << "TerminalStrip::fromXml: strip" << name()
<< "lists terminal" << uuid_.toString()
<< "but no free terminal symbol has this uuid; it is left out";
}
}
if (!real_t_vector.isEmpty()) {
+8
View File
@@ -53,6 +53,9 @@ class TerminalStrip : public QObject
void orderChanged(); //Emitted when the order of the physical terminal is changed
void bridgeChanged();
void bridgeColorChanged(QSharedPointer<TerminalStripBridge> bridge);
//Emitted when the data of the strip (name...) or of one of its
//terminals (width, color, type...) is changed : what is drawn changes
void dataChanged();
public:
TerminalStrip(QETProject *project);
@@ -72,11 +75,14 @@ class TerminalStrip : public QObject
QString comment() const {return m_data.m_comment;}
void setDescription(const QString &description);
QString description() const {return m_data.m_description;}
///Manufacturer of the terminals proposed by the editor, empty = all
QString manufacturer() const {return m_data.m_manufacturer;}
QUuid uuid() const {return m_data.m_uuid;}
QETProject *project() const;
TerminalStripData data() const;
void setData(const TerminalStripData &data);
void terminalDataChanged();
bool addTerminal (QSharedPointer<RealTerminal> real_t);
void addTerminals (QVector<QSharedPointer<RealTerminal>> real_t_vector);
@@ -89,6 +95,8 @@ class TerminalStrip : public QObject
void addTerminals (QVector<QSharedPointer<PhysicalTerminal>> phy_t_vector);
bool removeTerminal (QSharedPointer<PhysicalTerminal> phy_t);
void removeTerminals (QVector<QSharedPointer<PhysicalTerminal>> phy_t_vector);
QVector<QSharedPointer<PhysicalTerminal>> insertSpareTerminals(int pos, const QVector<QSharedPointer<RealTerminal>> &spare_terminals);
void insertTerminals(int pos, const QVector<QSharedPointer<PhysicalTerminal>> &phy_t_vector);
int pos(const QSharedPointer<PhysicalTerminal> &terminal) const;
int physicalTerminalCount() const;
@@ -0,0 +1,293 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "terminalstripautoimport.h"
#include "../elementprovider.h"
#include "../qetgraphicsitem/terminalelement.h"
#include "../qetinformation.h"
#include "../qetproject.h"
#include "../utils/qetutils.h"
#include "UndoCommand/addterminalstripcommand.h"
#include "UndoCommand/addterminaltostripcommand.h"
#include "UndoCommand/sortterminalstripcommand.h"
#include "physicalterminal.h"
#include "realterminal.h"
#include "terminalstrip.h"
#include <QCoreApplication>
#include <QMap>
#include <QUndoStack>
namespace
{
/**
* Terminals going to one strip : a label prefix plus the plant and
* the location typed on the terminals. A plant or location set on
* the terminals must match the strip exactly, an empty one accepts
* every strip of the same name (in most projects the plant and the
* location live on the strip, not on the terminals).
*/
struct StripGroup
{
QVector<QSharedPointer<RealTerminal>> free_terminals;
QVector<QPair<QSharedPointer<RealTerminal>, TerminalStrip *>> placed_terminals;
QString name, plant, location;
};
QString elementInfo(const QSharedPointer<RealTerminal> &terminal, const QString &key)
{
auto element_ = terminal->element();
if (!element_) {
return QString();
}
return element_->elementData().m_informations.value(key).toString().trimmed();
}
//An empty plant is saved and read back as "=" and an empty
//location as "+" (the placeholders of the title block) : all
//three spellings mean "no plant" / "no location"
QString normalizedPlant(const QString &plant_)
{
const auto value_ = plant_.trimmed();
return (value_.isEmpty() || value_ == QLatin1String("="))
? QString() : value_;
}
QString normalizedLocation(const QString &location_)
{
const auto value_ = location_.trimmed();
return (value_.isEmpty() || value_ == QLatin1String("+"))
? QString() : value_;
}
QString groupKey(const QString &name, const QString &plant, const QString &location) {
return name + QChar(0x1f) + plant + QChar(0x1f) + location;
}
/**
* @brief stripOf
* The strip of the group : same name, and same plant and location
* when the terminals carry some. Empty plant and location on the
* terminals mean "no opinion" : any strip of the name is reused,
* the one already holding terminals winning over an empty one.
* @return the strip to work on, null when none has to exist yet.
*/
TerminalStrip *stripOf(QETProject *project, const QString &name,
const QString &plant, const QString &location)
{
const bool strict_ = !plant.isEmpty() || !location.isEmpty();
TerminalStrip *best_ = nullptr;
int best_score_ = -1;
for (auto strip_ : project->terminalStrip())
{
if (!strip_ || strip_->name() != name) {
continue;
}
const bool same_ =
normalizedPlant(strip_->installation()) == plant &&
normalizedLocation(strip_->location()) == location;
if (strict_ && !same_) {
continue;
}
//Same plant and location first, then the strip already
//holding terminals over an empty one
const int score_ = (same_ ? 4 : 0)
+ (strip_->realTerminals().isEmpty() ? 0 : 1);
if (score_ > best_score_) {
best_ = strip_;
best_score_ = score_;
}
}
return best_;
}
}
/**
* @brief TerminalStripAutoImport::importFreeTerminals
* Put the terminals of @a project in the strip named after their label
* prefix ("X1:2" goes to the strip "X1"), creating the strip when it
* does not exist yet. The whole run is one undoable step.
*
* @a move_placed picks the action : false only places free terminals,
* placed ones are never touched. True also moves a placed terminal
* whose label prefix or plant and location no longer match its strip.
*
* In both cases the order the user gave to a strip is never changed,
* only a strip created by the run itself is sorted.
*/
TerminalStripAutoImport::ImportResult TerminalStripAutoImport::importFreeTerminals(
QETProject *project, bool move_placed)
{
if (!project) {
return ImportResult{};
}
//Group every real terminal of the project, the free ones and
//the ones already in a strip alike
QMap<QString, StripGroup> groups_;
const auto collect_ = [&groups_](const QSharedPointer<RealTerminal> &real_,
TerminalStrip *current_)
{
if (!real_ || !real_->element()) {
return; //Spare terminals (no element) are left alone
}
const auto name_ = stripNameForLabel(real_->label());
if (name_.isEmpty()) {
return;
}
const auto plant_ = normalizedPlant(
elementInfo(real_, QETInformation::ELMT_PLANT));
const auto location_ = normalizedLocation(
elementInfo(real_, QETInformation::ELMT_LOCATION));
auto &group_ = groups_[groupKey(name_, plant_, location_)];
group_.name = name_;
group_.plant = plant_;
group_.location = location_;
if (current_) {
group_.placed_terminals.append({real_, current_});
} else {
group_.free_terminals.append(real_);
}
};
const ElementProvider provider(project);
for (auto terminal_ : provider.freeTerminal()) {
if (terminal_) {
collect_(terminal_->realTerminal(), nullptr);
}
}
for (auto strip_ : project->terminalStrip())
{
if (!strip_) {
continue;
}
for (const auto &real_ : strip_->realTerminals()) {
collect_(real_, strip_);
}
}
if (groups_.isEmpty()) {
return ImportResult{};
}
for (auto it_ = groups_.begin(); it_ != groups_.end(); ++it_)
{
auto by_label_ = [](const QSharedPointer<RealTerminal> &a_,
const QSharedPointer<RealTerminal> &b_) {
return QETUtils::naturalLessThan(a_->label(), b_->label());
};
std::sort(it_.value().free_terminals.begin(),
it_.value().free_terminals.end(), by_label_);
std::sort(it_.value().placed_terminals.begin(),
it_.value().placed_terminals.end(),
[&by_label_](const auto &a_, const auto &b_) {
return by_label_(a_.first, b_.first);
});
}
auto names_ = groups_.keys();
std::sort(names_.begin(), names_.end(), QETUtils::naturalLessThan);
//First phase : work out what has to change, without touching the
//project. When there is nothing to do, nothing is pushed : no
//undo step is recorded and the project stays untouched.
struct Plan
{
const StripGroup *group{nullptr};
TerminalStrip *strip{nullptr}; //Target strip, null : to create
};
QVector<Plan> plans;
for (const auto &key_ : std::as_const(names_))
{
auto &group_ = groups_[key_];
Plan plan_;
plan_.group = &group_;
plan_.strip = stripOf(project, group_.name, group_.plant, group_.location);
//Free terminals always, placed ones only when the action
//is allowed to re-group them
bool changed_ = !group_.free_terminals.isEmpty();
if (move_placed) {
for (const auto &entry_ : std::as_const(group_.placed_terminals)) {
changed_ = changed_ || entry_.second != plan_.strip;
}
}
if (changed_) {
plans.append(plan_);
}
}
if (plans.isEmpty()) {
return ImportResult{};
}
//Second phase : the changes, as one undoable step
int added_ = 0, moved_ = 0, created_strips_ = 0;
auto undo_ = project->undoStack();
undo_->beginMacro(move_placed
? QCoreApplication::translate("TerminalStripAutoImport",
"Re-group all terminals")
: QCoreApplication::translate("TerminalStripAutoImport",
"Assign free terminals"));
for (const auto &plan_ : std::as_const(plans))
{
const auto &group_ = *plan_.group;
bool created_ = false;
auto strip_ = plan_.strip;
if (!strip_) {
strip_ = project->newTerminalStrip(
group_.plant, group_.location, group_.name);
undo_->push(new AddTerminalStripCommand(strip_, project));
++created_strips_;
created_ = true;
}
if (!group_.free_terminals.isEmpty()) {
undo_->push(new AddTerminalToStripCommand(group_.free_terminals, strip_));
added_ += group_.free_terminals.size();
}
//A terminal whose label prefix or plant and location no
//longer match its strip moves to the right one, but only
//when the action is allowed to re-group placed terminals
if (move_placed)
{
for (const auto &entry_ : std::as_const(group_.placed_terminals))
{
if (entry_.second == strip_) {
continue;
}
undo_->push(new MoveTerminalCommand(
entry_.first->physicalTerminal(), entry_.second, strip_));
++moved_;
}
}
//A strip just created is sorted, a strip already known
//keeps the order its user gave it
if (created_) {
undo_->push(new SortTerminalStripCommand(strip_));
}
}
undo_->endMacro();
ImportResult result_;
result_.terminals = added_ + moved_;
result_.strips = created_strips_;
return result_;
}
@@ -0,0 +1,103 @@
/*
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 TERMINALSTRIPAUTOIMPORT_H
#define TERMINALSTRIPAUTOIMPORT_H
#include <QString>
class QETProject;
/**
* The TerminalStripAutoImport namespace works out the terminal strips of a
* project from the labels of its free terminals, so that the user does not
* have to create the strips and move the terminals one by one.
*
* The rule : the name of a strip is the label of its terminals up to the
* first colon, "X1:2" giving the strip "X1", as numbered by the terminal
* numbering dialog. Terminals whose label holds no colon use the whole
* label, terminals with no label are left alone.
*
* The plant and the location work as follows : they are read from the
* terminal and only decide which strip to pick when they are set. An
* existing strip with the same name is reused as soon as they are empty
* (in most projects the plant and the location live on the strip, not on
* the terminals), while a set plant or location must match exactly, a
* strip being created when no one matches. That way the import never
* builds a second strip for a name that already exists.
*
* Two actions run this import :
* - the default one places free terminals only, it never moves or
* re-sorts a terminal the user already assigned to a strip;
* - "re-group all terminals" also moves a placed terminal whose label
* prefix or plant and location no longer match its strip, for the case
* where the user corrects a terminal afterwards. Existing strips are
* never re-sorted.
*
* The import is idempotent : a second run adds nothing, the terminals
* of a strip the user ordered or completed by hand are never re-ordered,
* only new terminals are added (at the end of the strip). Only a strip
* created by the import itself is sorted. When nothing has to change,
* nothing is pushed to the undo stack : the project stays untouched.
*/
namespace TerminalStripAutoImport
{
struct ImportResult
{
int terminals = 0; ///< Terminals added to a strip, moved ones included
int strips = 0; ///< Terminal strips created
};
/**
* @brief stripNameForLabel
* @param label : the label of a terminal, for example "X1:2"
* @return the name of the strip holding this terminal, "X1", or an
* empty string when the label holds nothing usable.
*/
inline QString stripNameForLabel(const QString &label)
{
const auto label_ = label.trimmed();
if (label_.isEmpty()) {
return QString();
}
const auto colon_ = label_.indexOf(QLatin1Char(':'));
if (colon_ > 0) {
return label_.left(colon_).trimmed();
}
if (colon_ == 0) {
return QString(); //Label starts with ":" : no name before it
}
return label_; //No colon : the whole label names the strip
}
/**
* @brief importFreeTerminals
* Add every free terminal of @a project to the terminal strip named
* after its label prefix, creating the strip when it does not exist
* yet. Everything happens on the undo stack, as one undoable step.
* @param move_placed : false (default, the "assign free terminals"
* action) only places free terminals, placed ones are never touched.
* True (the "re-group all terminals" action) also moves a placed
* terminal whose label prefix or plant and location no longer match
* its strip. Existing strips are never re-sorted.
* @return What has been done. Empty : nothing was pushed, the
* project is untouched.
*/
ImportResult importFreeTerminals(QETProject *project, bool move_placed = false);
}
#endif // TERMINALSTRIPAUTOIMPORT_H
@@ -52,6 +52,9 @@ QDomElement TerminalStripData::toXml(QDomDocument &xml_document) const
if (!m_description.isEmpty()) {
info_elmt.appendChild(infoToXml(xml_document, QStringLiteral("description"), m_description));
}
if (!m_manufacturer.isEmpty()) {
info_elmt.appendChild(infoToXml(xml_document, QStringLiteral("manufacturer"), m_manufacturer));
}
return root_elmt;
}
@@ -79,6 +82,7 @@ bool TerminalStripData::fromXml(const QDomElement &xml_element)
else if (name == QLatin1String("name")) {m_name = value;}
else if (name == QLatin1String("comment")) {m_comment = value;}
else if (name == QLatin1String("description")) {m_description = value;}
else if (name == QLatin1String("manufacturer")) {m_manufacturer = value;}
}
return true;
@@ -91,6 +95,7 @@ TerminalStripData &TerminalStripData::operator=(const TerminalStripData &other)
m_name = other.m_name;
m_comment = other.m_comment;
m_description = other.m_description;
m_manufacturer = other.m_manufacturer;
m_uuid = other.m_uuid;
return *this;
+4 -3
View File
@@ -44,11 +44,12 @@ class TerminalStripData : public PropertiesInterface
private :
static QDomElement infoToXml(QDomDocument &xml_doc, const QString &name, const QString &value);
QString m_installation = QStringLiteral("="),
m_location = QStringLiteral("+"),
QString m_installation,
m_location,
m_name,
m_comment,
m_description;
m_description,
m_manufacturer; //Manufacturer of the terminals proposed in the editor, empty = all
QUuid m_uuid = QUuid::createUuid();
};
@@ -24,6 +24,8 @@
#include "../../diagram.h"
#include "../../qet.h"
#include <algorithm>
void AddTerminalStripItemDialog::openDialog(Diagram *diagram, QWidget *parent)
{
AddTerminalStripItemDialog d(diagram->project(), parent);
@@ -33,6 +35,7 @@ void AddTerminalStripItemDialog::openDialog(Diagram *diagram, QWidget *parent)
if (strip_)
{
auto item_ = new TerminalStripItem(strip_);
item_->setDrawStyle(static_cast<TerminalStripLayoutPattern::DrawStyle>(d.selectedDrawStyle()));
diagram->addItem(item_);
item_->setPos(50, 50);
@@ -77,6 +80,16 @@ TerminalStrip *AddTerminalStripItemDialog::selectedTerminalStrip() const
return nullptr;
}
/**
* @brief AddTerminalStripItemDialog::selectedDrawStyle
* @return The drawing style chosen for the new item,
* as an int value of TerminalStripLayoutPattern::DrawStyle.
* The index of the combo box is the value of the enum.
*/
int AddTerminalStripItemDialog::selectedDrawStyle() const {
return std::clamp(ui->m_draw_style_cb->currentIndex(), 0, 2);
}
void AddTerminalStripItemDialog::fillComboBox()
{
if (m_project)
@@ -41,6 +41,7 @@ class AddTerminalStripItemDialog : public QDialog
explicit AddTerminalStripItemDialog(QETProject *project, QWidget *parent = nullptr);
~AddTerminalStripItemDialog();
TerminalStrip *selectedTerminalStrip() const;
int selectedDrawStyle() const;
void fillComboBox();
private:
@@ -24,6 +24,35 @@
<item>
<widget class="QComboBox" name="m_terminal_strip_cb"/>
</item>
<item>
<widget class="QLabel" name="label_draw_style">
<property name="text">
<string>Drawing style :</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="m_draw_style_cb">
<property name="toolTip">
<string>How the terminals are drawn. The realistic styles draw the strip at scale, seen from above, with the real width of each terminal.</string>
</property>
<item>
<property name="text">
<string>Schematic</string>
</property>
</item>
<item>
<property name="text">
<string>Realistic (push-in)</string>
</property>
</item>
<item>
<property name="text">
<string>Realistic (screw)</string>
</property>
</item>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
@@ -18,6 +18,7 @@
#ifndef MODELTERMINALDATA_H
#define MODELTERMINALDATA_H
#include <QColor>
#include <QString>
#include "../../qetgraphicsitem/element.h"
#include "../realterminal.h"
@@ -37,11 +38,18 @@ struct modelRealTerminalData
mrtd.conductor_ = real_t->conductor();
mrtd.led_ = real_t->isLed();
mrtd.shield_ = real_t->isShield();
mrtd.separator_ = real_t->hasSeparatorAfter();
mrtd.color_ = real_t->color();
mrtd.stripe_color_ = real_t->stripeColor();
mrtd.section_ = real_t->section();
mrtd.width_mm_ = real_t->widthMm();
mrtd.catalog_entry_ = real_t->catalogEntry();
mrtd.type_ = real_t->type();
mrtd.function_ = real_t->function();
mrtd.element_ = real_t->element();
mrtd.real_terminal = real_t.toWeakRef();
mrtd.bridged_ = real_t->isBridged();
mrtd.spare_ = real_t->isSpare();
}
return mrtd;
@@ -55,7 +63,14 @@ struct modelRealTerminalData
QString conductor_;
bool led_ = false;
bool shield_ = false;
bool separator_ = false; //A separator plate is mounted after the terminal
QColor color_; //Invalid = no color set by the user
QColor stripe_color_; //Second color of a two-tone color, invalid if plain
qreal section_ = 0; //In mm2, 0 = no section set by the user
qreal width_mm_ = 0; //In mm, 0 = no width set by the user
QString catalog_entry_; //Terminal chosen in the catalog, empty if the width was typed
bool bridged_ = false;
bool spare_ = false; //Spare terminal, on no folio (RealTerminal::isSpare())
ElementData::TerminalType type_ = ElementData::TerminalType::TTGeneric;
ElementData::TerminalFunction function_ = ElementData::TerminalFunction::TFGeneric;
+258 -26
View File
@@ -31,10 +31,21 @@
#include "../UndoCommand/changeterminalstripcolor.h"
#include "../physicalterminal.h"
#include "../terminalstripbridge.h"
#include "../terminalcolor.h"
#include "../terminalcatalog.h"
#include "../realterminal.h"
#include "../../qetinformation.h"
#include <QApplication>
#include <QCheckBox>
#include <QClipboard>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QKeyEvent>
#include <QLineEdit>
#include <QRegularExpression>
#include <QSpinBox>
#include <algorithm>
#include <limits>
@@ -51,7 +62,18 @@ TerminalStripEditor::TerminalStripEditor(QETProject *project, QWidget *parent) :
{
ui->setupUi(this);
ui->m_table_widget->setItemDelegate(new TerminalStripModelDelegate{this});
auto delegate_ = new TerminalStripModelDelegate{this};
ui->m_table_widget->setItemDelegate(delegate_);
//Manufacturer of the terminals proposed in the Width column
ui->m_manufacturer_cb->addItem(tr("All manufacturers"), QString());
for (const auto &name_ : TerminalCatalog::manufacturers()) {
ui->m_manufacturer_cb->addItem(name_, name_);
}
connect(ui->m_manufacturer_cb, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, [this, delegate_]() {
delegate_->setManufacturer(ui->m_manufacturer_cb->currentData().toString());
});
//Copy, paste and delete of the cells with the keyboard
ui->m_table_widget->installEventFilter(this);
@@ -116,6 +138,7 @@ void TerminalStripEditor::setCurrentStrip(TerminalStrip *strip_)
ui->m_name_le ->setText(strip_->name());
ui->m_comment_le ->setText(strip_->comment());
ui->m_description_te ->setPlainText(strip_->description());
setManufacturerFilter(strip_->manufacturer());
ui->m_move_to_cb->addItem(tr("Independent terminals"), QUuid());
const auto project_{strip_->project()};
@@ -188,12 +211,14 @@ void TerminalStripEditor::apply()
{
m_project->undoStack()->beginMacro(tr("Modify terminal strip properties"));
TerminalStripData data;
//Start from the current data : keep the uuid of the strip
TerminalStripData data = m_current_strip->data();
data.m_installation = ui->m_installation_le->text();
data.m_location = ui->m_location_le->text();
data.m_name = ui->m_name_le->text();
data.m_comment = ui->m_comment_le->text();
data.m_description = ui->m_description_te->toPlainText();
data.m_manufacturer = ui->m_manufacturer_cb->currentData().toString();
m_project->undoStack()->push(new ChangeTerminalStripData(m_current_strip, data, nullptr));
@@ -201,28 +226,48 @@ void TerminalStripEditor::apply()
{
for (const auto &data_ : m_model->modifiedmodelRealTerminalData())
{
if (auto element = data_.element_)
{
auto current_data = element->elementData();
current_data.setTerminalType(data_.type_);
current_data.setTerminalFunction(data_.function_);
current_data.setTerminalLED(data_.led_);
current_data.m_informations.addValue(QStringLiteral("label"), data_.label_);
//The cable data are only stored when they are used
const auto set_info = [&current_data](const QString &key, const QString &value) {
if (!value.isEmpty() || current_data.m_informations.contains(key)) {
current_data.m_informations.addValue(key, value);
}
};
set_info(RealTerminal::cableInfoKey(), data_.cable_);
set_info(RealTerminal::cableWireInfoKey(), data_.cable_wire);
set_info(RealTerminal::shieldInfoKey(), data_.shield_ ? QStringLiteral("true") : QString());
if (element->elementData() != current_data)
m_project->undoStack()->push(new ChangeElementDataCommand(element, current_data));
if (data_.level_ != data_.real_terminal.toStrongRef()->level())
m_project->undoStack()->push(new ChangeTerminalLevel(m_current_strip, data_.real_terminal, data_.level_));
const auto real_t = data_.real_terminal.toStrongRef();
auto element = data_.element_;
//A spare terminal (on no folio) has its own data, not the data of an element
if (!real_t || (!element && !data_.spare_)) {
continue;
}
const auto old_data = element ? element->elementData() : real_t->spareData();
auto current_data = old_data;
current_data.setTerminalType(data_.type_);
current_data.setTerminalFunction(data_.function_);
current_data.setTerminalLED(data_.led_);
current_data.m_informations.addValue(QStringLiteral("label"), data_.label_);
//The cable data are only stored when they are used
const auto set_info = [&current_data](const QString &key, const QString &value) {
if (!value.isEmpty() || current_data.m_informations.contains(key)) {
current_data.m_informations.addValue(key, value);
}
};
set_info(RealTerminal::cableInfoKey(), data_.cable_);
set_info(RealTerminal::cableWireInfoKey(), data_.cable_wire);
set_info(RealTerminal::shieldInfoKey(), data_.shield_ ? QStringLiteral("true") : QString());
//The color and the section are stored with the dot as decimal separator, whatever the locale
set_info(RealTerminal::colorInfoKey(), TerminalColor::toSpec(data_.color_, data_.stripe_color_));
set_info(RealTerminal::sectionInfoKey(), data_.section_ > 0 ? QString::number(data_.section_, 'g', 6) : QString());
set_info(RealTerminal::widthInfoKey(), data_.width_mm_ > 0 ? QString::number(data_.width_mm_, 'g', 6) : QString());
//The terminal chosen in the catalog, its manufacturer and its reference
//(only written when the user didn't type them)
RealTerminal::applyCatalogEntry(current_data.m_informations,
data_.width_mm_ > 0 ? data_.catalog_entry_ : QString());
set_info(RealTerminal::separatorInfoKey(), data_.separator_ ? QStringLiteral("true") : QString());
if (old_data != current_data)
{
if (element) {
m_project->undoStack()->push(new ChangeElementDataCommand(element, current_data));
} else {
m_project->undoStack()->push(new ChangeSpareTerminalDataCommand(real_t, current_data));
}
}
if (data_.level_ != real_t->level())
m_project->undoStack()->push(new ChangeTerminalLevel(m_current_strip, data_.real_terminal, data_.level_));
}
}
@@ -246,6 +291,21 @@ void TerminalStripEditor::clear()
m_model->deleteLater();
m_model = nullptr;
}
setManufacturerFilter(QString());
}
/**
* @brief TerminalStripEditor::setManufacturerFilter
* Select @a manufacturer in the manufacturer combo box : only the terminals
* of this manufacturer are proposed in the Width column.
* Each terminal strip stores its own manufacturer (TerminalStripData),
* an empty or unknown manufacturer proposes all the manufacturers.
* @param manufacturer
*/
void TerminalStripEditor::setManufacturerFilter(const QString &manufacturer)
{
const auto index_ = ui->m_manufacturer_cb->findData(manufacturer);
ui->m_manufacturer_cb->setCurrentIndex(index_ > 0 ? index_ : 0);
}
/**
@@ -290,10 +350,12 @@ void TerminalStripEditor::selectionChanged()
ui->m_bridge_terminals_pb ->setDisabled(true);
ui->m_unbridge_terminals_pb->setDisabled(true);
ui->m_bridge_color_cb ->setDisabled(true);
ui->m_add_spare_pb ->setDisabled(true);
return;
}
ui->m_auto_ordering_pb->setEnabled(true);
ui->m_add_spare_pb ->setEnabled(true);
const auto index_list = ui->m_table_widget->selectionModel()->selectedIndexes();
@@ -639,14 +701,17 @@ void TerminalStripEditor::on_m_led_cb_activated(int index)
namespace {
/**
* @return true if the cell at @a index contain a free text
* (label, cable or wire of the cable), so a text can be pasted in it.
* (label, cable, wire of the cable, color, section or width), so a text can be pasted in it.
*/
bool cellAcceptText(const QModelIndex &index)
{
const auto column_ = TerminalStripModel::columnTypeForIndex(index);
return column_ == TerminalStripModel::Label
|| column_ == TerminalStripModel::Cable
|| column_ == TerminalStripModel::CableWire;
|| column_ == TerminalStripModel::CableWire
|| column_ == TerminalStripModel::Color
|| column_ == TerminalStripModel::Section
|| column_ == TerminalStripModel::Width;
}
}
@@ -730,7 +795,7 @@ void TerminalStripEditor::copySelectionToClipboard()
* the value is set to every selected cell.
* - Otherwise the text is read like a table (tabulation between the columns
* and new line between the rows) and written from the top left selected cell.
* Only the label, cable and cable wire cells are modified.
* Only the label, cable, cable wire, color, section and width cells are modified.
*/
void TerminalStripEditor::pasteFromClipboard()
{
@@ -984,3 +1049,170 @@ void TerminalStripEditor::on_m_move_to_pb_clicked()
}
}
namespace {
/**
* @brief The NumberedLabel struct
* A terminal label split in a prefix and a number : "X1:12" is "X1:" and 12.
*/
struct NumberedLabel
{
QString prefix;
int number = 0; ///< 0 if the label has no number
int width = 0; ///< Number of digits, to keep the leading zeros ("X1:05")
};
/**
* @brief splitLabel
* @param label : a terminal label, like "X1:12", "12" or "PE"
* @return the label split in a prefix and its last number
*/
NumberedLabel splitLabel(const QString &label)
{
static const QRegularExpression re_(QStringLiteral("^(.*?)(\\d+)$"));
NumberedLabel numbered_;
const auto match_ = re_.match(label.trimmed());
if (match_.hasMatch())
{
numbered_.prefix = match_.captured(1);
numbered_.number = match_.captured(2).toInt();
const auto digits_ = match_.captured(2);
numbered_.width = digits_.startsWith(QLatin1Char('0')) ? digits_.size() : 0;
}
else {
numbered_.prefix = label.trimmed();
}
return numbered_;
}
/**
* @brief numberedLabel
* @return @a prefix followed by @a number, with leading zeros up to @a width digits
*/
QString numberedLabel(const QString &prefix, int number, int width) {
return prefix + QStringLiteral("%1").arg(number, width, 10, QLatin1Char('0'));
}
}
/**
* @brief TerminalStripEditor::on_m_add_spare_pb_clicked
* Add spare terminals (on no folio) after the selected terminal,
* or at the end of the strip if no terminal is selected.
* The labels continue the number of the selected terminal ("X1:12" ->
* "X1:13", "X1:14"...), the user can change or remove them in the table.
* The properties of the selected terminal (type, section, width, terminal
* of the catalog, color, manufacturer...) can be copied to the spare terminals,
* which then are the same part in the bill of materials.
*/
void TerminalStripEditor::on_m_add_spare_pb_clicked()
{
if (!m_model || !m_current_strip || !m_project) {
return;
}
//The last selected physical terminal in the strip, else the last one of the strip
int insert_pos_ = -1;
QSharedPointer<PhysicalTerminal> reference_;
const auto selected_ = m_model->modelPhysicalTerminalDataForIndex(
ui->m_table_widget->selectionModel()->selectedIndexes());
for (const auto &data_ : selected_)
{
const auto phy_t = m_current_strip->physicalTerminal(data_.uuid_);
const auto pos_ = m_current_strip->pos(phy_t);
if (phy_t && pos_ >= insert_pos_) {
insert_pos_ = pos_;
reference_ = phy_t;
}
}
if (reference_) {
++insert_pos_; //After the selected terminal
} else {
insert_pos_ = -1; //At the end of the strip
if (m_current_strip->physicalTerminalCount()) {
reference_ = m_current_strip->physicalTerminal(m_current_strip->physicalTerminalCount() - 1);
}
}
QSharedPointer<RealTerminal> reference_real_;
if (reference_ && !reference_->realTerminals().isEmpty()) {
reference_real_ = reference_->realTerminals().last();
}
//Proposed numbering : the one of the reference terminal, continued
const auto numbered_ = splitLabel(reference_real_ ? reference_real_->label() : QString());
QDialog dialog(this);
dialog.setWindowTitle(tr("Add spare terminals"));
auto layout_ = new QFormLayout(&dialog);
auto count_sb = new QSpinBox(&dialog);
count_sb->setRange(1, 999);
count_sb->setValue(1);
layout_->addRow(tr("Number of terminals :"), count_sb);
auto numbering_cb = new QCheckBox(tr("Number the terminals"), &dialog);
numbering_cb->setChecked(true);
layout_->addRow(numbering_cb);
auto prefix_le = new QLineEdit(numbered_.prefix, &dialog);
layout_->addRow(tr("Prefix :"), prefix_le);
auto first_sb = new QSpinBox(&dialog);
first_sb->setRange(0, 999999);
first_sb->setValue(numbered_.number + 1);
layout_->addRow(tr("First number :"), first_sb);
connect(numbering_cb, &QCheckBox::toggled, prefix_le, &QWidget::setEnabled);
connect(numbering_cb, &QCheckBox::toggled, first_sb, &QWidget::setEnabled);
auto copy_cb = new QCheckBox(tr("Same terminal as %1 (type, section, width, color, manufacturer...)")
.arg(reference_real_ && !reference_real_->label().isEmpty()
? reference_real_->label() : tr("the selected terminal")),
&dialog);
copy_cb->setChecked(!reference_real_.isNull());
copy_cb->setEnabled(!reference_real_.isNull());
layout_->addRow(copy_cb);
auto buttons_ = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
connect(buttons_, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons_, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout_->addRow(buttons_);
if (dialog.exec() != QDialog::Accepted) {
return;
}
//The data shared by every spare terminal
ElementData model_;
if (copy_cb->isChecked() && reference_real_)
{
const auto reference_data_ = reference_real_->terminalData();
model_.setTerminalType(reference_data_.terminalType());
model_.setTerminalFunction(reference_data_.terminalFunction());
model_.setTerminalLED(reference_data_.terminalLed());
//What makes the part, not what belongs to this terminal (label, cable...)
for (const auto &key_ : {RealTerminal::sectionInfoKey(), RealTerminal::widthInfoKey(),
RealTerminal::catalogEntryInfoKey(), RealTerminal::colorInfoKey(),
QETInformation::ELMT_MANUFACTURER, QETInformation::ELMT_MANUFACTURER_REF,
QETInformation::ELMT_DESIGNATION, QETInformation::ELMT_PLANT,
QETInformation::ELMT_LOCATION})
{
const auto value_ = reference_data_.m_informations.value(key_).toString();
if (!value_.isEmpty()) {
model_.m_informations.addValue(key_, value_);
}
}
}
QVector<QSharedPointer<RealTerminal>> spare_vector;
for (int i = 0 ; i < count_sb->value() ; ++i)
{
auto data_ = model_;
if (numbering_cb->isChecked()) {
data_.m_informations.addValue(QStringLiteral("label"),
numberedLabel(prefix_le->text(), first_sb->value() + i, numbered_.width));
}
spare_vector.append(RealTerminal::createSpare(data_));
}
m_project->undoStack()->push(new AddSpareTerminalsCommand(m_current_strip, insert_pos_, spare_vector));
}
@@ -54,6 +54,7 @@ class TerminalStripEditor : public QWidget
void pasteFromClipboard();
void clearSelectedTexts();
void clear();
void setManufacturerFilter(const QString &manufacturer);
void spanMultiLevelTerminals();
void selectionChanged();
QSize setUpBridgeCellWidth();
@@ -73,6 +74,7 @@ class TerminalStripEditor : public QWidget
void on_m_unbridge_terminals_pb_clicked();
void on_m_bridge_color_cb_activated(const QColor &col);
void on_m_move_to_pb_clicked();
void on_m_add_spare_pb_clicked();
private:
Ui::TerminalStripEditor *ui;
@@ -199,6 +199,30 @@
</widget>
</item>
<item row="15" column="0">
<widget class="QLabel" name="m_manufacturer_label">
<property name="text">
<string>Manufacturer :</string>
</property>
</widget>
</item>
<item row="15" column="1" colspan="2">
<widget class="QComboBox" name="m_manufacturer_cb">
<property name="toolTip">
<string>Manufacturer of the terminals proposed in the Width column</string>
</property>
</widget>
</item>
<item row="16" column="1" colspan="2">
<widget class="QPushButton" name="m_add_spare_pb">
<property name="toolTip">
<string>Add spare terminals, on no folio, after the selected terminal</string>
</property>
<property name="text">
<string>Add spare terminals...</string>
</property>
</widget>
</item>
<item row="17" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
@@ -260,7 +284,7 @@
<item row="5" column="1" colspan="2">
<widget class="QSpinBox" name="m_level_sb">
<property name="maximum">
<number>6</number>
<number>3</number>
</property>
</widget>
</item>
@@ -24,13 +24,94 @@
#include "../../qetproject.h"
#include "../realterminal.h"
#include "../terminalstrip.h"
#include "../terminalstripautoimport.h"
#include "terminalstripcreatordialog.h"
#include "terminalstripeditor.h"
#include "terminalstripeditorwindow.h"
#include "terminalstriptreedockwidget.h"
#include <QApplication>
#include <QElapsedTimer>
#include <QMessageBox>
#include <QProgressDialog>
#include <QThread>
#include <QWindow>
QPointer<TerminalStripEditorWindow> TerminalStripEditorWindow::window_;
/**
* @brief TerminalStripEditorWindow::instance
* @param project : the project to edit
* @param parent : parent widget of the window, when it is created
* @return the terminal block manager window, created the first time,
* showing the terminal strips of @a project.
* Opening it reads every terminal of the project (and builds the folios
* not built yet) : it can take a while on a big project. A message and a
* busy cursor say that it is opening, so that the user does not think
* that nothing happens.
*/
TerminalStripEditorWindow *TerminalStripEditorWindow::instance(QETProject *project, QWidget *parent)
{
QProgressDialog busy_(tr("Opening the terminal block manager..."), QString(), 0, 0, parent);
busy_.setWindowTitle(tr("Terminal block manager"));
//Only a title : nothing to minimize, maximize or close
busy_.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint);
busy_.setWindowModality(Qt::ApplicationModal);
busy_.setCancelButton(nullptr);
busy_.setMinimumDuration(0);
busy_.show();
//Paint the message before the long work starts. One pass of the
//event loop is not enough : the window is shown by the window system
//(Wayland compositor...) a bit later. Wait for it to be exposed,
//a short time at most, then paint it.
QElapsedTimer timer_;
timer_.start();
while (timer_.elapsed() < 500)
{
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 20);
if (busy_.windowHandle() && busy_.windowHandle()->isExposed()) {
break;
}
QThread::msleep(5);
}
busy_.repaint();
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
QApplication::setOverrideCursor(Qt::WaitCursor);
//The folios not built yet (lazy folios) are built now : show the
//progress, and run the event loop between two folios, so that the
//window system still gets answers (else the message is said
//"not responding")
if (project)
{
project->setFolioBuildObserver([&busy_](int built, int total)
{
if (busy_.maximum() != total) {
busy_.setMaximum(total);
}
busy_.setLabelText(tr("Reading folio %1 of %2...").arg(built).arg(total));
busy_.setValue(built);
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
});
}
static QMutex mutex_;
if (!window_) {
mutex_.lock();
if (!window_)
window_ = new TerminalStripEditorWindow{project, parent};
mutex_.unlock();
} else {
window_->setProject(project);
}
if (project) {
project->setFolioBuildObserver({});
}
QApplication::restoreOverrideCursor();
return window_;
}
static const int EMPTY_PAGE = 0;
static const int FREE_TERMINAL_PAGE = 1;
static const int TERMINAL_STRIP_PAGE = 2;
@@ -75,6 +156,11 @@ TerminalStripEditorWindow::TerminalStripEditorWindow(QETProject *project, QWidge
ui->m_stacked_widget->insertWidget(EMPTY_PAGE, new QWidget(ui->m_stacked_widget));
ui->m_stacked_widget->insertWidget(FREE_TERMINAL_PAGE, m_free_terminal_editor);
ui->m_stacked_widget->insertWidget(TERMINAL_STRIP_PAGE, m_terminal_strip_editor);
//The strips are worked out from the labels of the free terminals,
//so that the user does not create them one by one : only the
//toolbar actions run the import, opening the window changes
//nothing
}
/**
@@ -97,6 +183,81 @@ void TerminalStripEditorWindow::setProject(QETProject *project)
m_terminal_strip_editor->setProject(project);
}
/**
* @brief TerminalStripEditorWindow::importFreeTerminals
* Run one of the two import actions of the window, after the user has
* confirmed it : @a move_placed false assigns the free terminals only,
* true re-groups every terminal and also moves placed ones. Both run as
* one undoable step and say what has been done.
*/
void TerminalStripEditorWindow::importFreeTerminals(bool move_placed)
{
if (!m_project) {
return;
}
//A reload of the free terminal table would lose its unapplied edits
if (m_free_terminal_editor && m_free_terminal_editor->hasPendingEdits())
{
QMessageBox::information(
this, tr("Unapplied edits"),
tr("Apply or discard the pending edits of the free terminals "
"table first."));
return;
}
const auto title_ = move_placed ? tr("Re-group all terminals")
: tr("Assign free terminals");
const auto text_ = move_placed
? tr("Free terminals will be added to the terminal strips named "
"after their labels, and every terminal whose label prefix "
"or plant and location no longer match its strip will be "
"moved to the right strip.\n\n"
"Terminals you placed by hand in a strip whose name does "
"not match their label will be moved too. Existing strips "
"are never re-sorted.\n\n"
"This action can be undone with a single Undo.")
: tr("Every free terminal will be added to the terminal strip "
"named after its label (\"X1:2\" goes to the strip \"X1\"), "
"creating the strips when needed.\n\n"
"Terminals that already belong to a strip are not touched "
"and no strip is re-sorted.\n\n"
"This action can be undone with a single Undo.");
if (QMessageBox::question(this, title_, text_,
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No) != QMessageBox::Yes) {
return;
}
const auto result_ = TerminalStripAutoImport::importFreeTerminals(
m_project, move_placed);
if (result_.terminals == 0 && result_.strips == 0) {
return;
}
m_tree_dock->reload();
if (move_placed) {
statusBar()->showMessage(
tr("%1 terminal(s) assigned or moved to a terminal strip.",
"", result_.terminals).arg(result_.terminals),
10000);
} else {
statusBar()->showMessage(
tr("%1 free terminal(s) added to the terminal strips.",
"", result_.terminals).arg(result_.terminals),
10000);
}
}
void TerminalStripEditorWindow::on_m_import_free_terminals_triggered() {
importFreeTerminals(false);
}
void TerminalStripEditorWindow::on_m_regroup_terminals_triggered() {
importFreeTerminals(true);
}
void TerminalStripEditorWindow::setCurrentStrip(TerminalStrip *strip) {
m_tree_dock->setSelectedStrip(strip);
}
@@ -44,18 +44,7 @@ class TerminalStripEditorWindow : public QMainWindow
static QPointer<TerminalStripEditorWindow> window_;
public:
static TerminalStripEditorWindow* instance(QETProject *project, QWidget *parent = nullptr) {
static QMutex mutex_;
if (!window_) {
mutex_.lock();
if (!window_)
window_ = new TerminalStripEditorWindow{project, parent};
mutex_.unlock();
} else {
window_->setProject(project);
}
return window_;
}
static TerminalStripEditorWindow* instance(QETProject *project, QWidget *parent = nullptr);
static void dropInstance () {
static QMutex mutex;
@@ -80,6 +69,8 @@ class TerminalStripEditorWindow : public QMainWindow
void on_m_add_terminal_strip_triggered();
void on_m_remove_terminal_triggered();
void on_m_reload_triggered();
void on_m_import_free_terminals_triggered();
void on_m_regroup_terminals_triggered();
void on_m_button_box_clicked(QAbstractButton *button);
void on_m_stacked_widget_currentChanged(int arg1);
@@ -87,6 +78,7 @@ class TerminalStripEditorWindow : public QMainWindow
void addTreeDockWidget();
void currentStripChanged(TerminalStrip *strip);
void updateUi();
void importFreeTerminals(bool move_placed);
private:
Ui::TerminalStripEditorWindow *ui{nullptr};
@@ -51,6 +51,8 @@
<addaction name="m_add_terminal_strip"/>
<addaction name="m_remove_terminal"/>
<addaction name="m_reload"/>
<addaction name="m_import_free_terminals"/>
<addaction name="m_regroup_terminals"/>
</widget>
<action name="m_add_terminal_strip">
<property name="icon">
@@ -85,6 +87,28 @@
<string>Reload the terminals</string>
</property>
</action>
<action name="m_import_free_terminals">
<property name="icon">
<iconset theme="list-add"/>
</property>
<property name="text">
<string>Assign free terminals</string>
</property>
<property name="toolTip">
<string>Add the free terminals to the terminal strips named after their labels ("X1:2" goes to the strip "X1"), creating the strips when needed. Terminals already assigned to a strip are never touched. Asks for confirmation.</string>
</property>
</action>
<action name="m_regroup_terminals">
<property name="icon">
<iconset theme="start"/>
</property>
<property name="text">
<string>Re-group all terminals</string>
</property>
<property name="toolTip">
<string>Assign the free terminals and also move every terminal whose label prefix or plant and location no longer match its strip. Existing strips are never re-sorted. Asks for confirmation.</string>
</property>
</action>
</widget>
<resources>
<include location="../../../qelectrotech.qrc"/>
@@ -17,6 +17,8 @@
*/
#include <QFontDialog>
#include <algorithm>
#include "terminalstriplayouteditor.h"
#include "ui_terminalstriplayouteditor.h"
#include "../GraphicsItem/properties/terminalstriplayoutpattern.h"
@@ -33,6 +35,26 @@ TerminalStripLayoutEditor::TerminalStripLayoutEditor(QSharedPointer<TerminalStri
// the application palette.
ui->m_graphics_view->setBackgroundBrush(Qt::white);
ui->m_graphics_view->scene()->addItem(&m_preview_strip_item);
//Drawing style
//The style is chosen for each item when it is added to a folio,
//here it only change the preview, to see the values of each style.
connect(ui->m_draw_style_cb, &QComboBox::currentIndexChanged, this, [this](int index) {
m_preview_strip_item.prepareGeometryChange();
m_preview_strip_item.m_drawer.setDrawStyle(
static_cast<TerminalStripLayoutPattern::DrawStyle>(std::clamp(index, 0, 2)));
m_preview_strip_item.update();
updatePreview();
});
connect(ui->m_realistic_scale_sb, &QDoubleSpinBox::valueChanged,
this, &TerminalStripLayoutEditor::valueEdited);
connect(ui->m_realistic_length_sb, &QSpinBox::valueChanged,
this, &TerminalStripLayoutEditor::valueEdited);
connect(ui->m_realistic_end_stops_cb, &QCheckBox::toggled,
this, &TerminalStripLayoutEditor::valueEdited);
connect(ui->m_realistic_group_marker_cb, &QCheckBox::toggled,
this, &TerminalStripLayoutEditor::valueEdited);
updateUi();
}
@@ -159,6 +181,12 @@ void TerminalStripLayoutEditor::valueEdited()
m_layout.data()->m_cable_length = ui->m_cable_length_sb->value();
m_layout.data()->m_cable_end_length = ui->m_cable_end_length_sb->value();
//Values of the realistic styles
m_layout.data()->m_realistic_px_per_mm = ui->m_realistic_scale_sb->value();
m_layout.data()->m_realistic_length_mm = ui->m_realistic_length_sb->value();
m_layout.data()->m_realistic_end_stops = ui->m_realistic_end_stops_cb->isChecked();
m_layout.data()->m_realistic_group_marker = ui->m_realistic_group_marker_cb->isChecked();
updateUi();
m_preview_strip_item.update();
}
@@ -271,6 +299,12 @@ void TerminalStripLayoutEditor::updateUi()
ui->m_cable_length_sb->setValue(data->m_cable_length);
ui->m_cable_end_length_sb->setValue(data->m_cable_end_length);
//Values of the realistic styles
ui->m_realistic_scale_sb->setValue(data->m_realistic_px_per_mm);
ui->m_realistic_length_sb->setValue(qRound(data->m_realistic_length_mm));
ui->m_realistic_end_stops_cb->setChecked(data->m_realistic_end_stops);
ui->m_realistic_group_marker_cb->setChecked(data->m_realistic_group_marker);
m_ui_updating = false;
updatePreview();
}
@@ -540,6 +540,88 @@
</property>
</widget>
</item>
<item row="11" column="2">
<widget class="QLabel" name="label_draw_style">
<property name="text">
<string>Preview style</string>
</property>
</widget>
</item>
<item row="11" column="3" colspan="2">
<widget class="QComboBox" name="m_draw_style_cb">
<property name="toolTip">
<string>Style of the preview only. The style of a terminal strip plan is chosen when it is added to a folio.</string>
</property>
<item>
<property name="text">
<string>Schematic</string>
</property>
</item>
<item>
<property name="text">
<string>Realistic (push-in)</string>
</property>
</item>
<item>
<property name="text">
<string>Realistic (screw)</string>
</property>
</item>
</widget>
</item>
<item row="12" column="2">
<widget class="QLabel" name="label_realistic_scale">
<property name="text">
<string>Scale (px/mm) / length (mm)</string>
</property>
</widget>
</item>
<item row="12" column="3">
<widget class="QDoubleSpinBox" name="m_realistic_scale_sb">
<property name="toolTip">
<string>Number of pixels for one millimeter of terminal.</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>0.5</double>
</property>
<property name="maximum">
<double>50.0</double>
</property>
<property name="singleStep">
<double>0.5</double>
</property>
</widget>
</item>
<item row="12" column="4">
<widget class="QSpinBox" name="m_realistic_length_sb">
<property name="toolTip">
<string>Length of a terminal along the wires, in millimeter.</string>
</property>
<property name="minimum">
<number>10</number>
</property>
<property name="maximum">
<number>300</number>
</property>
</widget>
</item>
<item row="13" column="3">
<widget class="QCheckBox" name="m_realistic_end_stops_cb">
<property name="text">
<string>End stops</string>
</property>
</widget>
</item>
<item row="13" column="4">
<widget class="QCheckBox" name="m_realistic_group_marker_cb">
<property name="text">
<string>Group marker</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
+354 -12
View File
@@ -16,12 +16,17 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "terminalstripmodel.h"
#include "../terminalcatalog.h"
#include "../terminalstrip.h"
#include "../../qetgraphicsitem/element.h"
#include "../physicalterminal.h"
#include "../realterminal.h"
#include "../terminalcolor.h"
#include "../terminalstripbridge.h"
#include <QColor>
#include <QCoreApplication>
#include <QDebug>
#include <QLocale>
#include <QBrush>
#include <QVector>
#include <QComboBox>
@@ -47,10 +52,46 @@ const int TYPE_CELL = 11;
const int FUNCTION_CELL = 12;
const int LED_CELL = 13;
const int SHIELD_CELL = 14;
const int COLOR_CELL = 15;
const int SECTION_CELL = 16;
const int WIDTH_CELL = 17;
const int SEPARATOR_CELL = 18;
const int COLUMN_COUNT = 15;
const int COLUMN_COUNT = 19;
static QVector<bool> UNMODIFIED_CELL_VECTOR{false, false, false, false, false, false, false, false, false, false, false, false, false, false, false};
static QVector<bool> UNMODIFIED_CELL_VECTOR(COLUMN_COUNT, false);
/**
* @brief modifiedCellKey
* @return the key of the real terminal of @a data in the modified cells
*/
static const RealTerminal *modifiedCellKey(const modelRealTerminalData &data) {
return data.real_terminal.toStrongRef().data();
}
/**
* @brief parsePositiveNumber
* Convert the text written by the user to a number >= 0
* (a section in mm2 or a width in mm).
* The comma and the dot are both accepted as decimal separator.
* @param text
* @param ok : true if the text is valid (empty text is valid and mean 0)
* @return
*/
static qreal parsePositiveNumber(const QString &text, bool *ok)
{
auto text_ = text.trimmed();
text_.replace(QLatin1Char(','), QLatin1Char('.'));
if (text_.isEmpty()) {
*ok = true;
return 0;
}
const auto value_ = text_.toDouble(ok);
if (*ok && value_ < 0) {
*ok = false;
}
return *ok ? value_ : 0;
}
/**
* @brief TerminalStripModel::levelForColumn
@@ -95,6 +136,10 @@ TerminalStripModel::Column TerminalStripModel::columnTypeForIndex(const QModelIn
case 12 : return Function;
case 13 : return Led;
case 14 : return Shield;
case 15 : return Color;
case 16 : return Section;
case 17 : return Width;
case 18 : return Separator;
default : return Invalid;
}
}
@@ -167,11 +212,20 @@ QVariant TerminalStripModel::data(const QModelIndex &index, int role) const
case LEVEL_CELL : return mrtd.level_;
case LABEL_CELL : return mrtd.label_;
case CONDUCTOR_CELL : return mrtd.conductor_;
case XREF_CELL : return mrtd.Xref_;
case XREF_CELL : return mrtd.spare_ ? tr("Spare") : mrtd.Xref_;
case CABLE_CELL : return mrtd.cable_;
case CABLE_WIRE_CELL : return mrtd.cable_wire;
case TYPE_CELL : return ElementData::translatedTerminalType(mrtd.type_);
case FUNCTION_CELL : return ElementData::translatedTerminalFunction(mrtd.function_);
case COLOR_CELL : return TerminalColor::displayName(mrtd.color_, mrtd.stripe_color_);
case SECTION_CELL : return mrtd.section_ > 0 ? QLocale().toString(mrtd.section_, 'g', 6) : QString();
case WIDTH_CELL :
if (mrtd.width_mm_ <= 0) {
return QString();
}
//"6,2 - Phoenix Contact ST 4" : the width alone doesn't say which terminal it is
return mrtd.catalog_entry_.isEmpty() ? QLocale().toString(mrtd.width_mm_, 'g', 6)
: QLocale().toString(mrtd.width_mm_, 'g', 6) + QStringLiteral(" - ") + mrtd.catalog_entry_;
default : return QVariant();
}
}
@@ -181,10 +235,18 @@ QVariant TerminalStripModel::data(const QModelIndex &index, int role) const
case LABEL_CELL : return mrtd.label_;
case CABLE_CELL : return mrtd.cable_;
case CABLE_WIRE_CELL : return mrtd.cable_wire;
case COLOR_CELL : return TerminalColor::toSpec(mrtd.color_, mrtd.stripe_color_);
case SECTION_CELL : return mrtd.section_ > 0 ? QLocale().toString(mrtd.section_, 'g', 6) : QString();
case WIDTH_CELL : return mrtd.width_mm_ > 0 ? QLocale().toString(mrtd.width_mm_, 'g', 6) : QString();
default: return QVariant();
}
}
else if (role == CatalogEntryRole &&
index.column() == WIDTH_CELL)
{
return mrtd.catalog_entry_;
}
else if (role == Qt::CheckStateRole &&
index.column() == LED_CELL)
{
@@ -195,14 +257,32 @@ QVariant TerminalStripModel::data(const QModelIndex &index, int role) const
{
return mrtd.shield_ ? Qt::Checked : Qt::Unchecked;
}
else if (role == Qt::CheckStateRole &&
index.column() == SEPARATOR_CELL)
{
return mrtd.separator_ ? Qt::Checked : Qt::Unchecked;
}
else if (role == Qt::ForegroundRole &&
index.column() == XREF_CELL && mrtd.spare_)
{
return QBrush(Qt::gray); //"Spare" : not a reference to a folio
}
else if (role == Qt::BackgroundRole && index.column() < COLUMN_COUNT )
{
if (m_modified_cell.contains(mrtd.element_) &&
m_modified_cell.value(mrtd.element_).at(index.column()))
const auto key_ = modifiedCellKey(mrtd);
if (m_modified_cell.contains(key_) &&
m_modified_cell.value(key_).at(index.column()))
{
return QBrush(Qt::yellow);
}
}
else if (role == Qt::DecorationRole &&
index.column() == COLOR_CELL)
{
if (mrtd.color_.isValid()) {
return TerminalColor::swatch(mrtd.color_, mrtd.stripe_color_);
}
}
else if (role == Qt::DecorationRole &&
(index.column() == LEVEL_0_CELL ||
index.column() == LEVEL_1_CELL ||
@@ -279,23 +359,97 @@ bool TerminalStripModel::setData(const QModelIndex &index, const QVariant &value
modified_ = true;
modified_cell = SHIELD_CELL;
}
else if (column_ == SEPARATOR_CELL)
{
mrtd.separator_ = value.toBool();
modified_ = true;
modified_cell = SEPARATOR_CELL;
}
else if (column_ == COLOR_CELL &&
role == Qt::EditRole)
{
//An empty text remove the color, an unknown text is ignored.
//The text can be a name of the palette (gray, blue, green/yellow...),
//or a color "#rrggbb" or "#rrggbb/#rrggbb"
QColor color_, stripe_;
if (TerminalColor::parse(value.toString(), &color_, &stripe_) &&
(mrtd.color_ != color_ || mrtd.stripe_color_ != stripe_))
{
mrtd.color_ = color_;
mrtd.stripe_color_ = stripe_;
modified_ = true;
modified_cell = COLOR_CELL;
}
}
else if (column_ == SECTION_CELL &&
role == Qt::EditRole)
{
bool ok_ = false;
const auto section_ = parsePositiveNumber(value.toString(), &ok_);
if (ok_ && !qFuzzyCompare(1 + mrtd.section_, 1 + section_))
{
mrtd.section_ = section_;
modified_ = true;
modified_cell = SECTION_CELL;
}
}
else if (column_ == WIDTH_CELL &&
(role == Qt::EditRole || role == CatalogEntryRole))
{
bool ok_ = false;
qreal width_ = 0;
QString entry_;
if (role == CatalogEntryRole)
{
//Chosen in the list : the width and the terminal of the catalog
const auto map_ = value.toMap();
width_ = map_.value(QStringLiteral("mm")).toDouble(&ok_);
ok_ = ok_ && width_ >= 0;
entry_ = map_.value(QStringLiteral("entry")).toString();
}
else
{
//Typed or pasted : "6.2", or "6,2 - Phoenix Contact ST 4" as copied from this column
const auto text_ = value.toString();
const auto separator_ = text_.indexOf(QStringLiteral(" - "));
width_ = parsePositiveNumber(separator_ < 0 ? text_ : text_.left(separator_), &ok_);
if (ok_ && separator_ >= 0) {
entry_ = text_.mid(separator_ + 3).trimmed();
}
}
//Only a terminal of the catalog with this width is kept
const auto *catalog_entry_ = TerminalCatalog::entryByLabel(entry_);
if (!catalog_entry_ || width_ <= 0 ||
!qFuzzyCompare(1 + catalog_entry_->mm, 1 + width_)) {
entry_.clear();
}
if (ok_ && (!qFuzzyCompare(1 + mrtd.width_mm_, 1 + width_) ||
mrtd.catalog_entry_ != entry_))
{
mrtd.width_mm_ = width_;
mrtd.catalog_entry_ = entry_;
modified_ = true;
modified_cell = WIDTH_CELL;
}
}
//Set the modification to the terminal data
if (modified_)
{
replaceDataAtRow(mrtd, index.row());
if (mrtd.element_)
if (const auto key_ = modifiedCellKey(mrtd))
{
QVector<bool> vector_;
if (m_modified_cell.contains(mrtd.element_)) {
vector_ = m_modified_cell.value(mrtd.element_);
if (m_modified_cell.contains(key_)) {
vector_ = m_modified_cell.value(key_);
} else {
vector_ = UNMODIFIED_CELL_VECTOR;
}
vector_.replace(modified_cell, true);
m_modified_cell.insert(mrtd.element_, vector_);
m_modified_cell.insert(key_, vector_);
}
emit dataChanged(index, index);
return true;
@@ -326,6 +480,10 @@ QVariant TerminalStripModel::headerData(int section, Qt::Orientation orientation
case FUNCTION_CELL : return tr("Function");
case LED_CELL: return tr("led");
case SHIELD_CELL: return tr("Shield");
case COLOR_CELL: return tr("Color");
case SECTION_CELL: return tr("Section (mm²)");
case WIDTH_CELL: return tr("Width (mm)");
case SEPARATOR_CELL: return tr("Separator plate after");
default : return QVariant();
}
}
@@ -340,9 +498,10 @@ Qt::ItemFlags TerminalStripModel::flags(const QModelIndex &index) const
auto c = index.column();
if (c == LABEL_CELL || c == TYPE_CELL || c == FUNCTION_CELL
|| c == CABLE_CELL || c == CABLE_WIRE_CELL)
|| c == CABLE_CELL || c == CABLE_WIRE_CELL
|| c == COLOR_CELL || c == SECTION_CELL || c == WIDTH_CELL)
flags = flags | Qt::ItemIsEditable;
if (c == LED_CELL || c == SHIELD_CELL) {
if (c == LED_CELL || c == SHIELD_CELL || c == SEPARATOR_CELL) {
flags = flags | Qt::ItemIsUserCheckable;
}
return flags;
@@ -364,7 +523,7 @@ QVector<modelRealTerminalData> TerminalStripModel::modifiedmodelRealTerminalData
for (auto j = 0 ; j < ptd_.real_data.size() ; ++j)
{
auto mrtd_ = ptd_.real_data.at(j);
if (modified_real_terminal.contains(mrtd_.element_))
if (modified_real_terminal.contains(modifiedCellKey(mrtd_)))
{
returned_vector.append(m_physical_data.at(i).real_data.at(j));
}
@@ -819,10 +978,27 @@ QPixmap TerminalStripModel::bridgePixmapFor(const QModelIndex &index) const
* and a spinbox to edit the level of a terminal
**********************************************************/
namespace {
QString widthItemText(double mm, const QString &label = QString())
{
const auto text_ = QCoreApplication::translate("TerminalStripModelDelegate", "%1 mm").arg(QLocale().toString(mm, 'g', 6));
return label.isEmpty() ? text_ : text_ + QStringLiteral(" - ") + label;
}
}
TerminalStripModelDelegate::TerminalStripModelDelegate(QObject *parent) :
QStyledItemDelegate(parent)
{}
/**
* @brief TerminalStripModelDelegate::setManufacturer
* @param manufacturer : manufacturer of the terminals proposed
* in the Width column, an empty string proposes all the manufacturers.
*/
void TerminalStripModelDelegate::setManufacturer(const QString &manufacturer) {
m_manufacturer = manufacturer;
}
QWidget *TerminalStripModelDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (index.column() == TYPE_CELL) {
@@ -850,10 +1026,155 @@ QWidget *TerminalStripModelDelegate::createEditor(QWidget *parent, const QStyleO
return qcb;
}
if (index.column() == SECTION_CELL) {
auto qcb = new QComboBox(parent);
qcb->setObjectName(QStringLiteral("terminal_section"));
qcb->addItem(tr("None (default)"), 0.0);
//Usual sections, in mm2
for (const auto section_ : TerminalCatalog::sections()) {
qcb->addItem(TerminalCatalog::sectionText(section_, tr("%1 mm²").arg(QLocale().toString(section_, 'g', 6))), section_);
}
return qcb;
}
if (index.column() == WIDTH_CELL) {
auto qcb = new QComboBox(parent);
qcb->setObjectName(QStringLiteral("terminal_width"));
qcb->addItem(tr("None (default)"), 0.0);
// Show the terminals of the selected manufacturer (all if none),
// compatible with the section selected in this row.
const auto manufacturer_ok = [this](const TerminalCatalog::WidthEntry &entry) {
return m_manufacturer.isEmpty() || m_manufacturer == QString::fromUtf8(entry.manufacturer);
};
const auto section_index = index.sibling(index.row(), SECTION_CELL);
bool section_ok = false;
const auto section = parsePositiveNumber(section_index.data(Qt::EditRole).toString(), &section_ok);
bool has_compatible_entry = false;
if (section_ok && section > 0.0) {
for (const auto &entry : TerminalCatalog::widthEntries()) {
if (manufacturer_ok(entry) && qFuzzyCompare(1 + entry.section, 1 + section)) {
has_compatible_entry = true;
break;
}
}
}
// If the section has no catalog match (or is unspecified), keep all widths available.
for (const auto &entry : TerminalCatalog::widthEntries()) {
if (manufacturer_ok(entry) &&
(!has_compatible_entry || qFuzzyCompare(1 + entry.section, 1 + section))) {
qcb->addItem(widthItemText(entry.mm, QString::fromUtf8(entry.label)), entry.mm);
qcb->setItemData(qcb->count() - 1, QString::fromUtf8(entry.label), TerminalStripModel::CatalogEntryRole);
}
}
return qcb;
}
if (index.column() == COLOR_CELL) {
auto qcb = new QComboBox(parent);
qcb->setObjectName(QStringLiteral("terminal_color"));
qcb->addItem(tr("None (default)"), QString());
for (const auto &entry : TerminalColor::palette())
{
const auto spec_ = TerminalColor::specOf(entry);
QColor main_, stripe_;
TerminalColor::parse(spec_, &main_, &stripe_);
qcb->addItem(QIcon(TerminalColor::swatch(main_, stripe_)),
TerminalColor::translatedName(entry),
spec_);
}
return qcb;
}
return QStyledItemDelegate::createEditor(parent, option, index);
}
void TerminalStripModelDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
{
if (index.isValid() &&
editor->objectName() == QLatin1String("terminal_color"))
{
if (auto qcb = dynamic_cast<QComboBox *>(editor))
{
const auto spec_ = index.data(Qt::EditRole).toString();
auto pos_ = qcb->findData(spec_);
if (pos_ < 0)
{
//The color is not in the palette (typed or pasted), keep it in the list
QColor main_, stripe_;
TerminalColor::parse(spec_, &main_, &stripe_);
qcb->addItem(QIcon(TerminalColor::swatch(main_, stripe_)), spec_, spec_);
pos_ = qcb->count() - 1;
}
qcb->setCurrentIndex(pos_);
}
return;
}
if (index.isValid() &&
editor->objectName() == QLatin1String("terminal_section"))
{
if (auto qcb = dynamic_cast<QComboBox *>(editor))
{
bool ok_ = false;
const auto section_ = parsePositiveNumber(index.data(Qt::EditRole).toString(), &ok_);
auto pos_ = -1;
for (auto i = 0 ; i < qcb->count() ; ++i)
{
if (qFuzzyCompare(1 + qcb->itemData(i).toDouble(), 1 + section_)) {
pos_ = i;
break;
}
}
if (pos_ < 0)
{
//The section is not in the list (typed or pasted), keep it in the list
qcb->addItem(TerminalCatalog::sectionText(section_, tr("%1 mm²").arg(QLocale().toString(section_, 'g', 6))), section_);
pos_ = qcb->count() - 1;
}
qcb->setCurrentIndex(pos_);
}
return;
}
if (index.isValid() &&
editor->objectName() == QLatin1String("terminal_width"))
{
if (auto qcb = dynamic_cast<QComboBox *>(editor))
{
bool ok_ = false;
const auto width_ = parsePositiveNumber(index.data(Qt::EditRole).toString(), &ok_);
const auto entry_ = index.data(TerminalStripModel::CatalogEntryRole).toString();
//The terminal chosen in the catalog. A width typed or set before
//the catalog entry was stored is not tied to a terminal of the
//catalog : it must not take the first one with the same width.
auto pos_ = entry_.isEmpty() ? -1 : qcb->findData(entry_, TerminalStripModel::CatalogEntryRole);
for (auto i = 0 ; pos_ < 0 && entry_.isEmpty() && i < qcb->count() ; ++i)
{
if (qcb->itemData(i, TerminalStripModel::CatalogEntryRole).toString().isEmpty() &&
qFuzzyCompare(1 + qcb->itemData(i).toDouble(), 1 + width_)) {
pos_ = i;
}
}
if (pos_ < 0)
{
//The width or the terminal is not in the list
//(typed, pasted, or another manufacturer), keep it in the list
qcb->addItem(widthItemText(width_, entry_), width_);
if (!entry_.isEmpty()) {
qcb->setItemData(qcb->count() - 1, entry_, TerminalStripModel::CatalogEntryRole);
}
pos_ = qcb->count() - 1;
}
qcb->setCurrentIndex(pos_);
}
return;
}
QStyledItemDelegate::setEditorData(editor, index);
}
void TerminalStripModelDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const
{
if (index.isValid())
@@ -870,6 +1191,27 @@ void TerminalStripModelDelegate::setModelData(QWidget *editor, QAbstractItemMode
model->setData(index, qcb->currentData(), Qt::EditRole);
}
}
else if (editor->objectName() == QLatin1String("terminal_section"))
{
if (auto qcb = dynamic_cast<QComboBox *>(editor)) {
model->setData(index, qcb->currentData().toDouble(), Qt::EditRole);
}
}
else if (editor->objectName() == QLatin1String("terminal_width"))
{
if (auto qcb = dynamic_cast<QComboBox *>(editor)) {
QVariantMap map_;
map_.insert(QStringLiteral("mm"), qcb->currentData().toDouble());
map_.insert(QStringLiteral("entry"), qcb->currentData(TerminalStripModel::CatalogEntryRole).toString());
model->setData(index, map_, TerminalStripModel::CatalogEntryRole);
}
}
else if (editor->objectName() == QLatin1String("terminal_color"))
{
if (auto qcb = dynamic_cast<QComboBox *>(editor)) {
model->setData(index, qcb->currentData(), Qt::EditRole);
}
}
else {
QStyledItemDelegate::setModelData(editor, model, index);
}
+19 -1
View File
@@ -62,12 +62,20 @@ class TerminalStripModel : public QAbstractTableModel
Function = 12,
Led = 13,
Shield = 14,
Color = 15,
Section = 16,
Width = 17,
Separator = 18,
Invalid = 99
};
static int levelForColumn(TerminalStripModel::Column column);
static TerminalStripModel::Column columnTypeForIndex(const QModelIndex &index);
///Role of the Width column : the label of the terminal chosen in the catalog.
///setData() with this role takes a QVariantMap {"mm": width, "entry": label}
static constexpr int CatalogEntryRole = Qt::UserRole + 1;
Q_OBJECT
public:
TerminalStripModel(TerminalStrip *terminal_strip, QObject *parent = nullptr);
@@ -99,7 +107,8 @@ class TerminalStripModel : public QAbstractTableModel
private:
QPointer<TerminalStrip> m_terminal_strip;
QHash<QPointer<Element>, QVector<bool>> m_modified_cell;
//Modified cells of each real terminal (spare terminals have no element)
QHash<const RealTerminal *, QVector<bool>> m_modified_cell;
QVector<modelPhysicalTerminalData> m_physical_data;
struct BridgePixmap
{
@@ -124,12 +133,21 @@ class TerminalStripModelDelegate : public QStyledItemDelegate
QWidget *parent,
const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
void setEditorData(
QWidget *editor,
const QModelIndex &index) const override;
void setModelData(
QWidget *editor,
QAbstractItemModel *model,
const QModelIndex &index) const override;
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
void setManufacturer(const QString &manufacturer);
private:
///Manufacturer of the terminals proposed in the Width column, empty = all
QString m_manufacturer;
};
#endif // TERMINALSTRIPMODEL_H
+10 -1
View File
@@ -17,6 +17,8 @@
*/
#include "assignvariables.h"
#include <QRegularExpression>
#include "../diagram.h"
#include "../diagramposition.h"
#include "../qetapp.h"
@@ -229,6 +231,7 @@ namespace autonum
FormulaContext context;
const BorderTitleBlock &border = diagram->border_and_titleblock;
context.folio = border.folio();
context.folio_title = border.title();
context.folio_index = diagram->folioIndex();
context.folio_total = border.folioTotal();
context.plant = border.plant();
@@ -412,9 +415,9 @@ namespace autonum
m_assigned_label.replace("%M", m_context.plant);
m_assigned_label.replace("%LM", m_context.locmach);
QSettings settings;
if (m_context.has_element)
{
QSettings settings;
if (settings.value("border-columns_0", true).toBool()){
m_assigned_label.replace("%c", QString::number(m_context.element_position.number() - 1));
}else{
@@ -435,6 +438,12 @@ namespace autonum
assignTitleBlockVar();
assignProjectVar();
assignSequence();
//The folio title last: it is free text, and a title such as
//"Schema %c" must stay as typed, not have the variables in it
//replaced. %T on its own only, not the start of a longer word.
static const QRegularExpression folio_title(QStringLiteral("%T(?![A-Za-z0-9_])"));
m_assigned_label.replace(folio_title, m_context.folio_title);
}
void AssignVariables::assignTitleBlockVar()
+2
View File
@@ -69,6 +69,8 @@ namespace autonum
{
/// border_and_titleblock.folio(), folioIndex(), folioTotal()...
QString folio;
/// the folio's title, %T
QString folio_title;
int folio_index = 0;
int folio_total = 0;
QString plant;
@@ -92,12 +92,6 @@
</item>
<item row="2" column="4">
<widget class="QSpinBox" name="m_conductor_increase_sb">
<property name="maximumSize">
<size>
<width>55</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Increment: the value added to the counter each time a new number is dialled</string>
</property>
@@ -179,12 +173,6 @@
</item>
<item row="3" column="4">
<widget class="QSpinBox" name="m_element_increase_sb">
<property name="maximumSize">
<size>
<width>55</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Increment: the value added to the counter each time a new number is dialled</string>
</property>
@@ -255,12 +243,6 @@
</item>
<item row="4" column="4">
<widget class="QSpinBox" name="m_folio_increase_sb">
<property name="maximumSize">
<size>
<width>55</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Increment: the value added to the counter each time a new number is dialled</string>
</property>
+2 -2
View File
@@ -321,8 +321,8 @@ TitleBlockProperties BorderTitleBlock::localTitleBlock() const
/**
@brief BorderTitleBlock::current
@return the properties of the titleblock: for a folio placed in a
project the project's store holds them (DB-ACCESSORS-PLAN.md stage
4.4), for any other this object's own copy.
project the project's store holds them, for any other this object's
own copy.
*/
TitleBlockProperties BorderTitleBlock::current() const
{

Some files were not shown because too many files have changed in this diff Show More