Skip to content

feat(schematic): place symbols mirrored - #485

Merged
neusse merged 4 commits into
mixelpixx:mainfrom
triglav-modular:feat/450-schematic-mirror
Sep 11, 2026
Merged

neusse merged 4 commits into
mixelpixx:mainfrom
triglav-modular:feat/450-schematic-mirror

Conversation

@triglav-modular

@triglav-modular triglav-modular commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Symbols can be placed mirrored. add_schematic_component and batch_place_components take an optional mirror of "x", "y" or "none".

Konnect already modelled mirroring completely on the read side and could never write it. The model carries mirror, the parser reads (mirror x|y), the serializer emits it in eeschema's own field order, geometry.rs implements the transform with eeschema's exact semantics, and both list_schematic_components and get_schematic_pin_locations report through it. What was missing was any assignment: Symbol::mirror was initialised to None and never set to Some(...) outside the parser, so a mirrored schematic round-tripped losslessly while one authored through Konnect could never contain a mirror.

Part of #450

Acceptance mapping

owned by acceptance
this PR mirrored placement: a mirror argument on add_schematic_component and batch_place_components, the axis written to the file, and the placement's PinTransform carrying it so field anchors follow a reflected body.
successor feat/450-mirror-schematic-component mutation of an already-placed symbol — a mirror_schematic_component alongside rotate_schematic_component — and the sch_components toolset-capacity question that adding a tool raises. That PR is the terminal change and will carry Closes #450.

The successor plan is recorded on #450 itself so the issue stays the source of truth.

Approach

Three changes, no new geometry:

  • Symbol::set_mirror(Option<&str>) — None removes the token, because eeschema records an unmirrored symbol by omitting it and has no (mirror none).
  • mirror_arg reads the argument in the file format's own vocabulary rather than inventing one. An unknown axis is refused through invalid_arg, not dropped: a caller that misspells the axis is asking for a reflection, and placing the symbol unmirrored while reporting success is exactly the failure this argument exists to end. In the batch a bad axis refuses only its own entry, as every other per-item problem in that loop already does.
  • The placement's PinTransform takes the requested mirror instead of the hardcoded mirror_x: false, mirror_y: false, so Reference and Value follow a reflected body the way they already follow a rotated one (Schematic property placement: use library anchors instead of hardcoded ±3.81, account for rotation #101).

ComponentTargetUnit binds the axis as placement intent alongside x, y and rotation, and verify_component_expectations compares it against the committed readback, so a reflection that failed to write cannot return success. The from-source constructor records the axis the file already carries, which makes rotate, move and delete assert they left an existing reflection alone rather than fail on any symbol that has one. add_power_symbol binds None: a power symbol is placed upright and now says so.

The axes are mutually exclusive, which is why the argument is one enum rather than two booleans: eeschema stores at most one SYM_MIRROR_* flag, and mirroring about both axes is rotation by 180°, which belongs in at. The response keeps the existing mirror_x / mirror_y booleans and now reports them from the committed readback, per unit and at the top level.

Why rotation 180° is not a substitute. It is exact for a single-input symbol, but it reverses the visual order of a multi-pin symbol's pins: a 4-input NAND drawn output-left renders its inputs 5,4,3,2 instead of 2,3,4,5. Pin numbers and the netlist stay correct — only the drawing is wrong. That matters when the deliverable is the reproduced drawing, which is where this came from: transcribing hand-drawn sheets whose gates appear in all four rotations and mirrored.

One thing left out, and a question

I did not add a mirror_schematic_component alongside rotate_schematic_component, although the issue proposes one and mirroring an already-placed symbol currently needs a delete and re-place.

sch_components is at MAX_TOOLS_PER_TOOLSET (20), and that constant says "don't raise this number without a conversation". Rather than raise it, split the toolset, or file the tool somewhere it does not belong, this PR stops at the placement arguments — one reviewable outcome, no tool-count churn, no tool-directory.md or registry changes.

Where would you like it to live? I am happy to follow up with the mutation tool once that is settled.

Branch and dependencies

Base branch: main. Reconstructed once onto 4a5dd41e470c9b2a12ce56457d7d2240fe7e80d8, the merge of #489 and current main at the time of the push.

Depends on: #489, now merged. This PR was held behind it on the maintainers' sequencing because both change sch_components.rs. It carries none of #489's commits — only its own mirror-placement work on top of them.

Series order: #489 → this PR → #499, the order set on 2026-09-09. After this one, the successor feat/450-mirror-schematic-component owns mutation of an already-placed symbol and is the terminal Closes #450.

Unique commits: four.

commit what it owns
feat(schematic): place symbols mirrored the mirror argument, Symbol::set_mirror, and the placement PinTransform carrying the axis
test(schematic): place mirror fixtures from the file's own lib_symbols the CI fix — the tests were the platform-dependent part, not the change
fix(schematic): bind the placement mirror as verified intent the axis bound as placement intent, so a reflection that did not reach the file cannot return success
docs(api): record the placement mirror contract the docs/API_MIGRATIONS.md entry asked for on 2026-09-10

Next PR to promote after this one: #499, which writes pre-commit validation against the final shape of these handlers.

Compatibility and safety

  • Public API additions only. A new optional mirror property on two existing tool schemas. Omitting it is exactly the previous behaviour, and every existing call keeps its meaning.
  • Response additions only. mirror_x / mirror_y now appear on the component mutation readback, per unit and at the top level. Both names already exist elsewhere in the API (list_schematic_components, get_schematic_component) and carry the same meaning here. Nothing was renamed or removed.
  • File mutations go through the existing schematic write path unchanged; the only new write is one (mirror x|y) token on the placed symbol, in the position the serializer already emitted it from a parsed file.
  • Durable migration record. docs/API_MIGRATIONS.md gains ## Unreleased: mirrored schematic placement (minor release), inserted above the existing entries and preserving every one now on main — the file is byte-identical to main once the new entry is removed. It records the accepted values, that omitting mirror is exactly the previous behaviour, why there is no both-axes spelling, what an unknown axis refuses, and that mirror_x/mirror_y are read from the reparsed committed file and bound as intent.
  • No new tool, so no registry tool_count, tool-directory.md, DEV.md or README count changes. cargo xtask fix-doc-counts --check reports documentation already current.
  • Rollback is reverting the commit; no format migration is involved, and a file written by this code is one eeschema already writes.

Validation

cargo fmt --all -- --check                                        clean
cargo test --workspace --locked --lib --tests                     pass (1195 tests in konnect-core, 0 failed)
cargo test --workspace --locked --doc                             pass
cargo clippy --workspace --locked --all-targets -- -D warnings    clean

The exact head under review is 16c9a0b52e14efaa721cdaf0bbe6c117a519907a, on base 4a5dd41. The four commands above were also re-run under env -i with an empty HOME and KICAD10_SYMBOL_DIR / KICAD10_FOOTPRINT_DIR unset — a bare runner in one command — and pass there too.

All ten required checks pass on the exact head under review, 16c9a0b52e14efaa721cdaf0bbe6c117a519907a, on macOS, Ubuntu and Windows.

Every new guard was neutered and watched to fail before being trusted:

guard removed test that must fail result
mirror_arg accepts any axis placing_with_an_unknown_mirror_axis_refuses_without_writing FAILED
placement stops calling set_mirror placing_mirrored_reflects_the_body_and_its_field_anchors FAILED
PinTransform back to hardcoded false placing_mirrored_reflects_the_body_and_its_field_anchors FAILED
batch stops passing mirror batch_place_components_mirrors_per_component_and_refuses_a_bad_axis FAILED
batch skips validation batch_place_components_mirrors_per_component_and_refuses_a_bad_axis FAILED
mirror dropped from the readback comparison native_readback_intent_mirror FAILED

Re-neutered after the reconstruction, because the merge with #489 rewrote the block both changes touch:

guard removed tests that must fail result
mirror_x / mirror_y forced to false in component_mutation_readback_from_schematic native_readback_intent_mirror, placing_mirrored_reflects_the_body_and_its_field_anchors, batch_place_components_mirrors_per_component_and_refuses_a_bad_axis, and #489's field_placement_moves_the_text_and_keeps_its_value, field_placement_hides_then_shows_a_field, field_placement_never_rewrites_a_value_that_looks_like_a_placement 6 FAILED, with post-write mirror differs from bound intent for UUID …
#489's field_placements insert dropped from the same block six field_placement_* tests 6 FAILED

The second row is there on purpose: it proves the conflict resolution kept #489's half live rather than merely compiling beside it. The first row shows the binding reaches beyond the mirror tests — with the observed axis lost, every mutation on a symbol the fixture already mirrors refuses, which is the guard working.

Two things worth flagging rather than burying.

A first push failed CI on Linux and Windows and the tests were the platform-dependent part, not the change. They resolved Device:R through the installed KiCad symbol libraries, so they passed on a machine with KiCad and failed on every runner without one. They now place ecc83-pp:R, which the real eeschema fixture already embeds — ensure_lib_symbol returns on an embedded symbol before consulting any library source. Confirmed by running them with HOME and KICAD10_SYMBOL_DIR pointed at an empty directory.

I did not add a "mirror" case to native_placement_readback_requires_requested_values. I tried, then neutered the guard and watched the test pass anyway. That matrix asserts only that the readback returns kind: "stale_target", and its fixture trips component UUID … has a conflicting instance reference before any field comparison runs — a control arm where nothing differs also passes. So it is currently vacuous for unit, lib_id, x, y, rotation and Value too. I left it alone rather than widen this PR, and did not add a case that would have looked like coverage while proving nothing. Happy to open a separate issue if that is useful.

The field-anchor test was wrong on its first pass and is worth flagging: it compared the two placements' raw Reference coordinates against the 40 mm the caller asked for, but the origins snap to the 1.27 mm grid and land 39.37 mm apart, so the assertion passed with the transform neutered. It now compares each anchor against its own origin and asserts the reflection exactly negates it — +2.032 unmirrored, −2.032 with (mirror y).

Verified against real KiCad (10.0.6), not only in-process. A 74xx:74HC00 placed three ways, then read back:

placement inputs 1,2 output 3
unmirrored x = 52.07 (left) x = 67.31 (right)
mirror: "y" x = 107.95 (right) x = 92.71 (left)
mirror: "x" pins 1 and 2 swapped in Y unchanged in x

which is SYM_MIRROR_Y negating screen-X and SYM_MIRROR_X negating screen-Y, as geometry.rs documents. Then:

  • kicad-cli sch export netlist loads the file and lists all three placements;
  • kicad-cli sch upgrade --force — KiCad's own writer — round-trips both (mirror x) and (mirror y) unchanged;
  • mirror: "none" writes no token at all.

The clearing path was also exercised against tests/fixtures/ecc83_multiunit.kicad_sch, a real eeschema save whose P3 and C1 are genuinely mirrored, during development of the mutation tool that this PR ultimately left out.

Not run: Windows and Linux; no environment here. Nothing in the change is platform-dependent — it is one token in the schematic writer — and hosted CI covers the build and test matrix.

Review checklist

  • The diff is focused and contains no generated output, personal data, or unrelated cleanup.
  • The branch includes current upstream/main, has no merge conflicts, and CI passed on this exact head (all ten checks green on 16c9a0b).
  • The branch was based on latest upstream/main, not a release tag.
  • The PR shows only its unique commits and diff; dependencies and series position are explicit.
  • New names follow docs/NAMING_CONVENTIONS.md; no public renames.
  • New behavior and failure paths have regression coverage.
  • File mutations are atomic and preserve unrelated content.
  • IPC mutations — none in this change.
  • No tools added or removed, so no counts or docs to update; the additive argument is recorded in docs/API_MIGRATIONS.md.

@triglav-modular
triglav-modular marked this pull request as draft September 7, 2026 16:53
@triglav-modular
triglav-modular marked this pull request as ready for review September 7, 2026 17:02
@neusse

neusse commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Ask first: make this PR's issue accounting explicit, but do not rebase yet.

The placement-only increment is an acceptable focused first step. It does not need to add mirror_schematic_component or raise the sch_components toolset limit in this PR. Please:

  1. Change the body to Part of #450.
  2. Add an acceptance mapping that says this PR owns mirrored placement for add_schematic_component and batch_place_components, while an explicitly named successor will own mutation of an already-placed symbol and be the terminal Closes #450 change.
  3. Add the successor plan to Add unit-selectable mirroring for already-placed schematic symbols #450 so the issue remains the source of truth.

This branch overlaps #483 and #489 in the schematic mutation/placement files and predates current main. Do not spend a rebase yet; we will give the exact refresh base and order after #442 lands. No redesign of the implemented placement support is requested in this triage pass.

@neusse neusse added the status:waiting-on-author Next actor: the PR author — one checklist, 14-day target label Sep 7, 2026
@triglav-modular

Copy link
Copy Markdown
Contributor Author

Done, and no rebase — the branch is untouched, waiting on the refresh base and order you set after #442.

  1. The body now reads Part of #450. GitHub confirms this PR closes nothing.
  2. Acceptance mapping added: this PR owns mirrored placement (add_schematic_component, batch_place_components, and the placement PinTransform carrying the axis so field anchors follow a reflected body). The successor, feat/450-mirror-schematic-component, owns mutation of an already-placed symbol and is the terminal Closes #450.
  3. The successor plan is on Add unit-selectable mirroring for already-placed schematic symbols #450.

One thing in that plan worth your eye before I write it: the reason the mutation tool is not here is that sch_components sits at MAX_TOOLS_PER_TOOLSET, and the constant asks for a conversation before it moves. So step 2 needs a decision — raise the cap, split the toolset, or house the tool elsewhere — and I would rather have that decision than open a PR presuming one. Happy to raise it as a design discussion whenever it suits; it is not blocking this PR.

@neusse neusse added status:waiting-on-dependency Next actor: the dependency owner — see linked blocking issue and removed status:waiting-on-author Next actor: the PR author — one checklist, 14-day target labels Sep 8, 2026
@mixelpixx

Copy link
Copy Markdown
Owner

#442 has landed as 1cd4978. This PR is second in the sch_components.rs overlap set, behind #489.

Nothing is needed from you yet: hold the rebase until #489 merges, then reconstruct once onto the main that results, with only your one unique commit, and reply with the new head SHA. Rebasing now would only mean doing it twice.

@neusse — same note as on #489; override if you sequenced these differently.

@neusse

neusse commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Queue status remains dependency-blocked; please do not rebase yet. #489 must land first because both PRs modify sch_components.rs.

For the eventual reconstruction, please also correct the PR body so its dependency/series section reflects the actual #489 ordering, and add durable docs/API_MIGRATIONS.md coverage for the additive mirror request/readback contract. Once #489 merges, reconstruct this placement-only PR once onto the resulting main, keep only its unique mirror-placement work, run all ten checks, and reply with the new head SHA for substantive review.

Part of #450 remains correct; the later mutation PR is still the terminal change. status:waiting-on-dependency is the correct label today.

@mixelpixx

Copy link
Copy Markdown
Owner

#489 has landed as 4a5dd41e470c9b2a12ce56457d7d2240fe7e80d8, so this PR is now the next actor in the sch_components.rs sequence.

Refresh base: 4a5dd41e470c9b2a12ce56457d7d2240fe7e80d8 (current main). Please reconstruct once onto it with only your unique mirror-placement commits, and in the same push:

  1. correct the body's dependency/series section to the actual ordering (behind feat(schematic): move and hide a symbol's field text #489, which is now merged);
  2. add a docs/API_MIGRATIONS.md entry for the additive mirror request/readback contract, inserted above the existing entries and preserving every entry now on main (fifteen-plus, including feat(schematic): move and hide a symbol's field text #489's);
  3. run all ten checks on the exact head and reply with its SHA.

Both of George's asks above stand; nothing further is requested before substantive review. Part of #450 remains correct and the later mutation PR stays the terminal change. Label → status:waiting-on-author for the reconstruction.

@mixelpixx mixelpixx added status:waiting-on-author Next actor: the PR author — one checklist, 14-day target and removed status:waiting-on-dependency Next actor: the dependency owner — see linked blocking issue labels Sep 11, 2026
triglav-modular and others added 4 commits September 11, 2026 15:08
Konnect models mirroring completely on the read side and could never
write it. The model carries `mirror`, the parser reads `(mirror x|y)`,
the serializer emits it in eeschema's own field order, geometry.rs
implements the transform with eeschema's exact semantics, and
list_schematic_components and get_schematic_pin_locations both report
through it. What was missing was any assignment: `Symbol::mirror` was
initialised to None and never set outside the parser, so a schematic
authored through Konnect could not contain a mirrored symbol (mixelpixx#450).

add_schematic_component and batch_place_components take an optional
`mirror` of "x", "y" or "none", using the file format's own vocabulary
rather than inventing one. An unknown axis is refused through
invalid_arg instead of being dropped: a caller that misspells the axis
is asking for a reflection, and placing the symbol unmirrored while
reporting success is the failure the argument exists to end. In the
batch, a bad axis refuses only its own entry, as every other per-item
problem there already does.

The placement's PinTransform takes the requested mirror rather than the
hardcoded `mirror_x: false, mirror_y: false`, so Reference and Value
follow a reflected body the way they already follow a rotated one
(mixelpixx#101).

Rotation 180 is not a substitute. It is exact for a single-input symbol
but reverses the visual order of a multi-pin symbol's pins: a 4-input
NAND drawn output-left renders its inputs 5,4,3,2 instead of 2,3,4,5.
Pin numbers and the netlist stay correct; only the drawing is wrong,
which matters when the deliverable is the reproduced drawing.

Verified against KiCad 10.0.6, not only in-process: a 74HC00 placed with
(mirror y) reports its output at the origin's left and its inputs at the
right, (mirror x) swaps pins 1 and 2 in screen-Y, kicad-cli exports a
netlist listing all three placements, and `kicad-cli sch upgrade`
round-trips both tokens unchanged through KiCad's own writer.

No new tool: sch_components is at MAX_TOOLS_PER_TOOLSET, and that
constant asks for a conversation before it moves. Mirroring an
already-placed symbol therefore still needs a delete and re-place; the
pull request raises where such a tool should live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two placement tests resolved Device:R through the installed KiCad
symbol libraries, so they passed on a developer machine with KiCad and
failed on every CI runner without one ("Library 'Device' not found").
The tests were the platform-dependent part, not the change.

They now place ecc83-pp:R, which the real eeschema fixture already
embeds; ensure_lib_symbol returns on an embedded symbol before it
consults any library source, so nothing outside the repository is
touched. Verified by running them with HOME and KICAD10_SYMBOL_DIR
pointed at an empty directory.

Placement preflights the project name against the file's instance paths,
which name ecc83-pp, so the fixture is written under that stem rather
than the shared helper's ecc83. The assertions are scoped to the symbol
each test places, since that fixture carries mirrored symbols of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mirror reached the response but was never bound as placement intent,
so nothing proved it landed. ComponentTargetUnit binds unit, lib_id, x,
y, rotation, fields and instance paths, and verify_component_expectations
checks each against the committed readback; mirror was reported from the
file but not compared. A reflection that failed to write would still have
returned success, which is the class of defect GOVERNANCE.md's house rule
exists to prevent.

ComponentTargetUnit now carries the axis. The placement constructor takes
it as intent from the request. The from-source constructor records what
the file already carries, so rotate, move and delete assert they left the
reflection alone rather than failing on symbols that have one -- verified
by moving and rotating mirrored symbols end to end. add_power_symbol
binds None: a power symbol is placed upright and now says so.

native_readback_intent_mismatch gains a "mirror" arm alongside rotation
and the rest, and the guard was neutered to watch the new test fail.

Not added: a "mirror" case in native_placement_readback_requires_requested_values.
That matrix asserts only that the readback returns kind "stale_target",
and its fixture trips "component UUID ... has a conflicting instance
reference" before any field comparison runs -- a control arm where
nothing differs at all still passes. Adding a mirror case there would
have looked like coverage while proving nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Konnect's tool schemas are public API and API_MIGRATIONS.md is where an
argument's contract is recorded; the PR body and the tool-directory row
are not that durable record. Asked for on mixelpixx#489 and on mixelpixx#514's design
acceptance in the same words, so this branch carries its own entry
rather than waiting to be asked a third time.

The entry covers what a caller needs: that `mirror` is optional on
add_schematic_component and on each batch_place_components entry, that
it speaks the file format's own "x"/"y"/"none", that omitting it keeps
the previous behaviour exactly, why there is no both-axes spelling, that
an unknown axis refuses the call (or only its own batch entry) rather
than placing the symbol upright, that the field anchors follow the
reflection, and that mirror_x/mirror_y are read from the reparsed
committed schematic beside x, y and rotation and bound as intent, so a
reflection that did not reach the file refuses with stale_target.

No code change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@triglav-modular
triglav-modular force-pushed the feat/450-schematic-mirror branch from aa3514e to 16c9a0b Compare September 11, 2026 13:38
@triglav-modular

Copy link
Copy Markdown
Contributor Author

Pushed, reconstructed once onto the base you named.

Head 16c9a0b52e14efaa721cdaf0bbe6c117a519907a, base 4a5dd41e470c9b2a12ce56457d7d2240fe7e80d8. I re-fetched origin/main immediately before the push and it was still that commit, so the base I named and the base I used are the same one.

Four commits: the three mirror-placement commits the branch already carried when you set the sequencing, plus docs(api): record the placement mirror contract for the docs/API_MIGRATIONS.md entry.

The migration entry is ## Unreleased: mirrored schematic placement (minor release), inserted above the sixteen entries already in the file. Cut it back out and the file is byte-identical to main, so every entry now on main — #489's included — is preserved.

The sch_components.rs conflict. #489 rewrote component_mutation_readback_from_schematic to build each unit from the reparsed committed file, and added units[].field_placements in exactly the block where this branch adds the mirror readback. I kept both halves, feeding the same json!.

mirror_x / mirror_y are now derived the way their neighbours are: both read symbol.mirror / anchor.mirror from the &cse::Schematic the caller loaded with cse::Schematic::load(path) after the write — the same object x, y, rotation, lib_id, fields and field_placements come from — never from the request or from the pre-commit model. The requested axis is bound separately on ComponentTargetUnit, and verify_component_expectations compares the two, so a reflection that did not reach the file refuses with stale_target rather than reporting success.

Dropping #489's field_placements insert from that block fails its six field_placement_* tests, which is how I checked the resolution kept their half live rather than merely compiling beside it.

Body corrected. The dependency/series section now records the real ordering — base 4a5dd41, depends on #489 (merged), series #489 → this PR → #499 — and lists the four commits individually. I also fixed two stale details: the test count, and a reference to get_schematic_component_info, which is not a tool; the tool is get_schematic_component. tool-directory.md is untouched, since neither tool's registry description changed.

All ten required checks are green on 16c9a0b.

@neusse neusse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head review complete on 16c9a0b52e14efaa721cdaf0bbe6c117a519907a.

The reconstructed branch contains only the mirrored-placement increment on current main; the #489 conflict resolution preserves both field-placement and mirror readback. The optional mirror contract, serialized axis, transformed field anchors, committed-file intent verification, compatibility/migration record, real-KiCad evidence, and negative controls satisfy this PR's assigned half of #450. All ten required checks are green and there are no unresolved review threads.

Part of #450 is correct: the named successor owns mutation of an already-placed symbol and remains the terminal Closes #450 change. This exact head is ready for the user's merge authorization.

@neusse neusse added status:ready-to-merge Next actor: automation or maintainer — exact head reviewed and removed status:waiting-on-author Next actor: the PR author — one checklist, 14-day target labels Sep 11, 2026
@neusse
neusse merged commit 3dbfd9a into mixelpixx:main Sep 11, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:ready-to-merge Next actor: automation or maintainer — exact head reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants