feat(schematic): place sheet pins on the top and bottom edges - #524
triglav-modular wants to merge 8 commits into
Conversation
neusse
left a comment
There was a problem hiding this comment.
Please close the import-overflow and result-truth gaps before this can merge.
Exact head reviewed: bdb9988d48fcc30e8965ba4a2a45b2c522c51595; all ten hosted checks are green, and the four-side mapping, explicit add/edit validation, readback, and closure reference otherwise match #514. One complete correction pass is needed:
import_sheet_pinsgenerates positions indefinitely from the total existing-pin count but never validates them against the selected edge. Enough pins can be written past a corner and then clamped/overlapped by KiCad—the exact silent-relocation failure this issue fixes. Precompute the full import, place relative to pins already on the selected edge, validate every generated point before the single write, and refuse atomically on overflow. Add left/right and top/bottom overflow regressions proving no pin is written.- For the new
sidebehavior, derivechanged_fieldsfrom the saved delta. Restating the side already present must not be reported as an actual change merely because it was requested; return an honest no-op or omitsidefrom changed fields. - Update the three
tool-directory.mdrows for the new publicsidecontract and use the requiredKiCadspelling in newly added prose/errors. Preserve the real-KiCad round-trip evidence in the PR body.
Then rerun the ten checks and reply with the new head SHA. Closes #514 remains correct after these cases pass.
|
All three are done. New head: 1. The import overflow. You were right that this is a defect and not a nitpick — Two regressions, and both compare the file's bytes before and after rather than only asserting an error came back:
The byte comparison is load-bearing, and that is measured rather than asserted. Two neuters: removing the refusal fails both tests on Live on a build of this head, sheet box x 50..130 / y 50..100, 20 labels onto the left edge:
The capacity is checked against KiCad rather than only against our arithmetic, since a limit that is wrong in the permissive direction would not show up in our own tests. That 19-pin file plus a 2. File hash unchanged across the first. 3. The real-KiCad round-trip evidence is preserved and was re-run on Gate on this head: Four new guards this round, four new tests, each neutered and watched to fail with its own message; the full table is in the body. |
|
Following up on my own reply: re-reading that commit after posting it, I found a regression it introduced, and it is in the exact line your first item asked me to change. New head Counting only the pins carrying the selected edge's rotation — which is what you asked for and is right — made the count precise, and that quietly removed a safety margin the blunt total had by accident. The fix is conservative counting, not a refusal: a pin whose edge cannot be determined is counted as occupying the edge being imported to, on the grounds that if we cannot tell which edge it is on, it could be this one — stacking further out is always safe where stacking over it is not. It adds no failure mode and changes nothing for a well-formed file, which has no such pin. Refusing the import outright would match the malformed-file principle, but it is a harsher behaviour change than this review asked for; say the word if you would rather have the refusal. One thing I cannot do yet, and I would rather say so than let it look green: the ten checks have not run on this head. #485 merged while I was working, its |
5a9c9f3 to
67a6516
Compare
|
Reconstructed onto current Two files conflicted.
No behavioural change surfaced, and I checked specifically because the commit path was rewritten underneath this feature. The four-side KiCad 10.0.6 round trip was re-run on Gate on this head: |
neusse
left a comment
There was a problem hiding this comment.
One functional import-placement gap remains on exact head 67a651648436238e264dee0c9aeb85147919c650.
The previous overflow, atomicity, truthful-result, and documentation requests are addressed. However, import_sheet_pins chooses the next slot from the count of pins attributed to the selected edge, not from the furthest occupied coordinate. With existing same-edge pins in slots 1 and 3, count + 1 selects slot 3 again and overlaps the existing pin. The current regression imports onto an empty opposite edge, so it does not exercise this sparse same-edge case.
Please compute the first slot after the furthest valid pin on the selected edge, retain the atomic overflow refusal, and add a sparse same-edge regression proving no overlap. Also refresh the PR body's exact-head evidence/checklist, which still names an earlier head. Then rerun all ten checks and reply with the new exact head. Closes #514 remains correct once this is fixed.
|
Fixed, and the fix found two more things on the way — one of which was a regression in my own first attempt at it. New head: You were right, and the round 1 regression could not have caught it. The next slot came from the count of pins attributed to the edge. A count does not say where they are: slots 1 and 3 hold two pins, so the import took slot 3 and wrote on top of the second. I reproduced it before changing anything rather than reading it off the source — the imported pin landed at 1. The slot division needs a tolerance, and the suite could not see that it was missing. 2. "Valid" is doing real work in your wording, and my first version ignored it and broke a working call. 3. One consequence is intended, but I would rather you chose it than inherit it from my reading of your sentence. A single pin on an edge's last slot fills that edge while every slot before stays free, so the import now refuses where it used to place pins into the gap. That is what "the first slot after the furthest valid pin" says, so that is what is implemented — but it seemed wrong to then tell that caller to enlarge a sheet with 18 free slots, so the refusal says this instead: If you meant the import to fill gaps rather than stack past them, say the word and I will change it here — it is small, and it would make that refusal unreachable. I did not assume it, because your sentence says the opposite. One wording fix came out of the same pass: an empty edge was being described as "already filled through slot 0". It reads "is empty" now, and the top-edge overflow regression — which overflows from an empty edge — carries the assertion. The capacity arithmetic now lives in one Evidence re-measured on this head, not relabelled, since round 2 changes where an imported pin lands. The four-side KiCad 10.0.6 round trip: Five guards this round, five tests, each neutered and watched to fail with its own message; the table in the body has them. Gate on this head:
The PR body is refreshed: exact head, base, commit count, the validation numbers, the round trip and refusal captures, the neuter table and the checklist. |
|
Please fix these three remaining placement-safety cases, then refresh the branch onto current
Add focused regressions for: a shrunk-edge multi-label/corner case, an unknown-rotation sparse-position case, and an exact-multiple capacity case. The existing sparse-valid-pin correction remains good, but exact head |
10fbcb3 to
a4cfe12
Compare
|
All three were real and all three were mine, each reproduced before it was fixed. New head
One behaviour change, declared: such a pin now costs only the slots it occupies, so a shrunk-edge import starts at 52.54 where it started at 55.08. Across eleven before/after scenarios that is the only difference besides the two overlaps disappearing. An audit found a panic I had introduced — Five new guards, five tests, each neutered and watched to fail. Live output, the neuter table and the full evidence are in the PR body. Gate: |
|
Follow-up on the panic above, having verified the underlying cause against the shipped v0.11.1 release rather than a branch: it is not only One consequence lands inside this PR, and I would rather name it than have you find it. On such a box the span is inverted, so the edge validation added here refuses every position and prints the range backwards: "the 'left' edge of sheet 'card', which runs from y = 50 to 30". Safe, and no longer a crash, but unreadable. I can make it name the malformed box instead if you want that here; otherwise it belongs with the upstream fix and I will leave this PR alone. |
|
Keep this PR focused: the current fail-safe refusal for a malformed negative/zero-size sheet is acceptable here, so no additional wording change is required in #524. Please open a separate focused issue for validating positive sheet dimensions at the add/edit boundary and for making any remaining malformed-box diagnostic explicit. For #524, incorporate current |
a4cfe12 to
90087bc
Compare
|
Refreshed onto The rebase carried no content change: Gate on this head: Noted on the malformed-box wording — #524 keeps its current fail-safe refusal and I have changed nothing for it. The separate issue is #543: positive-dimension validation at both |
`add_sheet_pin` and `edit_sheet_pin` exposed only `x`/`y` and always wrote `(at x y 0)`; `import_sheet_pins` was the one tool writing any other angle and its `side` enum was `["right", "left"]`. KiCad reads a sheet pin's edge from that angle — 0 right, 90 top, 180 left, 270 bottom — so 90 and 270 were unreachable from any tool, and a sheet drawn as a card with pins along its top and bottom could not be built. Extend `import_sheet_pins`' enum to `["right", "left", "top", "bottom"]` and give `add_sheet_pin` and `edit_sheet_pin` the same `side` argument. `side` is the project's existing vocabulary for this and, unlike a raw rotation, cannot be handed 45 degrees. A top/bottom import stacks its new pins along the edge in x, as a left/right import stacks them in y. Purely additive: `side` defaults to `right`, which is what all three tools have always written, and the edge check below rides on the argument, so no existing call changes meaning and nothing that used to be accepted is now refused. The side is never inferred from the position — the caller says which edge. The contract is recorded in docs/API_MIGRATIONS.md. A position not on the named edge is REFUSED rather than written. KiCad would accept such a write and then move the pin: measured on 10.0.6, a pin written at `(at 130 140 0)` on a box spanning y 50..100 was saved back by KiCad's own writer as `(at 130 100 0)`, and ERC reported it at the moved position. The tool would have reported success for a position the editor does not use, which is the failure this argument exists to prevent, so the refusal happens at Konnect's boundary where it is still possible. The check covers both axes, because KiCad constrains a pin along the edge as well as across it: corners are on the edge and are accepted, a millimetre past one is clamped back to it. All three responses are built from the sheet pin read back out of the committed file, not from the request. `add_sheet_pin` and `edit_sheet_pin` report the saved `x`, `y` and side; `import_sheet_pins` reads its side off a pin it actually saved, and reports none when it saved none. A response that restates its arguments cannot tell a caller that the file holds something else, which is the entire failure `side` exists to surface — so deriving it from the request would have left the new argument unable to do the job it was added for. The observable difference is the writer's six decimal places: a finer position reaches the file rounded, and the response now says so. Verified against KiCad 10.0.6, not only in unit tests: pins written on all four sides were re-saved by `kicad-cli sch upgrade` byte-identical, and `kicad-cli sch erc` reported each at the position in the file. That fixture carries its own positive control — a pin written at the same top-edge position with `side` omitted goes in as `(at 80 50 0)` and comes back as `(at 130 50 0)`, so the comparison demonstrably can see a relocation when there is one. Every new guard was neutered and watched to fail with its own message before being restored. `add_sheet_pin_takes_the_rotation_from_the_side_not_the_position` is the negative control for the rotation write specifically: every other side case sits on exactly one edge, so an implementation that inferred the side from the position passes all of them, and only a corner — which belongs to two edges at once — separates the two. `add_sheet_pin_without_a_side_still_accepts_any_position` and `import_sheet_pins_keeps_placing_a_left_or_right_stack_down_the_edge` are scope tests rather than coverage: they pin behaviour this change must leave alone, and they survive every neuter that removes a new guard. Neither is decorative — each fails under a deliberate widening of the new behaviour into the old path, which is the only way a scope test can be shown to be live. Re-running the mutation pass on the rebased head also found one guard with no witness at all, which `import_sheet_pins_reports_no_side_when_it_saved_no_pin` now closes: an echoed side and a derived one agree on every import that writes a pin, so only an import that writes none can tell them apart. Not addressed here: a non-string `side` still falls back to the default rather than being refused, matching what `import_sheet_pins` already did. That is the dispatch-wide unenforced-schema issue, not this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`import_sheet_pins` generated its positions from the total number of pins on the sheet and never checked them against the edge it was placing on. Enough labels — or enough pins already sitting on the other three edges — and it wrote past a corner, where KiCad clamps the pin back onto the box. That is the silent relocation the `side` argument exists to prevent, reintroduced through the import path, and it was a defect in this branch rather than a nitpick. The stack now continues below only the pins already carrying this edge's rotation, the whole import is planned before any of it is written, every generated point goes through the same edge guard `add_sheet_pin` uses, and an overflow refuses the import entire with nothing written. Two regressions cover it: a left edge filled exactly to nineteen pins and then asked for a twentieth, and a top edge overflowing from empty. Both assert the file is byte-identical afterwards, not merely that an error came back. `edit_sheet_pin` now derives `side` in `changed_fields` from the saved delta. Restating the edge a pin already sits on is reported as the no-op it is and commits nothing, while remaining a legal one-field request; `changed` and `requested_fields` join the response, named as `edit_sheet` already names them. Also updates the three `tool-directory.md` rows for the public `side` contract, extends the migration note to cover the refusal and the response change, and uses the required `KiCad` spelling throughout the prose and error strings this branch adds. The ~400 pre-existing `KiCAD` occurrences are left alone, per docs/NAMING_CONVENTIONS.md. Review corrections on Konnect PR 524.
Found in my own re-read of the previous commit, after posting the review reply. Making the import's occupancy count precise took away a safety margin the imprecise version had by accident. The previous commit counted only the pins carrying the selected edge's rotation, which is what the review asked for. But `sheet_pin_side_for_rotation` answers `None` for a rotation that is absent or names no edge, so such a pin counted toward no edge at all and the import stacked from the first slot straight over it. The old code counted every pin on the sheet, so an unattributable pin used to push the stack outward; now it pushed nothing. That makes it a regression introduced by the fix, in the very line under review. A pin whose edge cannot be determined is now counted as occupying the edge being imported to. If we cannot tell which edge it is on, it could be this one, and stacking further out is always safe where stacking over it is not. It adds no failure mode and changes nothing for a well-formed file, which has no such pin. Deliberately not a refusal: aborting the import on an unattributable pin would match the malformed-file principle but is a harsher behaviour change than the review asked for. `import_sheet_pins_counts_a_pin_whose_rotation_names_no_edge` was written first, watched to fail on the previous commit with the two pins landing on the same coordinate, and fails again with its own message when the clause is neutered back. Review correction on Konnect PR 524.
…ount The next slot for an imported sheet pin came from the number of pins on the selected edge. A count does not say where those pins are: an edge holding pins in slots 1 and 3 holds two, so the import took slot 3 and wrote straight over the second one. The existing regression imports onto an empty opposite edge, so it could not see this. The slot each pin occupies is now derived from its coordinate and the largest taken, so the stack continues after the pin that reaches furthest along the edge. An existing gap is left alone rather than filled, which is what KiCad does with the file either way. Deriving a slot is a division, and the spacing does not divide cleanly in binary: (62.7 - 50.0) / 2.54 is 5.000000000000001, so rounding outward without a tolerance reads slot 5 as slot 6. It goes wrong at slots 5, 10, 12, 13, 17, 24, 26 and 31 within a single sheet. The tolerance is a thousand times finer than the nanometre KiCad stores a coordinate in. Two kinds of pin cannot say where the stack ends. One whose rotation names no edge could be on this one, as before. One that names this edge but lies past the end of it is the same problem from the other direction: edit_sheet resizes a sheet without moving its pins, so shrinking one strands them beyond the new corner, and measuring such a pin refuses every later import onto an edge that holds no valid pin at all. Both reserve a slot instead, which pushes the stack outward without inventing an overflow. The atomic refusal is unchanged. Its message said how many pins the edge carried, which stops being the reason an import runs out of room once the stack is measured rather than counted, so it now names the slot the edge is filled through. Continuing after the furthest pin also means one pin on the last slot fills the edge while the slots before it stay empty, which refuses an import that previously placed pins into the gap. That is a real behaviour change: the refusal now says how many slots below the outlying pin are free and names moving it as the remedy, rather than sending the caller to edit_sheet, and API_MIGRATIONS.md records it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overflow refusal reports how far down the edge the stack has reached. When nothing is on the edge that number is zero, and the sentence read "holds 19 sheet pins at 2.54 mm apart and is already filled through slot 0" — which describes an empty edge as partly full. It says "is empty" in that case now. The top-edge overflow regression already exercises this path, since it overflows from an empty edge, so it carries the assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reserve for a pin that could not be measured was a bare count. It said how many slots to step past, not which coordinates were occupied, so a single-label import cleared the pin and a multi-label import walked straight onto it. That is the same defect this PR's first round fixed for the pins that could be measured, left in place for the pins that could not. Every pin that might occupy a slot on the edge now contributes its actual slot, and the generator steps over any slot already taken. The rule is unchanged: the stack continues after the furthest pin genuinely on the edge, and gaps below it are still not filled. A stranded pin also contributes the slot KiCad would clamp it onto. A pin past the end of an edge does not stay there, and on an edge whose length is an exact number of slots that corner is a slot an import would otherwise take. Edge capacity now carries the same slack as the slot arithmetic. A span is a subtraction, so an edge that is an exact number of slots long rarely divides to an integer: (50.0 + 5.08) - 50.0 is 5.079999999999998, and an untoleranced floor() called that two-slot edge one. The undercount was not cosmetic — it decided which pins counted as being on the edge, so it stranded a valid last-slot pin and let an import write over it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it_sheet The negative-size regression asked `edit_sheet` to invert the span and threw the result away. `edit_sheet` now refuses a non-positive size, so the sheet stayed rectangular and the test passed while exercising an ordinary box — it asserted only that nothing panicked, and nothing was there to panic on. A backwards span is still reachable: a file written by an older Konnect, by hand, or by another tool can carry one, so the guard still has a job. The fixture now writes the inverted span straight into the schematic, and asserts both that the substitution landed and that the sheet reads back with the backwards span, so the fixture cannot fail silently the way the old one did. Verified by neutering: restoring `coord.clamp(span_start, span_end)` in place of the ordered bounds makes this test panic in `f64::clamp`'s own assert, and restoring the guard makes it pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `side` enum is enforced at dispatch, so widening it to four edges is what makes `top` and `bottom` reachable at all. Every other test of this feature calls the handlers directly and never passes through that validator, so a schema narrowed back to two edges would leave the feature unreachable with the whole suite still green. This dispatches all three sheet-pin tools with each of the four edges and asserts none is refused for `side`. The `middle` case is the test's own positive control: if an edge that does not exist stops being refused by name, the validator is not running and the rest of the test proves nothing. Verified by neutering: narrowing one enum back to `right|left` fails it with `"top" is not one of "right" or "left"`. Each tool is given only its own arguments, because the records are closed and a stray key is refused before `side` is looked at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90087bc to
8586359
Compare
|
Reconstructed onto current The rebase carried no content change: Two test-only commits follow, because #551 changed what two of this branch's tests were worth. Both were re-verified by neutering rather than by passing. The negative-size regression had gone vacuous. It asked Nothing exercised the Writing that test also confirmed #584's closure is live: one argument set across three tools was refused for an unexpected The migration entry sits at the top of |
Summary
Sheet pins can be placed on all four edges of a sheet block.
add_sheet_pinandedit_sheet_pintake an optionalsideofright,left,toporbottom, andimport_sheet_pins' existingsideenum is extended from two values to the same four.Closes #514
The reproduction, and why it is not cosmetic. KiCad does not derive a sheet pin's edge from its position — it reads the edge from the rotation in the pin's
at, and relocates a pin whose position disagrees with it.add_sheet_pinandedit_sheet_pinhad no rotation input at all and always wrote0, so every pin they wrote was a right-edge pin, andimport_sheet_pinswas the only tool writing any other angle with an enum of["right", "left"]. Rotations90and270were unreachable from any tool, so a sheet with pins along its top or bottom edge could not be authored through Konnect.The failure that makes this a bug rather than a missing feature is that it is silent and three-way. On a sheet box spanning x 50..130, y 50..100, asking for a pin at the middle of the top edge — this is a live run against this branch with
sideomitted, which is exactly the pre-change behaviour and still the behaviour of any call that omits it:add_sheet_pinresponse{"added_pin": "NO_SIDE", "pin_type": "passive", "sheet": "card", "side": "right", "x": 80.0, "y": 50.0}.kicad_schon disk(at 80 50 0)kicad-cli sch erc, on that same unmodified fileA 50 mm jump to the right edge, reported as success at every stage a caller can observe. Note that the response is not lying about the file — the file really does say rotation
0, which is the right edge. It is the position that KiCad discards. The rotation0is what moved it: KiCad bound the pin to the right edge because that is the edge rotation0names, and slid the position to match.Review round 1 — the three corrections, on
5d9569813be39e33b90c39b063960d0b50ec554c1.
import_sheet_pinshad the same defect this PR exists to fix, on its own code path — and the reviewer was right to call it a defect rather than a nitpick. It generated positions from the total pin count on the sheet and never checked one against the edge it was placing on. Two ways to reach the corner: enough labels for one edge, or enough pins already sitting on the other three, since every one of them pushed the next import's first slot further down. Past the corner KiCad clamps the pin back onto the box — the silent relocation, re-entered through the one tool most likely to write many pins at once.Three changes, matching what was asked:
ensure_pin_is_on_sheet_edge— the same guardadd_sheet_pinuses, so the two cannot drift apart.2.
changed_fieldsnow comes from the saved delta.edit_sheet_pinpushedsidewheneversidewas an argument. Restating the edge a pin already sits on is now the honest no-op it is:sideis absent fromchanged_fields, nothing is committed, and the call is still a legal one-field request rather than the "no fields to change" refusal.changedandrequested_fieldsjoin the response, spelled asedit_sheetalready spells them, so a no-op is legible rather than silent. The other three fields keep the behaviour they had; only the argument this PR introduces is in scope here.3.
tool-directory.md, the migration note, and the spelling. All three sheet-pin rows now carry the publicsidecontract, including the refusal and thechanged_fieldsrule.docs/API_MIGRATIONS.mdgains the atomic-refusal and response-derivation paragraphs. New prose and error strings useKiCad: the 15 lines this branch added withKiCADare corrected, and the ~400 pre-existing occurrences elsewhere in the file and the codebase are untouched, which is whatdocs/NAMING_CONVENTIONS.mdline 15 asks for. The real-KiCad round-trip evidence below is preserved, and re-run on the new head rather than inherited.Review round 2 — the import's next slot, on
10fbcb3You were right, and the regression written in round 1 could not have caught it.
import_sheet_pinschose its next slot from the count of pins attributed to the selected edge. A count does not say where those pins are: an edge holding pins in slots 1 and 3 holds two, so the import took slot 3 and wrote on top of the second one. The round 1 regression imports onto an empty opposite edge, so it never exercised a sparse same-edge case. Reproduced by measurement before anything was changed — the imported pin landed at(50.0, 57.62), byte-identical coordinates to the pin already there.The slot each pin occupies is now derived from its coordinate and the largest taken, so the stack continues after the pin that reaches furthest along the edge. The atomic refusal is untouched.
Three things came out of doing this that are worth your attention.
1. Deriving a slot is a division, and it needs a tolerance.
(62.7 - 50.0) / 2.54is5.000000000000001, so rounding outward without one reads slot 5 as slot 6 and the import skips a slot. It goes wrong at slots 5, 10, 12, 13, 17, 24, 26 and 31 within a single sheet. Removing the tolerance left all 60 tests passing — every fixture in the file sits on slots 1, 3 or 19, the three where the error happens to fall the harmless way.import_sheet_pins_reads_a_slot_the_division_cannot_represent_exactlylands on slot 5, and the neuter fails once it exists. The tolerance is a thousand times finer than the nanometre KiCad stores a coordinate in.2. "Valid" is load-bearing in your wording, and measuring without it broke a working call.
edit_sheetresizes a sheet without moving its pins, so shrinking one strands them past the new corner. Those pins still carry the edge's rotation, so measuring them reported an edge "filled through slot 19" whose capacity was now 7 — refusing every import onto an edge holding no valid pin at all. Caught by building the pre-change and post-change binaries and running the same scenario through both: the old one placed the pin aty = 55.08, the new one refused. A pin past the end of the edge now reserves a slot instead, exactly as a pin whose rotation names no edge already did, and the two binaries agree again on that case.import_sheet_pins_ignores_a_pin_the_sheet_shrank_away_fromcovers it.3. One consequence of "after the furthest" is intended, but I would rather you chose it than inherit it. A single pin on an edge's last slot fills the edge while every slot before it stays free, so the import refuses where it used to place pins into the gap. That is the literal reading of your request, and it is what is implemented. It seemed wrong to let that caller be told to enlarge a sheet that has 18 free slots, so the refusal says so instead:
If you meant the import to fill gaps rather than stack past them, say so and I will change it — it is a small change and it would make that refusal unreachable. I did not assume it, because "the first slot after the furthest valid pin" says the opposite.
A live sparse import on this head, sheet box x 50..130 / y 50..100, with pins already in slots 1 and 3 of the left edge:
kicad-cli sch upgradereturns all three byte-identical, so KiCad does not compact the gap either.The capacity arithmetic is now in one
sheet_pin_edge_capacity, used by both the refusal that reports the number and the import that decides a pin is off the edge by it, so the two cannot drift into describing the same edge differently.Review round 3 — a count is not a position, again, on
a4cfe12(The live captures below were taken on an
a4cfe12build. The implementation has not changed since:a4cfe12→90087bc→ the six commits now ondbc01de, each step a rebase only.git range-diff 39e0061..90087bc dbc01de..8586359~2reports five of six=, and the sixth differs only by auseline that merged withopt_positive_f64. Nothing is relabelled — the runs are exactly as they happened, on code identical to what is here now. The two test-only commits on top add no production code.)All three were real, all three were mine, and each was reproduced before it was fixed.
1 and 3 are one defect wearing two faces.
sheet_pin_edge_capacityused an untolerancedfloor(). Your 31-slot example is exact, but it is not one span: the capacity is computed fromspan_end - span_start, and 132 geometries in a scan of ordinary sheet sizes undercount — a sheet at y = 50 of height 5.08 mm is two slots and reads as one. The undercount is not cosmetic, because capacity decides which pins count as being on the edge: a valid last-slot pin was judged stranded, reserved as a bare count, and the import wrote on top of it. Live, before the fix:is_error=false, two pins both at(50, 55.08).2 is the same lesson I was given in round 2 and applied too narrowly. The reserve was a count. A count says how many slots to step past, not which coordinates are occupied, so a single-label import cleared the pin and a multi-label import walked onto it. Live, before the fix: an unattributable pin at slot 5 with five labels imported, and
N4landed exactly on it. I fixed "a count is not a position" for the pins that could be measured and left it in place for the pins that could not.The fix. Every pin that could occupy a slot on this edge contributes its actual slot, and the generator steps over any slot already taken. Your rule is unchanged — the stack still continues after the furthest pin genuinely on the edge, and gaps below it are still not filled; this only refuses to write on top of something. A stranded pin also contributes the slot KiCad would clamp it onto, which is the "or a corner" half of your first item: a pin past the end does not stay there, and on an edge whose length is an exact number of slots that corner is a slot an import would otherwise take. Capacity now carries the same slack the slot arithmetic already used.
Live on an
a4cfe12build, the three cases:One behaviour change to declare. A stranded or unattributable pin used to cost a slot whether or not it was in the way; now it costs only the slots it actually occupies. The shrunk-edge import above therefore starts at 52.54 where it previously started at 55.08. That is the intended consequence of keeping positions rather than a count, and it is the only difference a before/after run of eleven scenarios through both binaries showed, besides the two overlaps disappearing.
An audit after the fix found a panic I had introduced, and it is worth naming because it is not in the review. Ordering the "block the clamp point" logic through
f64::clampassertsmin <= max; the span is built asoriginandorigin + size, andedit_sheetwrites any size it is given, so a sheet with a negative size inverts the span and takes the process down instead of returning an error. Reachable from this crate's own API in three calls. It is ordered bounds andmin/maxnow, with a regression that fails — by panicking — when theclampis put back. I have not touchededit_sheet's lack of a positivity check, which is a separate question from this PR.Approach
sideis written as the rotation KiCad reads the edge from —right0,top90,left180,bottom270 — and the mapping is confirmed by KiCad's own writer rather than inferred (round trip below). OneSHEET_PIN_SIDE_DESCand onerotation_for_sheet_pin_sideserve all three tools so the vocabulary cannot drift, andsheet_pin_side_for_rotationis its inverse so a response can name the side the saved pin actually carries.Two design decisions worth stating
side, not a rawrotation.import_sheet_pinsalready spelled this argumentsidewith edge names, so a rotation argument would have given the same concept two vocabularies in one toolset. It is also the safer type: an enum of four edge names cannot be handed 45 degrees, whereas a number can, and KiCad has no edge for 45 degrees. Both points were put to the maintainer with the design and accepted.An off-edge position is refused, not written. This is the part that does real work. KiCad accepts such a write and then moves the pin, so writing it would mean reporting success for a position the editor does not use — the exact failure this argument exists to end. Konnect's boundary is the last point at which the caller can still be told the request cannot be carried out, so that is where it is refused, with a structured
invalid_argumentnaming the offending axis and nothing written.The check covers both axes, because KiCad constrains a pin along its edge as well as across it: a pin at
(at 130 140 0)on that box comes back from KiCad as(at 130 100 0), clamped to the corner. Corners are on the edge and are accepted — they belong to two edges at once — and a millimetre past one is refused.import_sheet_pinsis held to the same rule even though its caller supplies no position, because the caller cannot see an overflow coming in its own arguments; the tool refuses the whole import rather than writing what fitted.The response is read back off the committed file
All three tools load the saved schematic after
commit_edited_sheet_itemand report the pin that is in it.edit_sheet_pinlooks the pin up under the name it saved under, sincenew_namemay have just changed it, and an import that wrote nothing reports no side rather than asserting one.This is not decoration. A response that restates its arguments cannot tell a caller that KiCad moved their pin — which is the whole failure
sideexists to surface — so an echoing response would have left the new argument unable to do the job it was added for. It is also the same rule as #329'sadd_bus_entryfix: when a response field is a claim about the file, the file has to be consulted. The observable difference is the writer's six decimal places:add_sheet_pin(x=130.0000004, y=55.0000004, side="right")is inside the 1e-6 edge tolerance, so it is accepted, writes(at 130 55 0), and now answersx: 130.0, y: 55.0.changed_fieldsonedit_sheet_pinis the same rule one step further in, and is what the second correction applies: a field is named there because the delta reached the file, not because the caller asked for it.Branch and dependencies
Base branch:
main, reconstructed onto currentmain(dbc01de).git range-diffreports five of the six original commits identical to the revieweda4cfe12; the sixth differs only by theuseline, which now also importsopt_positive_f64from #551. Two test-only commits follow, described below.Reconstruction onto
dbc01de— two test-only commits#551enforces advertised input schemas at dispatch, which changed what two ofthis branch's tests are worth. Both were re-verified by neutering, not by
passing.
import_sheet_pinsnegative-size regression. It askededit_sheettoinvert the sheet's span and discarded the result.
opt_positive_f64now refusesthat call, so the sheet stayed rectangular and the test passed while exercising
an ordinary box. A backwards span is still reachable — a file written by an
older version, by hand, or by another tool can carry one — so the fixture now
writes the inverted span into the schematic and asserts both that the
substitution landed and that the sheet reads back inverted. Restoring
coord.clamp(span_start, span_end)in place of the ordered bounds makes itpanic inside
f64::clamp; restoring the guard makes it pass.A dispatch-level test for the
sideenum. Every other test of this featurecalls the handlers directly and never reaches the validator, so a schema
narrowed back to
right|leftwould leavetopandbottomunreachable withthe suite still green. The new test dispatches all three sheet-pin tools with
each of the four edges and asserts none is refused for
side;middleis itsown positive control, so an inert validator fails the test rather than passing
it. Narrowing one enum fails it with
"top" is not one of "right" or "left".mainhas since moved to30e0abd(#522). It does not conflict, so no rebase was taken —git merge-tree --write-tree origin/main HEADreturns a single tree oid with no conflict section, even though #522 touches bothdocs/API_MIGRATIONS.mdandtool-directory.md. Say the word if you would rather have the branch moved forward anyway.Depends on: nothing. No open pull request touches
sch_hierarchy.rsexcept this one — checked across every open PR, not only mine — so it can be sequenced independently of thesch_components.rsset.Unique commits: six, touching
crates/konnect-core/src/tools/sch_hierarchy.rs,docs/API_MIGRATIONS.mdandtool-directory.mdand nothing else.No tool was added or removed, so there are no registry
tool_count, DEV.md or README counts to update;cargo xtask fix-doc-countsconfirms the documentation is already current. The threetool-directory.mdrows changed are prose, not counts.Compatibility and safety
sideproperty on two tool schemas, and two new values on an enum that already existed on the third.sidedefaults toright, which is rotation0— exactly what all three tools have always written.add_sheet_pinandedit_sheet_pin: no existing call changes meaning, and nothing that used to be accepted is now refused. The edge validation rides on the argument, not on the position: a call that omitssidegets the old behaviour, including the old silent relocation.add_sheet_pin_without_a_side_still_accepts_any_positionpins that, and it is the one test that fails if the check is made unconditional.import_sheet_pinsis the one place where an existing call can newly fail, and it is deliberate. A sheet whose child labels no longer fit on one edge now returns an error instead of a success over pins KiCad would move. The migration note says so in those terms. Every import that fits is unaffected, and the two old sides land exactly where they always did —import_sheet_pins_keeps_placing_a_left_or_right_stack_down_the_edgeis the scope test for that.import_sheet_pinscase can newly fail, added in round 2 and deliberate. The stack continues after the pin that reaches furthest along the edge, so a single pin sitting on the edge's last slot fills it while every slot before stays free: the import now refuses where it used to place pins into that gap. The refusal names the free slots and points at the outlying pin rather than suggesting a resize. This is the literal reading of "the first slot after the furthest valid pin" — see the round 2 section for the alternative, if gap-filling is what you meant.sidecan see a refusal where it used to see a success. That is the point of the argument: the success it replaces had written a position the editor did not use.sideis new on all three responses;changedandrequested_fieldsare new onedit_sheet_pin;xandykeep their names and meanings and are now read from the saved file rather than echoed.changed_fieldskeeps its name and reports the same thing it always claimed to report. No tool, argument, or existing response field was renamed or removed.docs/API_MIGRATIONS.mdgains## Unreleased: side on the sheet-pin tools (minor release), inserted above the existing entries and preserving every one now onmain— the file is byte-identical tomainonce the new entry is cut out.at, a field KiCad already writes and Konnect already parsed. A refused import and a no-op edit both skip the commit outright, so neither reserialises the sheet.Validation
The exact head is
8586359on basedbc01de.konnect-core's own lib suite is 1295 of those tests, and it was also re-run underenv -iwith an emptyHOMEand noKICAD10_*set — a bare runner in one command — where it passes 1295 as well, with the native dependency rebuilt from source inside that run rather than reused, so nothing here resolves anything through an installed KiCad and passes locally while failing on the Linux and Windows runners.The flaky
tools::pcb_board::board_session_safety_tests::a_kicad_holding_another_project_edits_this_board_filereported in the first round did not recur in this round's runs. It is unrelated to this diff, and the mechanism named there — that module's test mock binding aTcpListenerto127.0.0.1:0, reading the port and dropping the listener before nng listens on it — is unchanged and untouched here.The KiCad 10.0.6 round trip, re-run on this exact head
Preserved from the earlier rounds and re-run against an
a4cfe12build, not inherited — the round 2 change moves where an imported pin lands, so this was re-measured rather than relabelled. A freshcreate_projectfixture, one sheet, box 80 x 50 at (50, 50). Written by Konnect, then re-saved bykicad-cli sch upgrade— KiCad's own parser and writer:R_PINside: "right"(at 130 55 0)(at 130 55 0)T_PINside: "top"(at 60 50 90)(at 60 50 90)L_PINside: "left"(at 50 60 180)(at 50 60 180)B_PINside: "bottom"(at 70 100 270)(at 70 100 270)C_TOPside: "right", on the corner(at 130 50 0)(at 130 50 0)C_BOTside: "right", on the corner(at 130 100 0)(at 130 100 0)NO_SIDEsideomitted(at 80 50 0)(at 130 50 0)kicad-cli sch upgradeexit 0;kicad-cli sch ercexit 0.The last row is the positive control, and it is the reason to believe the other six. "Nothing moved" is worth nothing from a comparison that could not see a move.
NO_SIDEwas written at the same top-edge y asT_PIN, withsideleft out, and it is the only pin in the run that moved — 50 mm, to the right edge, silently, exactly as before this change. The same run leaves the six sided pins byte-identical.ERC says it independently, and on the unmodified file rather than the upgraded copy: it places
NO_SIDEat (130, 50) while the file on disk says(at 80 50 0), and it places each of the six sided pins at the position the file gives. Seven pins produce only six distinct ERC positions, because after the relocationNO_SIDEandC_TOPoccupy the same point — the relocation is visible in KiCad's connectivity, not only in its geometry.The import overflow, on the same live binary
Run against an
a4cfe12build over stdio. Sheet box x 50..130, y 50..100, so the left edge is 50 mm long and holds 19 pins at 2.54 mm. The child sheet carries 20 hierarchical labels:sha256of the parent schematic is unchanged across the call, and the file contains zero sheet pins afterwards — not nineteen and a refusal.The boundary is then checked from the other side. With one label removed, the same import writes all 19, first at
y = 52.54and last aty = 98.26. Adding the twentieth label back and re-importing produces the same refusal, now readingis already filled through slot 19, and again leaves the file hash unchanged at 19 pins. So the capacity is neither over- nor under-stated by one.And KiCad agrees about the boundary. That 19-pin file — plus one
top-edge pin — round-trips throughkicad-cli sch upgradewith every one of the 20(at …)lines byte-identical, andkicad-cli sch ercon the unmodified file places all 19 left pins atx = 50fromy = 52.54toy = 98.26, and reports no pin at the corner (50, 100). The last pin that this change lets in is one KiCad does not move.changed_fieldsfrom the delta, on the same live binaryThe no-op leaves the file's
sha256unchanged: it is not written and then reported as unwritten, it is genuinely not written.Refusals, on the same live binary
Nothing is written; each returns a structured
invalid_argumentnaming the axis, and none of the three pin names appears in the file afterwards. Re-captured on ana4cfe12build, so theKiCadspelling below is the string the binary now emits:The first is the across-the-edge axis, the second the along-the-edge span. Corner inclusivity is the positive half of that second check:
C_TOP(130, 50) andC_BOT(130, 100) are the two ends of the right edge, both accepted, both round-tripped unchanged.The negative control: the rotation comes from
side, not from the positionThis one is worth spelling out because the obvious test does not test it. Every single-side case sits on exactly one edge, so an implementation that ignored the argument and inferred the side from the position passes all four of them — demonstrated, not argued: a position-inferring neuter leaves
add_sheet_pin_writes_the_rotation_each_side_selectsgreen.A corner belongs to two edges at once, so it separates the two implementations. On a second fixture, the same point (130, 50) written twice:
One position, two sides, two rotations. Only an implementation that reads the argument can produce both.
add_sheet_pin_takes_the_rotation_from_the_side_not_the_positionis that case, and it is the one test the inferring neuter kills.Every new guard neutered and watched to fail
Each guard was removed on this head, the
konnect-coresuite run, and the guard restored. The right-hand column is every test that failed — not a selection. The last four rows of the first table were round 1's guards, the five after them round 2's, and the five after those round 3's.sheet_pin_rotation_foraccepts any string and writes rotation0add_sheet_pin_refuses_a_side_that_is_not_an_edge,import_sheet_pins_refuses_a_side_that_is_not_an_edgeadd_sheet_pin_refuses_a_position_off_the_named_edge,edit_sheet_pin_refuses_a_side_the_pin_is_not_already_on,edit_sheet_pin_judges_the_side_against_the_position_it_is_givenadd_sheet_pin_refuses_a_position_past_the_end_of_the_named_edgeadd_sheet_pin_accepts_a_pin_on_a_corner,add_sheet_pin_takes_the_rotation_from_the_side_not_the_positionadd_sheet_pinstops writing a rotation at all (the old always-0)add_sheet_pin_writes_the_rotation_each_side_selects,add_sheet_pin_takes_the_rotation_from_the_side_not_the_positionadd_sheet_pininfers the rotation from the position instead of fromsideadd_sheet_pin_takes_the_rotation_from_the_side_not_the_position; also the pre-existingadd_sheet_pin_writes_a_rotation_kicad_can_loadand the scope testadd_sheet_pin_without_a_side_still_accepts_any_positionadd_sheet_pin's response echoes the requestedx/y/sideadd_sheet_pin_reports_the_saved_position_not_the_requested_oneedit_sheet_pin's summary is built from the in-memory pinedit_sheet_pin_reports_the_saved_position_not_the_requested_oneedit_sheet_pinreads back under the requested name, not the saved oneedit_sheet_pin_reports_the_name_it_saved_under; also the pre-existingedit_sheet_pin_renames_and_retypesedit_sheet_pinjudgessideagainst the position being replacededit_sheet_pin_judges_the_side_against_the_position_it_is_given,edit_sheet_pin_moves_a_pin_to_another_edgesideon its own is not counted as a changeedit_sheet_pin_counts_side_on_its_own_as_a_change,edit_sheet_pin_moves_a_pin_to_another_edgeedit_sheet_pinvalidatessidebut never writes the rotationedit_sheet_pin_moves_a_pin_to_another_edge,edit_sheet_pin_counts_side_on_its_own_as_a_change,edit_sheet_pin_judges_the_side_against_the_position_it_is_givenimport_sheet_pinsignores the stacking axis and always stacks down the edgeimport_sheet_pins_stacks_along_a_top_or_bottom_edgeimport_sheet_pinsreports the requested side instead of a saved pin'simport_sheet_pins_reports_no_side_when_it_saved_no_pinimport_sheet_pins_refuses_a_left_edge_import_that_runs_past_the_corner,import_sheet_pins_refuses_a_top_edge_import_that_runs_past_the_corner— both onan import past the corner must refusea refused import must leave the file byte-identical. This is the row that proves the byte comparison is load-bearing rather than decorative: an error was returned, and the tests still failedimport_sheet_pins_stacks_below_the_pins_already_on_that_edgesidecounted as changed whenever it is requestededit_sheet_pin_does_not_count_a_restated_side_as_a_changeimport_sheet_pins_starts_after_the_furthest_pin_on_a_sparse_edge,import_sheet_pins_reads_a_slot_the_division_cannot_represent_exactly,import_sheet_pins_names_the_free_slots_when_one_outlying_pin_fills_the_edgeimport_sheet_pins_reads_a_slot_the_division_cannot_represent_exactly. This is the row worth reading: before that test existed the same neuter passed 60 of 60, because every fixture sat on a slot where the error is harmlessimport_sheet_pins_ignores_a_pin_the_sheet_shrank_away_fromimport_sheet_pins_counts_a_pin_whose_rotation_names_no_edge,import_sheet_pins_ignores_a_pin_the_sheet_shrank_away_fromimport_sheet_pins_names_the_free_slots_when_one_outlying_pin_fills_the_edgeimport_sheet_pins_counts_a_pin_whose_rotation_names_no_edge,import_sheet_pins_does_not_walk_onto_a_pin_whose_rotation_names_no_edge,import_sheet_pins_blocks_the_slot_a_stranded_pin_will_be_clamped_ontoimport_sheet_pins_blocks_the_slot_a_stranded_pin_will_be_clamped_ontoimport_sheet_pins_counts_a_pin_whose_rotation_names_no_edge,import_sheet_pins_does_not_walk_onto_a_pin_whose_rotation_names_no_edgesheet_pin_edge_capacity_counts_an_exact_multiple_spanf64::clamprestored in place of the ordered boundsimport_sheet_pins_survives_a_sheet_with_a_negative_size, which fails by panicking — the failure mode it exists to preventTwo further neuters are there to prove the scope tests are live rather than decorative — each is a deliberate widening of the change into the path it is supposed to leave alone:
import_sheet_pinsstacks across the edge for every sideimport_sheet_pins_keeps_placing_a_left_or_right_stack_down_the_edgesideis validated tooadd_sheet_pin_without_a_side_still_accepts_any_position, plus seven pre-existing tests:add_sheet_pin_rejects_duplicate_name,add_sheet_pin_writes_a_rotation_kicad_can_load,delete_sheet_pin_removes_it,edit_sheet_pin_accepts_the_position_the_pin_already_has,edit_sheet_pin_counts_side_on_its_own_as_a_change,edit_sheet_pin_renames_and_retypes,validate_sheet_pins_reports_mismatchesThe mutation pass found one guard with no witness at all, and that is why it was re-run rather than inherited.
import_sheet_pinsreading its side off a pin it actually saved had no test: an echoed side and a derived one agree on every import that writes a pin, so the existingimport_sheet_pins_stacks_along_a_top_or_bottom_edge— which does assertside == "top"— stayed green with the readback replaced by the argument. Only an import that writes nothing separates them.import_sheet_pins_reports_no_side_when_it_saved_no_pincloses it: the second import asks forbottom, saves nothing because the label already has a pin, and must answernullrather than"bottom".Two tests in this change are scope tests, not coverage, and are declared as such rather than counted:
add_sheet_pin_without_a_side_still_accepts_any_positionasserts that the old behaviour is intact — it survives every neuter of a new guard, by design, because it exists to pin the additive contract rather than to catch a defect in the new code. It is not inert: the last row above is its control, and making the edge check unconditional is the one change that kills it.import_sheet_pins_keeps_placing_a_left_or_right_stack_down_the_edgeis the same shape for the import's stacking axis, and its control is the row above that one — forcing the stack across the edge instead of down it is what makes it fail.One round 3 test is a composite rather than single-guard coverage, and is declared as such.
import_sheet_pins_does_not_overwrite_a_pin_on_an_exact_multiple_edgepins the end-to-end property that the shipped defect violated, and it survives every single neuter above, because the fix defends it three separate ways. It fails only when the original shape is reconstructed — untoleranced capacity, a count-based starting slot, and no occupied-slot skip, all three together — which was measured rather than assumed.None of the four tests added this round is a scope test: each one fails under the neuter of the guard it exists for, which is the table above. The half of the left-edge test that asserts all nineteen pins do land is boundary scope inside a coverage test, and is why the capacity is checked against KiCad rather than only against our own arithmetic.
The row above is the honest shape of the last two rows of the first table as well:
edit_sheet_pin_renames_and_retypesandadd_sheet_pin_writes_a_rotation_kicad_can_loadare pre-existing tests, not this change's coverage, and they are listed because the instruction was every test that failed, not every test I would have chosen.Review checklist
8586359) — all ten required checks green.upstream/main(dbc01de).docs/NAMING_CONVENTIONS.md, including theKiCadspelling in new prose and errors; no public renames.tool-directory.md's three sheet-pin rows carry the new publicsidecontract, and the additive argument is recorded indocs/API_MIGRATIONS.md.