The lines of a wrapped or multi-line dynamic text were always centred,
whatever horizontal alignment the user picked, on the folio and in the
symbol editor alike. Follow the horizontal alignment instead.
The alignment still also sets the anchor point, as before, and saved
positions are unchanged. Existing multi-line texts set to the default
left alignment are now drawn left-aligned instead of centred.
Issue #1155
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A dragged shape put its pos() on the grid. pos() cannot be seen, and it
is off the drawn corner whenever the shape was drawn or resized with
Ctrl held, or rotated, so such a shape stayed off the grid however it
was dragged. Worse, Snap to grid (previous commit) moves pos() off the
grid to put the corner on it, so the next drag undid the snap.
QetShapeItem now overrides setPos(), which only the drag calls through
the virtual: dragged alone or with other shapes only, the top-left
corner of the drawn outline goes on the grid. Dragged together with
anything else it snaps by pos() as before, because the rest of the
selection follows this shape's movement and a corner correction would
take the symbols off the grid. Ctrl still drags freely: the snap goes
through Diagram::snapToGrid(), which reads it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The seven Edit > Align commands (Snap to grid and the six align
commands, discussion #1069) had no icons: the menu, the context menu,
command search and the shortcut bar showed them as bare text.
They are pixel-grid SVGs in ico/scalable/ drawn like the folio and
Add PDF icons: 24 pixel canvas, art on the inner 22 pixel grid, one
currentColor ink so misc/make_icon_themes.py writes the dark copies.
Each align icon is a guide line with two boxes of different lengths
against it; the centre ones show the guide only between the boxes.
Snap to grid is a faint grid with one box sitting exactly on a cell.
The boxes have a solid outline and a 35 % tinted inside, which keeps
the guide the strongest mark at 16 pixels.
Names follow the freedesktop icon names (align-horizontal-left, ...).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rectangles, ellipses, lines and polygons were left out of the Align
submenu: with only shapes selected every command was greyed out, and a
shape outside a group was ignored when aligning it with a symbol.
Shapes now take part like pictures. Their edges are the shape as drawn
(the new QetShapeItem::sceneOutlineRect(), without the pen, the 6 px
selection margin or the wider hover outline; the old code used
sceneBoundingRect() for grouped shapes and so aligned them 6 px off).
The point that goes on the grid is the top-left corner of that box: a
rectangle's corner, an ellipse's box. pos() is not used, because a
shape drawn with Ctrl held or rotated has its corners off the grid
while pos() is on it.
The menu's enable rule now counts what the command counts, a group as
one. Before, two symbols in one group enabled the six align commands,
which then did nothing and only said so in the status bar.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Grouping two symbols and then locking one of them (Lock position in its
properties) left the group in a broken state: dragging the unlocked symbol
pulled it away while the locked one stayed, and dragging the locked one
did nothing. The move simply dropped locked items, so the rest of the
group went without them.
A group with a locked member now does not move at all, whichever member
is dragged, and the status bar says why. This is the rule the item-groups
proposal (discussion #1070) set out for this case. The same rule applies
to the arrow keys and to the Align commands, which share
DiagramContent::removeNonMovableItems().
Also fixed on the way, for a plain selection with a locked symbol: a wire
between the locked symbol and one being dragged kept its user-placed text
moving with the dragged end. Such a wire is now redrawn only, as a wire to
an unselected symbol already is.
Checked in the GUI on two symbols joined by a wire (grafcet example),
master against this branch, positions read from the saved file:
- drag the unlocked member: master moves it 190 px, this branch moves
nothing and shows the message
- arrow keys on the selected group (3 runs each): master moves the
unlocked member, this branch nothing
- the same two symbols ungrouped: both move the unlocked one, as before
- user-placed wire text: master shifts it 190 px, this branch keeps it
ctest: 34/34.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Group and Ungroup are enabled in slot_updateComplexActions(), which ran
only when the selection changed. Grouping, ungrouping and undoing either
leave the selection as it is, and so does right-clicking an item that is
already selected, so the actions kept the state from before. The
right-click menu hides disabled actions, so it went on showing Group on a
selection that was now one group (and Ungroup after ungrouping), and the
one that would work was not in the menu at all.
Diagram::setItemGroup() is the one place an item's group changes (group,
ungroup, undo, redo, paste), so it now emits itemGroupChanged() and the
editor refreshes its actions on it, beside the selectionChanged
connection.
Checked in the GUI on two free texts, saving after each step: right-click
> Group, right-click again, Ctrl+Z, right-click again. Before, the second
and third right-clicks offered Group again and did nothing (both saves
still grouped). After, they offered Ungroup and ungrouped (0 grouped
texts in both saves); Group, Ungroup, Group also round-trips.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first fill from the document stored the label and wire text saved in
the file. QElectroTech saves what it last worked out, not what the formula
gives now: K%total-%id saved as K1-1 on a 3-folio project is K3-1 on the
folios, and the same for a wire's formula. So the document fill disagreed
with the folio fill on any file whose folios had been added or moved.
AssignVariables now works from a FormulaContext: the folio's number,
index, total, plant, location, title-block and project variables, and the
element's grid cell and prefix or the wire's four properties. The Diagram
overload fills one from a built folio, the database fills one from the
file, and both call the same evaluation.
Also brought in line with Diagram::fromXml(): an element Element::valideXml()
rejects is skipped with its wires; a folio where two elements number their
terminals alike (Element::fromXml() refuses one, by geometry), a frozen
formula wire text, and the older sequential-number attributes are left to
the folios. Each folio's border now starts from the defaults a new Diagram
has, not the previous folio's values.
tst_databasefromdocument: formulasAreWorkedOut (every variable kind, stale
saved values), unbuiltElementIsLeftOut, clashingTerminalIdsFallBack. Each
fails with its fix removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A project saved as "my.project.qet" showed "my" in every title block
cell using %{projectfilename}, and "my" again in %{savedfilename} after
a save. QETProject used QFileInfo::baseName(), which stops at the first
"."; completeBaseName() strips only the ".qet" suffix. Same fix as
#725 made for the PDF export's file name.
Verified headlessly on examples/industrial.qet copied to
my.project.qet, with a title block cell set to %{projectfilename}:
--export-svg shows "my" on master and "my.project" with this change,
on all 50 folios. For %{savedfilename}, a --run script calling
qet.save("") twice writes "my" on master and "my.project" with this
change. ctest 32/32.
Not changed: write() updates the saved* variables after the file is
written, so the values stored in a file are those of the save before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The project database -- behind parts lists, summary tables and the wiring
list -- was filled by walking the built folios: every element and
conductor object of every scene. Filling it from the document the project
was just read from instead is what the database needs before a project
could be opened without building every folio (qelectrotech-docker
DB-FROM-XML-SCOPE.md).
updateDB(document), called by readProjectXml(), fills diagram,
diagram_info, element, element_info, terminal and conductor from the
document, through the code the folios use: a BorderTitleBlock read from
each folio's XML gives the title-block values and each element's grid
cell, the embedded collection gives each definition, and the binders are
shared with the live path. Shapes, texts and pictures still come from the
folios (their boxes need fonts and pens).
It falls back to the folios, saying why in the log, when the document
does not carry what that needs: an item without a saved uuid (older files
-- the folios derive them on load), a conductor naming its ends the older
way, a %autonum folio number, a terminal showing its master's label, a
missing or unbuildable definition. A symbol label computed from a formula
is the one saved in the file, which QElectroTech writes as it computes it
on every save. QET_DATABASE_FROM_FOLIOS=1 forces the folio path.
tst_databasefromdocument saves every example once, then fills both ways
and requires identical tables (24/24, filled from the document each
time), and checks that an older file falls back with its reason. Red when
the document path is made to write a wrong grid cell. Database phase of
loading unchanged: industrial.qet 0.146 s vs 0.160 s, Polonez 0.040 s vs
0.038 s (median of 5).
QETProject::projectWideProperties() is split out of
updateDiagramsFolioData() so both fills use the same title-block context.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The configuration names "python" or "python3", or the Python the Windows
installer can add. When none of those can run, the assistant fails with
an error far from the cause, so the dialog now says so in bold, with what
to do: tick "Python pour l'assistant IA" in the installer or install
Python (Windows), or install Python 3 from the system's packages.
Windows 10 and 11 put a python.exe in WindowsApps that only opens the
Microsoft Store, so a PATH search alone reports Python where there is
none. A result there is reported as possibly that shortcut. Backslashes
are converted explicitly, not with QDir::fromNativeSeparators(), which
leaves them alone off Windows and so could not be tested here.
tst_aiassistantsetup: 17 cases (8 new: the bundled Python is taken
without looking on PATH; none, python.org, the Store shortcut with either
separator, and a Linux folder named WindowsApps). Each of the 4 new rules
was removed in turn and the test failed. ctest 32/32. In the GUI, the
same install layout shows the warning with no python3 on PATH and no
warning with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diagram_info.date -- and project_summary_view.date, which the summary
table dialog queries -- held the folio date read back from the title
block's text: QLocale::system().toDate() of the string
updateDiagramContextForTitleBlock() writes in the locale's short format.
Where that format has a two-digit year (en_US "M/d/yy") Qt reads the year
back as 19xx, so 741.qet's 2010-09-21 became 1910-09-21, and so did every
dated folio of the 24 examples (109 of 109).
bindDiagramInfoValues() now binds the folio's own date,
BorderTitleBlock::date(), the value that text was made from. A folio set
to show the current date or none gives the same date as before.
tst_dbfoliodate runs --run on examples/741.qet under LC_ALL=en_US.UTF-8
and expects 2010-09-21 from project_summary_view: 1910-09-21 without this
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dialog showing the configuration an AI assistant needs to start
QElectroTech's MCP server (misc/qet-mcp), with this installation's paths
filled in: pick the assistant (Claude Desktop, Claude Code, GitHub
Copilot in VS Code, Cursor, Gemini CLI, Codex CLI, LM Studio), the
drawings folder the assistant may use, and whether it may edit (off by
default). It says where the text goes, warns that text inside a project
from someone else can steer an assistant, and copies the text. It writes
nothing and starts nothing.
AiAssistantSetup finds the server beside the running program, in the two
places the packages put it (<root>/mcp beside <root>/bin on Windows,
<prefix>/share/qelectrotech/mcp beside <prefix>/bin otherwise), and the
Python the Windows installer can add. When there is none, the dialog says
the server is not installed with this version and links the guide.
The configuration is written with QJsonDocument, so Windows paths are
escaped correctly; Codex CLI gets TOML.
tst_aiassistantsetup (9 cases): both layouts, no server, the bundled
Python on Windows only, valid JSON for every JSON client with paths that
parse back unchanged, the "servers" key for VS Code, "type" only where
wanted, editing off unless allowed, escaped TOML. Each of 8 rules was
removed in turn and the test failed. ctest 32/32.
Checked in the GUI (Xvfb): Help menu opens it; from a build tree it
reports the server missing and disables Copy; from an install layout it
fills the paths, and the text Copy put on the clipboard, used unchanged
as a Claude Code configuration, ran a real qet_project_info call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateConductorPath() fits a stored profile to the new terminal
positions by sharing the horizontal difference over the profile's
horizontal segments and the vertical one over its vertical segments.
When a profile has no segment of non-zero length along an axis, the
difference along that axis was dropped and the last point joined the
terminal diagonally. On save that diagonal was written as one
axis-aligned segment, so on reopen pathFromXml() found the lengths
incoherent and rerouted the wire.
Every straight wire with a stored path hits this after a reload, as a
zero-length segment is saved as horizontal. Moving one end at a right
angle to the wire, in the direction that keeps its path type, showed it.
Generate a new path in that case, as is already done when there is no
profile for the path type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the previous commit: the line was built with chained arg()
calls, so a "%3" in a terminal's name was replaced by the conductor
count -- as before -- and now a "%4" by the uuid as well. One
multi-argument arg() substitutes each placeholder of the pattern once.
percentInNameKept renames a terminal of perceuse.qet "x%3y%4": listed as
"x1y{uuid}" before, "x%3y%4" now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The conductor calls name a wire end as element uuid + terminal index.
The index is the terminal's place in Element::terminals(), a sort by
position that is undefined between two terminals at the same point, and
the documentation ruled terminal uuids out as "empty for most of the
installed base". Since #1118 every terminal of an opened project has one.
- elementTerminals() ends each line with the terminal's uuid
(Terminal::stableUuid()); the text before it is unchanged.
- terminalIndex(folio, elementUuid, terminalUuid) returns the index the
calls take, or -1 if the element or terminal is not there, or if two of
the element's terminals carry that uuid.
- The class documentation says what does address a terminal: its uuid
together with its element's.
tst_scriptterminaluuid runs --run on perceuse.qet (552 elements, two
terminals at one point in some): every terminal listed with a uuid,
distinct within its element, found again at its own index; -1 for an
unknown or malformed uuid, an unknown element and a bad folio. Red when
terminalIndex() returns the wrong index. qet-mcp suite 253/253.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the previous commit: "Import scaled element" and "Import DXF"
add a definition to an open symbol through OpenElmtCommand, which, unlike
a paste, kept the imported terminals' uuids. With derived uuids, an old
symbol imported into itself (or into another with a terminal at the same
point) gave two terminals one uuid; importing any symbol whose terminals
already carried uuids did the same before this series. OpenElmtCommand now
renews the imported terminals' uuids, as PastePartsCommand does.
Checked in the editor with a stand-in scaler at scale 1: open
6es7_212-1ae40-0xb0__p3.elmt, import it into itself, save -- 6 terminals,
3 distinct uuids without this commit, 6 with it, the original 3 keeping
their derived values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A symbol file whose terminals have no uuid got a random one for each
terminal when opened in the element editor (PartTerminal's constructor),
written on save. Every copy of the same old symbol therefore ended up
with different terminal uuids, none of them the one a project gives the
same terminals on opening (TerminalUuids::fillMissing()).
ElementScene::loadContent() now reads a copy of the definition filled by
TerminalUuids::fillMissingInDefinition(), the same rule as a project,
including the next occurrence for the second of two terminals at one
point. Terminals that have a uuid keep it; a paste still renews them all
(PastePartsCommand).
Checked in the editor: 6es7_212-1ae40-0xb0__p3.elmt (no terminal uuids
in the collection) and tm3saf5r_layout.elmt with its uuids stripped (two
terminals at one point), opened, nudged back and forth, saved: this build
writes exactly the derived values (computed independently in Python),
the previous one random ones. Select all, copy, paste, save: 6 terminals,
6 distinct uuids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two problems with a custom variable newly added to a title-block template:
1. Find/Replace > Folio > Custom showed an empty table, so the user had to
know and type each variable name. It now lists every custom variable the
folios already carry plus those their templates use, with empty values.
An empty value now means "leave unchanged", like every field of the main
tab; only the variables actually filled in are written to the folios.
2. The title block showed the variable's own name ("%doc-type") until the
folio's properties were opened, because interpreteVariables() only
replaces names present in the context. Placeholders in the template text
that no key resolves now render blank, as auto-added unset ones already
did since #973. Only the template's own text is considered, so a value
that contains "%something" is never touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving an element from the element editor calls
ElementsCollectionWidget::locationWasSaved(), which runs clearData() on
the panel item: the icon is set to a null QIcon. The icon only comes
back through FileElementCollectionItem::setUpIcon(), but since the
#633 recursion guard that returns for good once m_icon_initialized is
set, and nothing ever reset it. The row stayed without an icon until
the whole collection was reloaded.
#1008 refreshed the picture caches on save, but no one asked them for
the new picture, so it could not fix this. Resetting the flag in
clearData() lets the next paint rebuild the icon, which then comes
from those refreshed caches, so the panel shows the new drawing.
The flag is reset after the base clearData(): its setIcon() emits
dataChanged(), which re-enters setUpIcon() and must still return early.
setUpIcon() sets the flag before its own setIcon(), so the #633 guard
is unchanged.
The same reset should also bring back a folder's icon after editing its
properties (editDirectory() calls clearData() too); read, not tested.
Project collection items were never affected: their setUpIcon() guards
on icon().isNull().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to #1088, covering its review comments and one behaviour
change found while testing the feature:
- MaterialList::load() names the columns from the machine key line when
the file has one, instead of from the translated label line above it.
A catalogue written in another language fills the element fields
again, and saving no longer replaces the key line with labels.
- The entry picked after "New entry" is found by comparing the columns
one by one instead of comparing the two maps as a whole: the entry
form leaves the empty columns out, so the record never matched the
line that had just been written and the search was cleared for
nothing. The search is now only given up when it really hides the new
line.
- Applying a catalogue entry pushes an undo command only when the live
edit is on. In the properties window, where there is none, the fields
wait for "Apply", so "Cancel" gives the element its own values back
instead of leaving the picked part in place.
- Cells a file holds past the header are kept in MaterialRecord::extra
and written back, so appending an article never shortens a line.
- An empty cell of a column describing the article itself (MaterialList::
isArticleBound: description, designation, manufacturer, order number,
supplier, model, ratings, dimensions, auxiliary block) clears the
field, so an element never keeps the manufacturer of the part picked
before. An empty cell of any other column (function, comment, notes,
plant, location, quantity, unity), and any column the file does not
hold at all, leaves the field alone.
- Comments left where the review asked for them: the corner button
lookup, the ';' separator fallback, the search filter cost.
Review of the previous commit:
- The load fallback compared a saved uuid with occurrence 0 only, so a
wire on the second of two terminals at one point of a symbol was lost
once the definition was replaced, and one on the first could go to
either of the pair (Element::m_terminals is sorted, not in definition
order). Element::parseTerminal() now records each terminal's rank among
the terminals of the definition at the same point, derivedUuid() uses
it, and fillMissing() starts from the same rank.
derivedUuidFoundAfterReplacement runs on perceuse.qet and industrial.qet
too: 154/156 and 670/671 wires without the rank, all with it.
qet-mcp: the first save of an older project now rewrites its wires from
the numbered form to the uuid form, and qet_diff keyed the two forms
differently, so an untouched resave showed every wire removed and added
(4 failures in test_qet_mcp.py). A uuid end is now resolved to the same
key as a numbered one: the terminal's definition position, moved to where
the wire docks, is the placed symbol's <terminal> record.
test_conductor_key_same_in_both_forms fails without it; 253/253 pass on
this build and on the previous stage's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Most symbols stored in older projects have no uuid on their terminals
(706 of the 900 in the 24 examples), so a terminal's identity is worked
out from where it sits in its symbol on every load (stableUuid()). That
is only sound while nothing keyed on it is kept between loads.
- On opening a project, every terminal of its embedded symbols without a
uuid gets that same derived value (TerminalUuids::fillMissing(), from
XmlElementCollection's loading constructor, before any folio is
built). The next save writes it, and the wires on it in the form that
names terminals by uuid, which QElectroTech reads since 0.8.0.
- The recipe moves to TerminalUuids::derived(), which stableUuid() now
calls, so the two cannot drift apart. A second terminal at the same
point of a symbol gets the next occurrence, and no value is given
twice within a symbol.
- findTerminal(): a wire whose terminal uuid is not found is matched to
the terminal whose derived value it is, so a saved uuid still finds its
terminal after the symbol's definition was replaced by one whose
terminals carry other uuids.
The project database's terminal and conductor tables are identical before
and after on all 24 examples except the 3 terminals that share a point
with another in their symbol (industrial.qet 1, perceuse.qet 2), which
now have an identity of their own. Every example keeps every wire through
a resave, and a second save changes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the previous commit:
- keep() could give an old uuid to a new terminal while another terminal
of the new definition already carried it (a moved terminal), leaving
two terminals with one uuid. A terminal carrying any old uuid is now
left alone, and an old uuid already in use is never handed out.
- copyDirectory() replaced a whole category of the embedded collection
(drag a folder onto the project's folder of the same name) without
carrying terminal uuids over. keepInDirectory() walks both trees by
name and calls keep() on each symbol.
- The "wire(s) not loaded" log line repeated the folio's list on every
paste; it is now written only when a folio is loaded.
tst_terminaluuids: 3 new cases, each red on the previous keep().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wire whose terminals have uuids is saved against them, and on load it
is reattached to a terminal with that uuid or dropped, with only a qDebug
line. Re-importing a changed symbol and choosing "replace" swapped the
project's definition for one whose terminal uuids differ; the placed
symbols kept the old ones until the project was reopened, so every wire
on them was lost at the next open, silently, and gone for good at the
next save.
- XmlElementCollection::copyElement(), where an embedded definition is
overwritten, carries each old terminal uuid onto the new terminal at
the same place and orientation (TerminalUuids::keep()). Terminals that
moved, and new ones, keep their own.
- Diagram::fromXml() records wires it could not reattach, logs them, and
the editor lists them in one warning after opening a project.
Measured on 2612_ats_singlephase.qet with the stored splice's terminal
uuids made to differ from the collection's: replace, save, reopen loads
34 of 131 wires on master, 131 with this change (GUI, both arms).
tst_terminaluuids covers keep() and runs the real loader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every scripting call names a folio by its index, and the index shifts when
a folio is added, removed or moved: a script that removes folio 0 and then
edits "folio 2" edits the wrong one. Texts, shapes, pictures, tables and
symbol text fields already have a uuid lookup for the same reason; folios
had none.
- qet.folioUuid(index): the folio's uuid, or "".
- qet.folioIndex(uuid): the folio's current index, or -1.
A folio saved without a uuid (132 of the 133 in the shipped examples) is
given one on load, derived from the file, so it is the same on every load
and is written on the next save. No two folios share one: a clash is
renewed on load.
Tests in misc/qet-mcp's integration suite, which drives these through
--run: a folio followed across the removal of the one before it, on a file
saved without folio uuids, and a new folio's uuid found in the saved file.
Both fail against a build without this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The anonymous namespace sat between the comment and the function, so
Doxygen attached the comment to describeEnd().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discussion #1070 proposed that rotate, like move, copy and delete, works on
the whole group once one of its items is clicked. Rotate (Space) turned
each member on its own spot instead, so rotating a group pulled it apart:
two grouped texts side by side ended up each turned in place, no longer
side by side.
When the selection is exactly one whole group -- wires aside, which follow
their symbols -- Rotate now turns it as one piece around its centre, as
"Pivoter le groupe" (Shift+Space) already does
(ItemGroups::soleWholeGroup()). Any other selection, including a single
member picked out of its group, rotates as before.
In the GUI, on two grouped texts selected by one click: Space on master
leaves both where they were, turned; here it gives exactly what
Shift+Space gives on both (both texts swung around the group's centre).
tst_itemgroups: 4 new checks; without the whole-group condition, a
picked member counts as a group and fails. ctest 24/24.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>