kicad_interface.py had grown to 8,596 lines. Most domains already delegate to
classes in commands/, but the schematic domain (43 _handle_* methods + the
schematic-only _svg_to_png helper, ~3,100 lines) was still inline. Move it into
commands/schematic_handlers.py as SchematicHandlersMixin and have KiCADInterface
inherit from it.
Pure mechanical relocation — no logic change:
- Methods stay bound to the same instance via the mixin, so every self.board,
self._auto_save_board(), cross-handler call, and the command_routes table
resolve exactly as before.
- Verified all 159 functions/methods (116 kept + 43 moved + _svg_to_png) are
AST-identical between the original and the split.
- tests/test_sync_schematic_to_board_footprints.py patch targets updated to
follow the moved module (commands.schematic_handlers.pcbnew).
- Full suite at the exact current main baseline (8 failed / 1044 passed; the 8
are the pre-existing Java-not-installed freerouting/autoroute set).
Regenerated against main after the June merge wave (#226-229, #231-234, #238,
#252, #253); deliberately excludes the 19 _handle_export_* kicad-cli handlers
from #253 and the datasheet delegators.
Note: get_component_properties 'attributes' response changed from raw bitmask ints to {type: smd|through_hole|unspecified, exclude_from_pos_files, exclude_from_bom, not_in_schematic}. Cleaner shape for AI consumers; callers parsing the old integer keys must update.
CONTRIBUTING.md mandates pathlib.Path over os.path (added for
cross-platform/Linux support); the rest of the codebase's newer code
already follows it. Converts the 19 new _handle_export_* handlers to
pathlib (exists/mkdir/iterdir/is_file, expanduser().resolve()) so this
feature's new code is compliant. Tests updated to patch pathlib.Path.
Scope is limited to the new export-handler block; the pre-existing
os.path usage elsewhere in kicad_interface.py/export.py is untouched
here and will be migrated in a dedicated refactor PR to keep that
sweep reviewable in isolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `set_footprint_type` MCP tool to set a placed footprint's
placement type (through_hole / smd / unspecified) plus the optional
exclude_from_pos_files / exclude_from_bom / not_in_schematic flags.
These attributes gate inclusion in pick-and-place (.pos) exports and
were previously unreachable: edit_component only handled
reference/value/footprint and get_component_properties did not report
the placement type at all.
- python/commands/component.py: SWIG (pcbnew) handler via
Set/GetAttributes + the dedicated exclusion setters; extends
get_component_properties to report a human-readable `type` plus the
three exclusion flags.
- python/kicad_interface.py: IPC (kipy) handler setting
attributes.mounting_style and the exclusion fields over the proto
API with begin_commit/update_items/push_commit so changes are live in
the KiCAD UI; registered in the command map, the IPC handler map, and
the realtime/IPC whitelist; SWIG fallback on proto error.
- src/tools/component.ts: tool registration with zod schema.
- tests/test_set_footprint_type.py: 26 unit tests over both backends
plus the extended get_component_properties response.
- pyproject.toml: add `fitz` (PyMuPDF) to mypy ignore_missing_imports
overrides; pre-existing gap that fails mypy on any edit to files
importing it (board/view.py, kicad_interface.py).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers all 19 new _handle_export_* handlers: parametrized success /
kicad-cli-not-found / subprocess-failure paths, plus validation
(missing output, unresolvable board, missing schematicPath) and
detailed flag-construction assertions for gerbers/drill/ipc2581.
Subprocess + filesystem are mocked; no real kicad-cli or board touched.
64 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_handler_logs_warning_on_collision relied on pytest's caplog, but the
package calls logging.basicConfig(..., force=True) at import time (the
rotating-file logging added in #181). force=True detaches the root handler
that caplog reads from, so the emitted warning was routed to the stderr/file
handlers but never reached caplog.records — the final assertion failed
depending on import/handler ordering.
Capture on the "kicad_interface" logger with a test-owned handler instead of
relying on caplog, and disable propagation for the duration so the warning no
longer leaks to stderr. Logger level and propagation are restored in a finally
block. Behaviour under test is unchanged; the assertion is now deterministic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kicad-cli's `pcb export svg` defaults to `--page-size-mode 0` (the full drawing sheet), so only the area inside the sheet is plotted -- board geometry past the page edge is left out. Pass `--exclude-drawing-sheet` and `--page-size-mode 2` (board area only) so the output is sized to the board's bounding box and the full board is rendered. Both flags exist since KiCad 7, so KiCad 8/9 are unaffected.
Net class definitions live in <project>.kicad_pro (net_settings) on KiCad 7+, not in the .kicad_pcb that the SWIG board save writes -- so create_netclass mutated the in-memory board and reported success, but nothing survived a reload.
Write the class definition and its net memberships (netclass_patterns) into the project JSON, which is what KiCad reads on open. The transform is a pure, unit-tested function that needs no live KiCad/SWIG round-trip; the in-memory NETCLASS path is kept for live-session consistency.
Adds batch authoring tools that collapse the dozens of round-trips needed to
stand up a schematic into a handful: batch_add_components,
batch_edit_schematic_components, replace_schematic_component,
batch_add_no_connects, batch_connect, and batch_add_and_connect (place a set of
parts and wire them in one call). These reuse the existing single-item handlers
internally, so behavior matches exactly.
- python/commands/schematic_batch.py: SchematicBatchCommands(iface)
- python/commands/schematic_text_utils.py: shared .kicad_sch text helpers
- src/tools/schematic-batch.ts + registry 'schematic_batch' category
- python/kicad_interface.py: import + instantiate + dispatch routes
- tests/test_schematic_batch.py: 13 unit tests
Note: schematic_text_utils.py is also shipped by the field-placement and
hierarchy PRs; if those merge first, drop the duplicate copy on rebase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds field-placement tools:
- set_schematic_property_position / batch_set_schematic_property_positions:
move a symbol's Reference/Value field labels
- autoplace_schematic_fields: place every symbol's fields clear of its body
and nearby net labels (the #1 readability problem in generated schematics)
- check_schematic_layout: audit out-of-bounds / fields-in-body / duplicate
labels (note: overlaps upstream find_overlapping_elements etc. — reuse or
drop on request)
Generic .kicad_sch text helpers are factored into commands/schematic_text_utils.py
so the batch/hierarchy modules don't import from one another.
- python/commands/schematic_text_utils.py: shared text/S-expr helpers
- python/commands/schematic_field_layout.py: SchematicFieldLayoutCommands
- src/tools/schematic-layout.ts + registry 'schematic_layout' category
- python/kicad_interface.py: import + instantiate + dispatch routes
- tests/test_schematic_field_layout.py: 23 unit tests incl. end-to-end on real .kicad_sch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds add_hierarchical_sheet (insert a sheet symbol referencing a child
.kicad_sch, with sheet_instances + fixed component instance paths) and
create_hierarchical_subsheet (create the child file + wire it into the parent
in one call). Upstream has add_sheet_pin / add_schematic_hierarchical_label
but no way to create a sheet or stand up a child sheet, so hierarchical
designs can't be built through the MCP server today.
- python/commands/schematic_hierarchy.py: SchematicHierarchyCommands
- src/tools/schematic-hierarchy.ts + registry 'schematic_hierarchy' category
- python/kicad_interface.py: import + instantiate + dispatch routes
- tests/test_schematic_hierarchy.py: 5 unit/integration tests on real .kicad_sch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds list_symbol_pins and batch_list_symbol_pins: read a symbol's pin
number/name/type/local-position straight from the .kicad_sym libraries,
without placing it on a schematic. Fills a gap in library_symbol.py
(which can search/list symbols and read properties but not pins) and
complements get_schematic_pin_locations (placed-symbol coords only).
- python/commands/symbol_pins.py: SymbolPinCommands (stateless)
- src/tools/library-symbol.ts: tool wrappers; registry 'symbol_pins' category
- python/kicad_interface.py: import + instantiate + dispatch routes
- tests/test_symbol_pins.py: 12 unit tests (MagicMock loader; no system KiCad)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a project is opened for the first time (e.g. right after
create_project) the sym-lib-table may not exist on disk yet.
SymbolLibraryManager.__init__ succeeds but leaves self.libraries={}.
The original guard in use_project() was:
if self.library_manager.project_path == project_path:
return
Because the path already matched the newly-created manager, the
early-return fired and the manager was never rebuilt once the
sym-lib-table appeared. All subsequent list_symbols / search calls
returned nothing.
Fix: also require that at least one library was loaded before
treating the cache as valid.
Adds three unit tests that cover:
- rebuild triggered when project_path matches but libraries={}
- no spurious rebuild when libraries are already loaded
- rebuild on project_path change (existing behaviour)
The server wrote gigabytes to ~/.kicad-mcp/logs and ignored LOG_LEVEL. Three
root causes, all fixed here (the logging carve-out of #182):
- Python (kicad_interface.py): replace the unbounded FileHandler with a
RotatingFileHandler (10 MB x 3 backups, env-tunable via KICAD_MCP_LOG_MAX_BYTES
/ KICAD_MCP_LOG_BACKUP_COUNT); read the level from KICAD_MCP_LOG_LEVEL or
LOG_LEVEL (default INFO) instead of hardcoding DEBUG; mute the noisy
skip / skip.sexp.* loggers to WARNING unless KICAD_MCP_DEBUG_SKIP is set.
- TypeScript (config.ts): honor KICAD_MCP_LOG_LEVEL / LOG_LEVEL for the TS logger.
- TypeScript (logger.ts): size-cap the per-day log files with the same env knobs.
- Docs + a no-network test for the env helpers, skip muting, and that no
unbounded handler targets kicad_interface.log.
Verified: LOG_LEVEL is now applied, skip is muted, and the file rotates instead
of growing forever. Full suite unchanged from baseline.
The hierarchical-sheet rewrite from #182 is intentionally left out (stays as a
separate PR pending the #169/#170 design discussion).
Co-Authored-By: angelorodem <angelorodem@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
KiCad 10 changed `kicad-cli pcb export svg` in three ways that broke
get_board_2d_view (reported with a verified fix in #209):
- `--output` is now a FILE path, not a directory (a dir fails with
"Failed to create file '<dir>'").
- `--mode-single` is required to merge layers into one SVG (the default
multi-file behavior is deprecated).
- exit code 2 is returned for the deprecation warning even on success.
Pass a file path to `--output` and add `--mode-single` (both also valid on
KiCad 8/9). Stop gating on the exit code — judge success by whether an SVG was
actually produced — so the exit-2 deprecation warning is no longer a false
failure. Add regression tests for the exit-2-is-success case and the command
shape (file output + --mode-single).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bhoot found that download_cdfer 416-loops when a complete cdfer.sqlite3 already
exists in the cache (e.g. a prior run downloaded it but died before
conversion/cleanup): resuming a complete file sends Range: bytes=<size>- which
the server answers with HTTP 416, and raise_for_status treated that as a
retryable error, spinning until max_retries then failing.
Fix: HEAD once for Content-Length up front and short-circuit when the local file
already matches the full size; and in the loop, treat a 416 as "complete" when
the size matches (else drop the stale partial and restart fresh). Remove the now
-unused _head_last_modified helper (HEAD is done inline). Add a no-network test.
Verified live: happy path still downloads+converts 616k parts (~50s); a
pre-existing complete file now short-circuits in ~0.4s instead of looping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The background warm-cache change made list_symbols() guard cache writes with
self._cache_lock. _manager_for_fixture() builds the manager via __new__ (to skip
disk I/O to system libs), bypassing __init__ where the lock is created, so the 8
SPICE-parsing tests raised AttributeError. Set the lock in the fixture. The
string-aware parser itself is correct — all 8 pass once the lock exists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add optional `justify` property to `fieldPositions` entries in
`edit_schematic_component` and to `set_schematic_component_property`.
Changes:
- `python/kicad_interface.py`: new `_set_justify_on_property()` helper
that adds/replaces/removes the `(justify ...)` token inside a property's
`(effects ...)` block. Passing "center" (the KiCad default) removes the
directive entirely. Integrated into `_set_property_in_block()` (for the
`properties` dict path) and into the `field_positions` loop in
`_handle_edit_schematic_component()`. `_handle_set_schematic_component_property()`
now forwards `justify` from params through to the spec dict.
Also fixes pre-existing mypy type-ignore on `circle.radius` (kipy stub).
- `src/tools/schematic.ts`: extend the `fieldPositions` Zod schema to accept
`justify?: string | string[]` (array form normalised to a space-separated
string before the Python call). Add `justify?: string` to
`set_schematic_component_property`.
- `python/commands/routing.py`: fix pre-existing mypy error — annotate `ex`
and `ey` as `float` in `_point_to_segment_distance_nm`.
- `python/commands/pin_locator.py`: fix pre-existing mypy error — use explicit
`str()` cast on `pin_data["number"]` before `dict.get()` call.
- `tests/test_schematic_field_justify.py`: 14 unit + integration tests
covering add/replace/remove of the justify directive and backward
compatibility (calls without justify leave existing directives untouched).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
People want to be able to pull the whole catalog, and prior downloads stalled or
repeated the same parts. Two fixes:
- Full catalog correctness: yaqwsx's cache.sqlite3 (the full ~10GB set) stores
category/manufacturer as IDs with no v_components view, so the convert left
those fields blank. Build an equivalent v_components join for yaqwsx-style
sources so the full catalog converts with category/subcategory/manufacturer
populated. Clarify in the tool schema that source="yaqwsx" = full catalog
(needs 7z) vs cdfer = in-stock subset.
- Resumable downloads: CDFER stream download now uses a read timeout and resumes
from the partial file via HTTP Range on interruption (up to 5 retries); the
yaqwsx curl calls use -C - / --retry. Addresses the stall/partial-download
complaints.
Adds a yaqwsx-schema conversion test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per review feedback: CDFER's upstream scraper pipeline has stalled for weeks
(broken cart API + a bug in their scraper), so a "fresh download" can still be
old data. Compute the catalog age from the source Last-Modified header, expose
catalog_age_days, and emit a stale=True + warning when older than 14 days that
points users to source='yaqwsx' (fresh, needs 7z) or source='official'. Surface
the warning in the MCP tool output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The download_jlcpcb_database tool paged the community JLCSearch API with an
offset parameter, but that endpoint is a search front-end that ignores offset
and returns the same first 100 parts on every page, so a full catalog download
was impossible.
Add commands/jlcpcb_downloader.py with a layered strategy that reuses prebuilt
catalogs the whole ecosystem already trusts:
- CDFER single-file SQLite (primary; no 7z/zip, reliable on Windows)
- yaqwsx/jlcparts split 7z (fallback; only if a 7z CLI is present)
- official JLCPCB API (optional; cursor pagination, if credentials set)
Conversion reads CDFER's v_components view (or sniffs the largest table for
yaqwsx), C-prefixes integer lcsc, derives library_type from basic/preferred,
maps mfr->mfr_part, and normalizes price JSON to the manager's [{qty,price}]
shape. Rewire _handle_download_jlcpcb_database to use it (closing/reopening the
manager connection so the on-disk db can be rewritten on Windows). Remove the
broken offset loop from jlcsearch.py (client kept for interactive lookups).
Reduce download_jlcpcb.py to a thin CLI wrapper and update the TS tool schema.
Verified end-to-end against live CDFER: 616k parts downloaded + converted in
~40s, FTS search and price-break parsing correct. New unit tests cover the
conversion and source fall-through; no network in tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dehydration recovery test (test_auto_save_recovers_when_save_leaves_
board_dehydrated) builds a KiCADInterface via __new__, skipping __init__.
After #173 was rebased onto the #151/#172 auto-save guard, _auto_save_board
reads self._board_disk_signature (the content-hash conflict guard), which
__init__ normally sets to None. The fixture didn't, so the test tripped an
AttributeError before reaching the recovery logic it exercises.
Initialise _board_disk_signature = None in the _make_iface helper, matching
the pattern already used in test_auto_save_guard.py. Test-only change.
Co-authored-by: mixelpixx <11727006+mixelpixx@users.noreply.github.com>
KiCAD nightly builds occasionally return a SwigPyObject from
pcbnew.LoadBoard, and SaveBoard can leave self.board with no method
dispatch as a side-effect of certain sequences (delete_trace + auto-save
is the one users have hit in the wild). Before this fix, open_project
would keep reporting "Opened project: foo.kicad_pcb" while every
subsequent board operation failed with AttributeError on
GetDesignSettings / GetBoardEdgesBoundingBox / GetCurrentViaSize, with
no path to recovery short of restarting the MCP server.
Add two helpers in KiCADInterface:
* _is_board_healthy(board=None) probes for stable BOARD methods
(GetDesignSettings, GetBoardEdgesBoundingBox, GetFileName) — these
are missing on a dehydrated SwigPyObject, so hasattr() catches the
state without segfaulting.
* _safe_load_board(path) wraps pcbnew.LoadBoard, checks health, and
on dehydration reloads the pcbnew module via importlib.reload and
retries once. Returns None when recovery is impossible so callers
surface real failure rather than fake success.
Wire the helpers in:
* handle_command's open_project / create_project path validates the
loaded board and either recovers (with a warnings[] entry) or
returns success=False with an explicit "restart the MCP server"
errorDetails — never claims success when the board is unusable.
* _auto_save_board now detects dehydration introduced by SaveBoard
itself and reloads from disk so the next command sees a usable
proxy. This is the post-delete_trace failure mode users hit.
* _handle_place_component, _handle_sync_schematic_to_board,
_handle_import_svg_logo and _handle_refill_zones all go through
_safe_load_board instead of bare LoadBoard, surfacing real errors
consistently.
Also fix two adjacent issues observed in the same incident:
* _handle_check_kicad_ui used to call manager.is_running() and
manager.get_process_info() separately, with different detection
methods. They could disagree, producing the confusing
running=True, processes=[] state users hit after manually
quitting KiCAD. processes is now the single source of truth and
running is derived from len().
* run_drc accepts a timeoutSec param (default 600s, clamped to
[10, 1800]) so callers with smaller MCP transport budgets can
bound the kicad-cli subprocess. Same timeout is applied to the
optional report-generation subprocess. Error message names the
actual timeout that fired.
Tests: tests/test_swig_dehydration.py adds 17 unit tests covering
detection, recovery, the open_project surfacing path, the auto-save
post-recovery path, the check_kicad_ui consistency, and the run_drc
timeout clamping. Full suite: same 12 pre-existing failures both
before and after this change, +17 new tests passing.
Note: the SWIG dehydration is fundamentally a pcbnew memory bug
exposed by repeated LoadBoard calls in a single Python process;
this PR is a defensive recovery layer, not a fix to the underlying
binding. Complementary to PR #151 (auto-save-guard), which expands
the LoadBoard call rate by refusing saves on external file change
and forcing re-open_project cycles.
* feat(units): add mil unit support across all position/coordinate commands
KiCad natively supports mils, so the MCP server should too. Added "mil"
as a valid unit option in tool schemas and updated all unit-to-nanometer
scale conversions across component, routing, outline, view, and IPC
handler code paths. 1 mil = 25400 nm (0.0254 mm).
Also fixes a pre-existing mypy overload error in pin_locator.py (str cast
on dict.get key) that was blocking pre-commit on any Python file change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(units): add mil to TypeScript tool schemas
The Python-side mil support was added but the actual input validation
happens in the TypeScript/Zod schemas. Updated all z.enum(["mm", "inch"])
to include "mil" across board, component, routing, design-rules, and
export tool definitions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tools): replace CP-1252 mojibake with correct Unicode in board.ts
Replace U+00C3 U+00D7 (×) with U+00D7 (×) in add_logo size output string.
Character was mangled when file was saved as CP-1252 instead of UTF-8.
* fix: restore em-dash and fix pre-commit mypy in component/routing
component.py: replace CP-1252 mojibake (â€") with correct Unicode
em-dash (—) in the 'Add to board first' comment. Addresses
maintainer review on PR #162.
routing.py: annotate ex/ey as float at first assignment site in
_point_to_segment_distance_nm so mypy pre-commit hook passes
cleanly on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop GND stitching vias across the board with collision checking
against every non-GND segment, via, and pad on every copper layer.
PTH vias penetrate the full stackup, so an F.Cu-only check (the most
common shortcut) silently creates shorts on inner / B.Cu copper —
this implementation explicitly walks all layers.
grid Regular grid across the board interior. Default
spacing 5mm.
around_refs Densify around specified footprints (e.g. MCUs,
switching regulators, RF parts). Configurable
density via densifyRadius.
in_zones Restrict placements to candidates inside the filled
polygons of GND copper zones, so each new via lands
on copper that's already a GND equipotential.
Recommended on boards where the GND zone is fragmented:
these vias actually stitch real polygons rather than
floating on silkscreen.
All three strategies use the same collision check + intra-call
clump-prevention, so passing `["grid", "around_refs", "in_zones"]`
is a safe kitchen-sink configuration.
- Auto-detect GND net (tries GND / GROUND / VSS / /GND in order)
OR explicit `gndNet` parameter.
- Per-via geometry control: viaSize, viaDrill, clearance.
- edgeMargin: keep-out distance from board edge.
- maxVias: cap on total placements (useful for incremental work).
- dryRun: return placements without modifying the board — for
previewing before committing.
- Validates viaDrill < viaSize, rejects unknown strategy names,
surfaces clear errors when GND net can't be resolved or the
board outline is missing.
Approach ported from morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/ground/add_gnd_vias.py). The original parses the PCB text
with regex and writes vias by string concatenation; this port reads
obstacles via the pcbnew API (handles rotated footprints, integrates
with the live in-memory board so two sequential calls see each
other's placements, picks up net codes from the loaded board) and
adds the in_zones strategy, the maxVias cap, and dry-run mode.
Credit is in the docstring, the TypeScript wrapper comment, the MCP
tool description (visible to clients), and the CHANGELOG entry.
tests/test_add_gnd_stitching_vias.py — 18 cases, all passing.
Uses mocked pcbnew objects so the suite runs under both the conftest
stub and a real pcbnew install.
- grid strategy fills empty board with correct count
- collision blocks via near a signal track (with extent assertion)
- GND-net obstacles are correctly ignored
- around_refs densifies near footprints with bounded extent
- in_zones rejects candidates outside HitTestFilledArea
- dryRun does NOT call board.Add
- actual run calls board.Add per placement
- maxVias caps total placements
- intra-call clump prevention (asserts pairwise distance)
- viaDrill >= viaSize is rejected
- unknown strategy name is rejected
- missing GND net returns clear error payload
- no board loaded returns clear error
- named GND net (e.g. VSS) is honoured even when GND also exists
- direct unit tests for _point_to_segment_distance_nm helper
Real-board smoke test on TuneForge_TF001 (4-layer, 44 footprints):
- GND net auto-detected
- grid spacing 4mm: 141 placements, 129 blocked by collision
- grid + in_zones: 140 placed, 15 rejected by zone membership,
115 blocked by collision
python/commands/routing.py (+impl, ~370 LOC)
python/kicad_interface.py (+handler registration)
python/schemas/tool_schemas.py (+MCP schema)
src/tools/routing.ts (+TypeScript surface, builds clean)
tests/test_add_gnd_stitching_vias.py (+18 tests)
CHANGELOG.md (+Unreleased -> New MCP Tools)
Components now include boundingBox (min/max X/Y, width, height) in
get_component_list and get_component_properties responses. The SWIG
get_component_properties also includes courtyard dimensions when the
footprint defines a courtyard layer.
SWIG backend uses GetBoundingBox() and GetCourtyard(). IPC backend
tries get_item_bounding_box(), then pad-extent fallback, then falls
back to SWIG backend data when available. This ensures bounding box
data is present regardless of which backend handles the query.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Import-LIB-KiCad-Plugin documentation registers third-party libraries
using `${KICAD_3RD_PARTY}` (without a KiCad-version prefix). KiCad accepts
both unprefixed and version-prefixed forms in lib-tables, and the analogous
`KICAD_SYMBOL_DIR` was already handled in `library_symbol.py`'s env-var
dictionary.
`KICAD_3RD_PARTY` was missing from both `_resolve_uri` env-var dictionaries
(in `python/commands/library.py` and `python/commands/library_symbol.py`),
so lib-table rows authored as `${KICAD_3RD_PARTY}/Foo.kicad_sym` or
`${KICAD_3RD_PARTY}/Foo.pretty` failed to substitute, were treated as
non-existent paths, and disappeared from `list_symbol_libraries` /
`list_libraries` results — even though KiCad's GUI showed them correctly.
Changes:
- Add `KICAD_3RD_PARTY` to both `_resolve_uri` env-var dictionaries.
- Add `KICAD_3RD_PARTY` fallback in
`SymbolLibraryManager._find_3rd_party_dir`.
- Refactor `LibraryManager._find_kicad_3rdparty_dir` to check all four
env-var forms (KICAD10/9/8_3RD_PARTY + KICAD_3RD_PARTY) consistently.
- Add regression tests in `tests/test_kicad_3rd_party_env_resolution.py`.
Reproduces with:
- Set `KICAD_3RD_PARTY` env var in the MCP server's environment.
- Register `(lib (name "Foo") (type "KiCad") (uri "\${KICAD_3RD_PARTY}/Foo.kicad_sym") ...)` in the global sym-lib-table.
- Place a real `Foo.kicad_sym` at the resolved path.
- Before: `list_symbol_libraries` does not return `Foo`.
- After: `Foo` is listed.
The existing single-shot autoroute leaves 1-7 nets unrouted on dense
boards in my testing. Best-of-N drives that to 0 most of the time by
running Freerouting a few times with varied --max-passes and keeping
the SES with the best routing score.
New optional parameters (all backward-compatible):
attempts: int, default 1 (unchanged behaviour). When > 1, run
Freerouting N times and pick the highest-scoring SES.
targetNets: list of critical net names. An attempt that routes all
of them earns a 50,000-point scoring bonus.
passSchedule: list of --max-passes values to cycle through across
attempts. Default: [50, 60, 65, 70, 75, 80, 85, 90, 55,
95] (wraps if attempts > len). Ignored when attempts=1
(legacy maxPasses still used).
Scoring contract (pinned by tests):
score = nets_routed * 1000 + segments
if targetNets and all routed: score += 50_000
- +1 net always beats any segment-count delta (1000 pt step).
- Segments break ties at equal net count.
- Target bonus dominates net-count gains from unrelated nets.
## Implementation notes
- When attempts > 1, each attempt runs with `-mt 1` (single-thread
optimisation). Freerouting 2.x's multi-threaded optimiser is
documented to introduce clearance violations, so forcing
single-thread during scoring keeps the comparison apples-to-apples.
- One failed attempt does not abort the whole best-of-N run. The
failure is recorded in the response under attempts[] with ok=False,
and the remaining attempts compete for best. If every attempt fails
the response surfaces a clear error.
- The winning SES is preserved as <stem>_best.ses next to the
canonical <stem>.ses so the caller can inspect it after the run.
- Response shape:
attempts == 1: unchanged (no attempts/best_attempt fields)
attempts > 1: adds attempts[], best_attempt, best_score,
best_ses_path
## Attribution
Scoring approach and default pass schedule ported from
morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/routing/freeroute_runner.py). Credited in the function
docstring, the TypeScript wrapper comment, the tool description (visible
to MCP clients), and the CHANGELOG entry.
The MCP version adds: cleaner per-attempt result reporting, automatic
single-thread optimisation, graceful degradation on partial failure,
and explicit validation that surfaces clean error payloads for invalid
attempts values.
## Tests
tests/test_autoroute_score.py 8 cases, scoring contract
tests/test_autoroute_best_of_n.py 6 cases, orchestration logic
All 14 passing. Tests are pure-Python: subprocess is mocked so the
suite runs in any environment (no Java / Freerouting / KiCad required).
- Single-attempt response shape unchanged
- Best-of-three picks the highest-scoring SES
- One nonzero exit attempt doesn't abort the run
- passSchedule wraps when attempts exceeds len
- targetNets bonus wins over higher raw net count
- attempts=0 rejected with clean error before DSN export
- +1 net (1000 pts) dominates any segment delta
- Segments tiebreak at equal net count
- Quoted net names in SES are normalised vs unquoted targets
TypeScript builds clean.
Detects courtyard overlaps between footprints and flags courtyards that
extend past the board outline. Returns overlap pairs with intersection
extents (mm), per-component boundary violations, and a placement summary.
The killer feature for AI-driven workflows is the `positions` parameter,
which accepts hypothetical placements `{ref: [x, y]}` or
`{ref: [x, y, rotation_degrees]}`. The tool evaluates the proposed
placement WITHOUT writing to the board file — so an AI agent can validate
a move_component / place_component before committing it, instead of the
current loop of write -> run DRC -> parse violations -> revert.
## Implementation
- Uses the real courtyard polygons from pcbnew (`fp.GetCourtyard(F_CrtYd)`
or B_CrtYd) for accurate AABBs even on custom and rotated footprints.
- Falls back to `fp.GetBoundingBox()` when no F/B.Courtyard polygon is
present.
- For virtual rotation, rotates the four AABB corners and re-axis-aligns.
Conservative: the rotated-AABB is always >= the rotated-polygon, so
overlap reports are never false-negatives (may be marginally
over-cautious on diagonal rectangles, which is the right error bias
for a placement validator).
- Optional `margin` parameter expands every courtyard by N mm — useful
for enforcing a manufacturing keepout wider than the symbol's
declared courtyard.
## Attribution
The approach is ported from morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation), specifically
`scripts/placement/check_overlaps.py`. The upstream uses a static
per-footprint-type courtyard lookup table; this implementation reads
the real polygons from pcbnew so it works on any footprint without
maintaining a table. Attribution is in the function docstring, the
TypeScript wrapper, the tool's description (visible to MCP clients),
and the CHANGELOG entry.
## Tests
12 pytest cases in tests/test_check_courtyard_overlaps.py, all passing:
- No overlaps when spaced; overlap detected on intersect
- Margin pushes borderline pairs into overlap
- `refs` filter restricts the check
- Boundary violations are flagged; `include_boundary=false` suppresses
- Virtual position does not mutate the footprint (asserts
`SetPosition` is never called)
- Virtual rotation swaps a tall-narrow courtyard's x/y extents
- No-board-loaded returns clean error payload
- Bad position spec (wrong arity) returns clean error payload
- GetCourtyard() OutlineCount=0 -> fallback to GetBoundingBox()
- `board_outline` override replaces the Edge.Cuts bbox
Tests use mocked pcbnew objects so they run under both the conftest stub
and a real pcbnew install. Real-board smoke test on a 44-footprint
production board succeeds: 1 known overlap detected (SW1<->SW2), 0
boundary violations, virtual placement test reports 6 expected overlaps.
## Files touched
- python/commands/component.py (impl + helpers)
- python/kicad_interface.py (tool registration)
- python/schemas/tool_schemas.py (MCP schema entry)
- src/tools/component.ts (TypeScript surface, builds clean)
- tests/test_check_courtyard_overlaps.py (12 cases)
- CHANGELOG.md (Unreleased -> New MCP Tools)
The existing y_flip tests cover Device:R (symmetric two-pin) and Device:C
rotated 90° (still electrically symmetric). The original bug — a double
Y-flip in get_pin_location — was invisible on symmetric passives because
pin 1 and pin 2 are interchangeable; it only showed up on asymmetric
multi-pin ICs like RF_Module:ESP32-WROOM-32, where labels meant for pin 3
(EN) silently landed on pin 35 (TXD0).
This adds a third test using an inline 6-pin asymmetric symbol with pins
at both positive and negative library Y on both sides. It asserts every
pin lands at the formula-predicted (symbol_x + lib_px, symbol_y - lib_py)
position, with an explicit cross-check that lib +Y pins resolve *above*
the placement centre in schematic Y-down space.
No system-library dependency — the symbol is constructed inline so the
test runs anywhere pytest does.
Fixes#135
* Fix: IPC rotate_component now uses absolute angle as documented
The IPC rotate handler was adding the angle to the current rotation
(relative), but the schema documents it as absolute. This caused
unexpected behavior where setting angle=0 had no effect on a component
already at 180°. Now correctly sets the rotation to the exact angle
specified, matching the SWIG backend behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(changelog): add unreleased entry for rotate_component absolute-angle fix
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#126.
A placed schematic symbol carries its reference designator in two places:
(symbol
(property "Reference" "R5" …) ← what eeschema renders
(instances
(project "MyProject"
(path "/sheet-uuid/symbol-uuid"
(reference "R5") ← what netlist + PCB sync read
(unit 1) )))
…)
Before this change, `edit_schematic_component` with `newReference` updated
only the (property "Reference" …) field. The (reference "…") leaves inside
(instances) → (project) → (path) kept the old value. eeschema rendered the
new reference correctly and ERC passed, but `kicad-cli sch export netlist`
and "Update PCB from Schematic" both read from the (instances) block and
silently used the OLD reference — producing destructive PCB-sync diffs on
what users thought was a clean rename. Severity was high for anyone running
batch renames because the symptom only surfaces at PCB-sync time, by which
point many renames may be queued.
Walk the (instances) subtree within the matched symbol block after the
property update and replace every `(reference "OLD")` leaf with the new
value. The regex matches `(reference "X")` specifically (not
`(property "Reference" "X"`), and the walk is constrained to the
(instances …) range via the existing _find_matching_paren helper so other
(reference …) tokens elsewhere in the file can't be affected.
Adds tests/test_edit_schematic_component_instances.py covering:
- Single-instance rename updates both property and instances leaf
- Hierarchical case with multiple (path …) entries all updated atomically
- No-instances-block schematics don't crash (older KiCad / partial files)
- The regex doesn't clobber (property "Reference" …) on the instances pass
- Other field values (Value, Footprint) are left intact
- The response payload's updated.reference reflects the new ref
All 6 tests fail on main without the fix (3 fully, 3 on the instances
assertions only) and pass on this branch.
The pre-existing TestAddComponentMirrorParam failures in
test_add_schematic_component.py are unrelated and present on main —
documented in inktomi's PR #169.
Co-authored-by: mixelpixx <11727006+mixelpixx@users.noreply.github.com>