Split from #913's second suggestion. There was no shortcut for the common
"duplicate with offset" convention; the nearest existing feature,
"Collage multiple", is a different workflow (a dialog for repeating a
paste in a grid pattern, not a one-shot duplicate).
Ctrl+D copies the selection and places it immediately, offset by a
configured spacing and direction -- no interactive follow-the-cursor
step, unlike Ctrl+V. The first press (or after the setting is explicitly
reopened) shows DuplicateOffsetDialog: spacing in grid steps, direction
up/down/left/right. Every later press reuses whatever was confirmed then,
silently, so a row of copies is one key held down and tapped, not a
dialog every time -- unattended, repeatable stamping is the actual point
of a duplicate shortcut, which a dialog or an interactive placement step
on every press would defeat. A separate "Configurer la duplication..."
entry reopens the dialog on demand to change the setting later. Cancel
leaves the diagram untouched -- verified, not assumed: qet_diff against
the saved file shows 0 added.
Chaining ("keep tapping to lay out a row") needs no special handling:
QET already reselects whatever a paste just added
(PasteDiagramCommand::redo()), so the next Ctrl+D naturally continues
from the copy just placed rather than the original.
The offset is applied by hand rather than by asking paste()/fromXml() to
place the copy at a target position. Both of those feed the position
through Diagram::snapToGrid(), which reads
QApplication::keyboardModifiers() and rounds to the nearest PIXEL instead
of the grid whenever Ctrl is held -- and Ctrl is always held here, this
action's own shortcut being Ctrl+D. Measured before settling on this:
routing the offset through paste() first produced copies off-grid on both
axes, by an amount that tracked the selection's own bounding-box geometry
rather than being a fixed error -- caught by qet-mcp's qet_elements
against the saved file, not by eye. fromXml() is instead called with no
position argument at all (leaves every item at its source coordinates,
landing the copy on top of the originals -- (0,0) is not a position, this
is "keep the source coordinates"), and the offset is added directly with
setPos(). A plain addition cannot be off by a rounding rule that never
runs.
Conductors are not in the hand-translated set: fromXml() itself does not
reposition them either -- they load after elements are already in their
final place and take their geometry from their terminals, which have
already moved with the elements that own them. Verified this holds: drew
a conductor by hand between two elements (drag, not click-click),
selected both, Ctrl+D, and the new conductor correctly joins the two new
elements via qet_conductors -- not the originals, not a mix.
Verified end-to-end on a built binary via qet-mcp, not by eye:
before L2 (303,207) L9 (512,196) -- deliberately off-grid
spacing=2, down (303,227) (512,216) -- +0,+20 exactly
same again, 2nd (303,247) (512,236) -- +0,+20 again, chained
Both elements land exactly the configured offset from their immediate
source regardless of the selection's own alignment. Qt 6.10.2, ctest
11/11.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A property value that is entirely whitespace -- reported in #973 as a
workaround (setting a title-block custom variable to a single space, the
only way to give it a value other than blank before that bug was fixed in
#989) -- did not survive a save/reload cycle. Two independent causes, both
needed for the round trip to actually work:
1. DiagramContext::toXml() called .trimmed() on every stored value before
writing it, unconditionally. For ordinary content this only strips
accidental leading/trailing whitespace, but for a value that IS
whitespace it collapses the entire thing to "", indistinguishable from
a value that was never set.
2. QDomDocument::setContent(), used to parse the project file, discards a
text node that is entirely whitespace by default. Confirmed in
isolation, outside any QET code: parsing "<a> </a>" with the default
ParseOptions gives QDomElement::text() == ""; adding
ParseOption::PreserveSpacingOnlyNodes gives " ". So even once (1) stops
destroying the value on save, the very next load throws it away again.
Fix (1) only trims when the trimmed result isn't empty, i.e. leaves an
all-whitespace value untouched. Fix (2) adds PreserveSpacingOnlyNodes to
the one setContent() call that parses a project file
(QETProject::readProjectXml()) -- not the other ~19 call sites in the
codebase (clipboard paste, element/macro loading, translations, autonum
context), which read different, narrower documents and are not implicated
in this report.
Blast radius of (2): every place that walks a QDomNode's children already
filters on isElement() (see QET::findInDomElement()), so the extra
whitespace-only text-node siblings this keeps around are inert wherever
current code already expected only elements. The one place it isn't inert
is exactly the bug -- calling .text() on an element whose entire content
is whitespace.
Verified end-to-end, not just at one stage: a single-space title-block
variable now survives two successive --resave cycles unchanged (confirmed
byte-for-byte in the saved XML), and renders as blank space rather than
literal placeholder text or a vanished value. Re-saved all 24 shipped
examples with and without this change and diffed: 23 byte-identical, the
one that differs (schema_indus.qet) differs only in element uuids -- and
resaving it twice with the SAME unpatched binary produces that same kind
of diff, confirming it is pre-existing non-determinism in files that
predate persisted uuids, unrelated to this change. Qt 6.10.2, ctest 11/11.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
BorderTitleBlock::updateDiagramContextForTitleBlock() skips merging a
page-level custom variable into the title block's render context whenever
its value is empty -- added by PR #572 to fix#531, where an empty
page-level value was shadowing a real project-level one of the same name.
But skipping the merge removes the key from the context entirely, and
TitleBlockTemplate::interpreteVariables() only replaces "%name"/"%{name}"
when "name" is an actual key in that context -- anything absent is left as
its own literal placeholder text. Folio Properties auto-adds every one of
a template's custom variables to the Custom tab with an empty value (#271/
#495) precisely so the user only has to fill in what's missing; until they
do, that variable now renders as e.g. "%label1" instead of blank.
Reproduced two ways: a synthetic fixture, and examples/2612_ats_singlephase.qet
itself, which already carries three such auto-added-but-unset properties
("label1", "label2", "label3") and renders all three literally on current
master.
Fix: skip the empty page-level value only when a project-level one already
exists to show through (preserving #531's guarantee); otherwise still merge
it in empty, so the placeholder resolves to blank rather than falling out
of the context altogether.
Verified against the shipped example (--export-png, before/after crop of
the rendered title block): "%label1"/"%label2"/"%label3" now blank. A
variable never added to the Custom tab at all ("%client", also present in
the same example) is unaffected -- nothing was ever configured for it, and
that is a separate, narrower case. Qt 6.10.2, ctest 11/11.
A related but distinct issue -- DiagramContext::toXml() trims a stored
value before saving, so an all-whitespace value is written as empty --
explains a second symptom from the same report (a single-space "workaround"
value vanishing after the project is reopened) but touches every
context-backed property, not just title blocks, and is left for a separate
fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Use a bound VACUUM INTO path and remove the stale SQLite
handle declaration. Fix shell continuations in Windows CI and
Debian installation instructions.
A <graphics_table>'s <query> is stored in the .qet and executed when the
project loads. SQLite produces rows lazily, so the cost of that query is
not bounded by anything the project contains -- it is bounded by how long
the loop reading the rows is willing to run. A recursive CTE takes one line
to make that forever:
WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) SELECT n ...
Put that in the <query> of any project's summary table and opening the file
pins a core at 100% and grows ProjectDBModel::m_record until memory runs
out. Measured on examples/industrial.qet with the query swapped, built from
master:
clean --export-bom 3.6 s, 396 rows, exit 0
poisoned --export-bom killed at 90 s, still going, no output
No scripting, no MCP, no flag beyond an ordinary export. Opening the file in
the editor is the same code path.
QetScriptApi::query() has the identical loop, and the script engine's own
30 s interrupt does not reach it: that aborts JavaScript, and this is C++
inside a single call. Left alone it hung a --run for 45 s until the harness
killed it.
Both loops now stop at projectDataBase::MaxResultRows (100000) and say so.
That is a backstop, not a page size: the largest table in the shipped
examples is 396 rows, and a caller that reaches 100000 has been handed
something it should not run to completion. It is not silent either way --
the model logs the offending query text, and qet.query() sets queryError(),
so a truncated result is never mistaken for a complete one.
clean --export-bom 3.6 s, 396 rows, exit 0 (unchanged)
poisoned --export-bom 20.2 s, 396 rows, exit 0, warning names the query
qet.query(recursive CTE) 3.8 s, 100000 rows, queryError() set
Reverting each cap restores the hang, so both checks discriminate.
Related to #983, which fixes a different flaw reachable through the same
stored query. Neither depends on the other.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A script reaches the whole project and, through the export calls, the
filesystem. That is a capability most people installing an electrical CAD
program never asked for, and leaving it on by default hands it to them
anyway. So QET_HAS_SCRIPTING builds now ship with it switched off.
QetSettings::scriptingEnabled() is the single answer, read by all three
places that need it, with QET_ENABLE_SCRIPTING=1 overriding the stored
value. The override is not decoration: a CI job or a batch run has no
dialog to tick, and a machine whose HOME is created fresh for each run has
nowhere to keep the setting either. It beats a stored "false" on purpose,
so a box unticked once cannot lock a build server out of --run for good.
Only the exact value "1" counts.
--run refuses with exit 3 and a message naming both ways in.
Projet > Exécuter un script... asks once, and turns the setting on if
the answer is yes. Asking beats grey: a disabled menu
entry says something exists and nothing about how to have
it, and this is the pattern people already know from
macro security in office software.
Configurer QElectroTech > Général > Projets has the checkbox, for
turning it back off. While the environment forces
scripting on, the box is disabled and says why, and
applyConf() then leaves the stored value alone rather
than quietly overwriting it.
runOnProject() checks as well, after both callers have. It is the one
function that actually evaluates JavaScript, so it is the one place a
future caller cannot forget to ask; the callers check first only to give a
better answer than it can.
Verified on the built binary, all four states, with an isolated HOME:
stored env result
absent - refused, exit 3
true - script runs, exit 0
false - refused, exit 3
false 1 script runs, exit 0
tst_scriptingsetting covers the same matrix hermetically, in its own
QSettings scope, and was mutation-checked: flipping the default to true
turns defaultsToOff() red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two issues on the interactive paste path:
1. Stall: DiagramEventAddPaste's constructor called Diagram::fromXml()
with no database batching, so every addItem() emitted dataBaseUpdated()
and each connected table model re-ran its full SQL query. A typical
paste (~40 elements + ~40 conductors) triggered ~77 rebuilds of the
table models -- measured at ~2.1 s of pure fromXml time on a large
project. Project loading already batches this via
setUpdateBlocked()/blockSignals() (QETProject::readProjectXml); the
paste path now does the same: block during fromXml, one updateDB()
after. Measured fromXml: 2114 ms -> 143 ms.
2. Cursor jump: m_initial_cursor was set to the group origin but the
physical cursor stayed at the Ctrl+V press location, so the first
mouseMoveEvent computed a large delta and the items jumped on first
touch. Warp the cursor to the group origin after placement so the
baseline and the actual cursor position match.
Commit dd0c194a3 (#913) moved the pasted group to the cursor position
at construction time. The desired behaviour is that items appear at
their original XML coordinates (where they were copied from) so the
user starts from the origin. The grid-snapped movement baseline and
the context-menu restoration from that commit are kept.
LinkElementCommand::redo() already had a check meant to catch exactly
this -- two report-linked conductors whose properties disagree -- and
ask the user which to keep via PotentialSelectorDialog. It never
worked: it built ONE combined list from three unrelated fields
(tension_protocol, wire_color, wire_section) and tested that whole
list for string equality, so a tension-protocol value could never
equal a wire-colour value even when every field individually matched
across every conductor. Worse, "wire_color"/"wire_section" are
ConductorProperties::m_wire_color/m_wire_section, a separate free-text
documentation pair that says nothing about how the wire is actually
drawn -- that's "color"/"style" -- so the one field #974 is actually
about was never compared at all.
Fixed by comparing each relevant field (text/num, function, tension
protocol, colour, line style) separately. Downloaded the reporter's
actual project, confirmed the mismatched wire reads color="#0000ff" on
one side of a "Folio suivant"/"Folio precedent" link and
color="#55aa00" on the other, with the link's other four conductors
matching correctly (ruling out a rendering artifact) -- see PR #980's
checkContinuity() extension, which now flags this class of mismatch on
sight.
Extracted the comparison into its own static
reportLinkNeedsPotentialChoice(), for the same reason
ConductorCreator::needsPotentialChoice() already exists as its own
method: a caller with nobody there to answer a modal dialog needs to
check first and decline, and the condition must not drift away from
the one redo() actually applies.
Fixing the comparison surfaced a real, previously-latent hang in this
session's own qet.linkElements(): PotentialSelectorDialog::exec() is a
plain QDialog::exec(), not routed through QET::QetMessageBox, so
headless --run has nobody to answer it. Measured directly -- hung
until killed with the property-comparison fix alone, clean refusal
after adding the guard. linkElements() now calls
reportLinkNeedsPotentialChoice() before constructing the command and
declines with a clear reason, the same choice addConductor() already
makes about ConductorCreator's own equivalent dialog.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a KColorButton next to the 'Utiliser les couleurs du système'
checkbox in the Apparence settings tab:
- When the checkbox is active (default), system colors are used
and the color button is disabled
- When the checkbox is inactive, the color button becomes active
and lets the user pick any color for the entire application
- useCustomPalette() builds a full QPalette from the chosen color
with proper light/dark text contrast, button shading, and icon
theme switching
- The chosen color is persisted in QSettings as
'customapplicationcolor' and restored on next startup
Files changed:
- sources/ui/configpage/generalconfigurationpage.ui: HBoxLayout
with checkbox + KColorButton, customwidget declaration
- sources/ui/configpage/generalconfigurationpage.h: new slot
- sources/ui/configpage/generalconfigurationpage.cpp: load/save
custom color, enable/disable logic, toggled slot
- sources/qetapp.h: useCustomPalette() declaration
- sources/qetapp.cpp: useCustomPalette() implementation,
startup restore of custom color
DynamicElementTextItem::updateLabel() resolved %{label} in composite
text using the stale value from elementInformations()["label"], which
is only set once at load time and never updated when the folio/page
number changes.
Use element->actualLabel() instead, which resolves the label formula
(including %F, %f, %id) against the current folio at call time.
Two bugs reported by @arummler on #591 after merge:
"It works but to select the text field one has to right click on
it...I think there are competing handlers or something."
"the drag elements should be on the border of the box. In the
moment they appear directly left and right from the text."
Both reproduced headlessly (scripts/qet-gui-dialog.sh) against a fresh
build of current master and root-caused before touching anything.
Selection: DynamicElementTextItem::mousePressEvent() forwards a plain
click (no Shift) straight to parentElement()->mousePressEvent(), by
design and pre-existing -- it's what lets dragging a symbol by its own
label move the whole symbol rather than just the label. That's correct
and untouched here. But it means a plain click leaves the *parent*
selected, not the text, and #591's handles were wired only to the
text's own ItemSelectedHasChanged -- so they were only reachable via
Shift+click or a right-click's context menu (which happens to select
the item under the cursor for its own context menu, unrelated to the
Shift path), neither of which anyone reaches for to resize a text.
Confirmed with screenshots at each step, including that Shift+click
already reached the existing (if misplaced) handles correctly.
Fix: DynamicElementTextItem::refreshResizeHandlesVisibility() shows the
handles when either the text itself or its parent element is selected,
and Element gets an itemChange() override (it had none) that calls it
on each of its own texts when the element's own selection changes. Both
sides driven from itemChange(), Qt's own hook for exactly this and the
same one already used for the text's own selection.
First attempt drove this from paint() instead, since the PR's own
updateResizeHandlesPos() already runs there. That crashed reproducibly
(SIGABRT) on deselecting a text: paint() runs while QGraphicsScene
iterates its item list to draw it, and addResizeHandles()/
removeResizeHandles() mutate that list via QGraphicsScene::addItem()/
removeItem(), which cannot safely happen mid-iteration. Caught it with
the same headless repro before it went anywhere near a PR, moved the
logic to itemChange(), and re-ran the full sequence -- select, resize,
undo, deselect, twice through -- clean.
Position: updateResizeHandlesPos() placed the handles on frameRect(),
which is a box sized to the text's natural (idealWidth()) content and
then re-centred inside boundingRect() -- it does not grow with
textWidth(). Once a text has been widened, frameRect() stays tight
around the glyphs while boundingRect() -- the box QGraphicsView actually
outlines as the selection, and the box a user drags relative to -- grows
around it, leaving the handles stranded well inside the visible
selection border. Fix: position them on boundingRect() instead, which
does track textWidth(); confirmed by widening a text and checking the
handle lands exactly on the new edge rather than partway across it.
Verified headlessly end to end on the original report's own element
("motor off" on grafcet.qet, folio 1): a single plain left-click (no
Shift, no right-click) now shows both handles at the true box border;
dragging resizes correctly and the handle tracks the growing edge;
Ctrl+Z restores the -1 auto-width sentinel and the handles stay at the
reverted position; clicking away removes them; repeated twice with no
crash. Qt 6.10.2, ctest 12/12, no new warnings in either changed file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every structural question this API could answer, it answered by walking
live objects. The project builds a SQLite database that already knows
most of them, and nothing outside the application could reach it.
tables() what is queryable, tables and views
query(sql) rows, one object per row
queryError() why the last one returned nothing
This is not a new door. QElectroTech already ships a "Requête SQL
personnalisée" box in the element-query dialog where a user types
arbitrary SQL, guarded by projectDataBase::isReadOnlySelect(); query()
goes through projectDataBase::newQuery(), which applies that same rule
and returns the same rejection message. A script gets what a user
already has, and neither can write: DELETE, UPDATE and a chained
"SELECT 1; DROP TABLE" are all refused before reaching SQLite.
An empty result and a failure are told apart. query() returns no rows
for both, so queryError() carries the reason -- a refusal, or SQLite's
own message for a bad column -- and is empty when the query simply
matched nothing. Conflating those is how a silent typo in a column name
becomes "there are no such elements".
No updateDB() before querying, and that is a measured decision rather
than an omission. A script that has just edited something is the
expected caller, so a stale cache was the obvious hazard; but
projectDataBase maintains itself incrementally through addElement(),
elementInfoChanged(), addConductor() and the rest, which the undo
commands behind every edit already call. Tested both ways on the cases
most likely to go stale -- an element added and labelled, a conductor
property changed -- each queried immediately afterwards through both the
table and the view. Identical counts with the rebuild and without it,
and updateDB() repopulates every table, so calling it per query would
have been real cost for no benefit. The comment says so, so it is not
added back on the assumption it must be needed.
The views are the surface to depend on: element_nomenclature_view,
project_summary_view and wiring_list_view exist to be queried. The
tables are how the cache is arranged today and a column may move --
which is why tables() lists both and the header says which is which.
Verified against examples/industrial.qet, the largest shipped project:
618 elements counted, the busiest wire numbers ranked (0VDC 93 times,
24V2 64), and duplicate element labels found by GROUP BY ... HAVING --
V6 seven times, V5 six -- which is a design-rule question no tool here
could previously ask. Qt 6.10.2, ctest matches master.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drawing furniture beside the circuit: a free-standing note, a line,
a rectangle, an ellipse, a polygon.
texts() addText() setTextContent() setTextColor()
setTextRotation() deleteText()
shapes() addShape() deleteShape()
Added with the same AddGraphicsObjectCommand the corresponding GUI tools
use, and changed through the plainText/color/rotation properties those
items already publish, so a script's note undoes like a hand-placed one.
These are addressed by index into a listing sorted by position, reading
order, because they have no better identity: unlike an element they carry
no uuid, and unlike a conductor they have no terminal to be named by.
Position is what they have and it persists, so the ordering survives a
save and reload -- verified by listing before and after, including a
rotated text whose bounding box moves. It does not survive adding or
deleting one: indexes after that point shift the way a list's do, which
is why texts() and shapes() exist rather than a caller keeping a handle.
The sort is on sceneBoundingRect(), not pos(). A QetShapeItem keeps its
geometry in its line/rect/polygon and leaves pos() at the origin, so
sorting on pos() put three shapes drawn in three different places all at
(0, 0) and made every shape index refer to whichever the set yielded
first -- which is what the first version of this did, and the test that
caught it was asking for three shapes and getting index 0 three times.
Path is deliberately not offered: it is built by successive clicks and
has no two-point form to give here.
Verified headlessly: three texts added bottom-up and listed in reading
order, edited, recoloured, rotated, one deleted; three shapes added,
listed with their real geometry, the middle one deleted and the right one
gone; unknown shape name, invalid colour and out-of-range index all
decline with a reason. Saved, reloaded, both listings identical.
Qt 6.10.2, build clean, ctest matches master, qet-lint clean on the
generated project, qet-coherence-check clean on the example corpus.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps left over from the drawing verbs. A script could create a
conductor but not say what it was -- no number, colour, section or
formula -- and could not link a master to its slave, although
LinkElementCommand has been there all along and nothing bound it.
conductors() what is on this folio, and how to address it
conductorProperty()
setConductorProperty() num, formula, function, bus, cable,
tension_protocol, conductor_color,
conductor_section, color, text_color
elementLinkType() simple / master / slave / next_report / ...
linkedElements()
linkElements() two folio indices: a master and its slave are
normally on different folios
unlinkElement()
The property names are the ones the .qet file uses for the same fields,
so what a script sets is what a reader of the file sees rather than a
third spelling invented here.
A property is applied to every conductor of the same electrical
potential, not to the one conductor named. That is the rule the
application already follows -- SearchAndReplaceWorker pushes one
QPropertyUndoCommand per conductor of relatedPotentialConductors()
inside a macro -- because a wire number describes a potential, not one
drawn segment; setting it on one and leaving the rest of the potential
disagreeing would produce a file no GUI action could have produced.
Linking asks LinkElementCommand::isLinkable() rather than re-deriving
its rules, so a script cannot make a link the GUI would refuse: master
to master, a PLC master to a non-PLC slave, a next-report to another
next-report, or anything to an already-taken target.
A conductor is addressed as "the conductor on terminal i of element U".
It has no identity of its own to use instead: conductors carry no
persisted uuid, and the terminal1/terminal2 ids in the file are
folio-scoped integers QElectroTech renumbers on every save. Since the
change is potential-wide, any terminal of the potential names it equally
well, so in practice a potential is addressed from one of its leaves; a
terminal carrying several conductors names none of them and is refused
rather than guessed at.
Verified headlessly. Conductor: num, section and colour set from one end
of a potential and read back from the other, saved and reloaded, present
in the XML. Propagation shown to discriminate, which took two tries --
the first attempt wired A.0-B.0 and B.1-C.0 and saw no propagation,
correctly, because a coil's two terminals are opposite ends of the coil
and not one potential. Wiring a real hub at A.0 instead, a number set
via the B leaf appears on the C conductor too, in memory and in the
saved file. Cross-reference: a master on one folio linked to a slave on
another, linkedElements() agreeing from both ends, surviving save and
reload with link_uuid written on both folios; master-to-master,
self-link, unlink and relink all behave. Unknown property, invalid
colour, bare terminal and ambiguous terminal all decline with a reason.
Qt 6.10.2, build clean, ctest matches master, qet-coherence-check clean
on the example corpus, qet-lint clean on the generated projects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qet_diff keyed each conductor on its raw terminal1/terminal2 pair, with
a comment claiming that pair was "stable within a folio". It is stable
within a folio; it is not stable across a save. QElectroTech reassigns
those folio-scoped integer ids on every write, in whatever order it
serialises the elements, so one untouched conductor of ArduinoLCD.qet
goes from terminal1="1" terminal2="16" to terminal1="34" terminal2="15".
Diffing a project against a re-saved copy of itself therefore reported
29 of its 47 conductors as removed and 29 as added, with nothing
changed. That is the main thing this tool is for, so the conductor half
of the answer was noise in exactly the case it was wanted.
The format has two addressing schemes and a file can hold both at once.
Older conductors use the integer ids with no element1/element2; current
ones use terminal uuids from the .elmt definition plus element1/element2
naming the placed instances. A terminal uuid alone is not an identity --
it belongs to the definition, so two coils of one type share it and a
conductor between them keys as a self-loop -- so an end is identified by
the (instance, terminal) pair, taken from the conductor where it carries
one and resolved through the folio's elements where it does not.
Where an element predates persisted uuids there is nothing stable to key
on. Keying those on terminal geometry alone collapsed nine distinct
conductors of schema_indus.qet onto a single key, which is worse than
the instability it was meant to fix, so such ends stay unresolved, keep
a "#"-marked key, and the diff reports unstable_keys and says in words
that added/removed may not mean what they look like.
Measured over the 24 shipped example projects, 3190 conductors: 0
colliding keys, against 8 for the geometry-only key. On a re-saved but
otherwise untouched project: 0 added, 0 removed, against 29 and 29
before this change. A project with two conductors genuinely added still
reports exactly two added and none removed, so the check still
discriminates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scripting API (bugtracker #162) could place an element and move it,
and could count conductors but not make one. So a script could put a
coil and a motor on a folio and had no way to connect them, which is
most of what drawing is. This adds the missing verbs:
addConductor() wire terminal i of one element to terminal j of another
rotateElement()
setElementInfo() any information key
setElementLabel() the label key, by name, since it is the one people want
addFolio()
setFolioTitle()
elementUuids() what is on this folio
elementName()
elementTerminals() which terminal index is which, before wiring it
Each goes through the command the GUI already uses, so a script's edits
undo like manual ones and reach the project database the same way:
ConductorCreator (the drag-a-rectangle-over-terminals path, which is
what makes a new conductor inherit an existing potential's properties
and join auto-numbering), ChangeElementInformationCommand,
QETProject::addNewDiagram(), ChangeTitleBlockCommand. rotateElement()
pushes the same QPropertyUndoCommand on "rotation" that
RotateSelectionCommand pushes for an Element, rather than
RotateSelectionCommand itself, which works on the diagram's selection
and would mean rewriting the user's selection to rotate one element.
Terminals are addressed by index, not uuid. Terminal::uuid() is a
property of the catalog .elmt definition: empty for most of the
installed base, and where present, identical across every instance of
that element -- two coils of the same type placed side by side have
byte-identical terminal uuids, so a uuid cannot say which coil's A1 is
meant. elementTerminals() exists so a script can see the indexing
instead of guessing it.
The one real hazard is that ConductorCreator asks the user which
potential to inherit from when the two terminals sit on two different
existing ones, and it asks with a plain modal QDialog that
QET::QetMessageBox's non-interactive mode does not cover -- so under
headless --run there is nobody to answer and the call never returns.
Measured: with the check removed, that one call hangs until killed;
with it, it declines in 0.4 s. addConductor() therefore refuses that
case, the same way and for the same reason addElement() already refuses
the import-conflict dialog.
To make that check without duplicating the condition, existingPotential()
becomes static over an explicit terminal list and ConductorCreator gains
a public needsPotentialChoice() predicate. Behaviour of the GUI path is
unchanged; setUpPropertieToUse() passes m_terminals_list to the same code
it called before.
Verified headlessly against a copy of examples/ArduinoLCD.qet: new folio
titled, two coils placed, wired, labelled, an info key set and the
element rotated; saved, reloaded, and the conductor, label, title and
rotation (persisted as orientation="1") all read back. Re-saving the
result is byte-identical. qet-lint clean on the generated project;
qet-coherence-check clean on it and on the 24-project example corpus,
and shown to report 9 findings on a deliberately broken copy of the same
file, so the clean result discriminates. Qt 6.10.2, ctest identical to
master (the 61 failures are the vendored KDE ECM suite, present on both).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>