Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""
Eagle (.brd / .sch) → KiCad (.kicad_pcb / .kicad_sch) import commands.
Coordinate-system notes (from KiCad source sch_io_eagle.cpp):
• Eagle schematic: X right, Y up (origin anywhere)
• KiCad schematic: X right, Y down
• Conversion: kicad_x = eagle_x
kicad_y = y_offset - eagle_y (y_offset shifts everything positive)
• Eagle lib-symbol coords: Y up (same as Eagle schematic)
• KiCad lib-symbol coords: Y up (same! — confirmed by Device:R pin 1 at (0, 3.81))
• Therefore NO Y-flip needed inside symbol definitions.
• Instance rotation: the Y-flip inverts the apparent rotation direction.
KiCad_rotation = (-Eagle_rotation) mod 360
"""
from __future__ import annotations
import logging
import math
import os
import re
import subprocess
import sys
import uuid
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from commands.symbol_creator import KICAD9_SYMBOL_LIB_VERSION
from utils.sexpr_format import escape_sexpr_string
logger = logging.getLogger("kicad_interface")
# ── Constants ─────────────────────────────────────────────────────────────────
_POWER_LIBS = frozenset({"supply1", "supply2"})
_FRAME_DEVICESETS = frozenset({"a4l-loc", "a3l-loc", "frames", "frame"})
# Passive two-terminal parts whose pin names/numbers should be hidden, like the
# standard KiCad R/L/C symbols. Detected by the deviceset ref prefix.
_PASSIVE_PREFIXES = frozenset({"R", "L", "C"})
# Two-terminal parts whose Reference/Value get the "vertical" side-placement rule
# (rotated 90°, Reference left / Value right, centred) when drawn vertically.
# R/L/C plus diodes (D) and LEDs.
_VERT_LABEL_PREFIXES = frozenset({"R", "L", "C", "D", "LED"})
_PIN_TYPE: Dict[str, str] = {
"in": "input",
"out": "output",
"io": "bidirectional",
"oc": "open_collector",
"pas": "passive",
"pwr": "power_in",
"sup": "power_out",
"nc": "no_connect",
"hiz": "tri_state",
}
_PIN_LEN: Dict[str, float] = {
"point": 0.0,
"short": 2.54,
"middle": 5.08,
"long": 7.62,
}
# ── Utility helpers ────────────────────────────────────────────────────────────
def _uid() -> str:
return str(uuid.uuid4())
def _fmt(v: float) -> str:
return f"{v:.4f}".rstrip("0").rstrip(".")
def _escape(s: str) -> str:
"""Escape a value for an S-expression double-quoted token (#336)."""
return escape_sexpr_string(str(s))
def _sanitize(s: str) -> str:
"""Make a string safe for use in KiCad symbol names (quoted)."""
return re.sub(r'[<>"/\\|?*\s]+', "_", s).strip("_") or "X"
def _parse_rot(rot: Optional[str]) -> Tuple[float, bool]:
"""Parse Eagle rotation string like 'R90', 'MR180', 'SMR0' → (degrees, mirror)."""
if not rot:
return 0.0, False
mirror = "M" in rot
s = rot.replace("S", "").replace("M", "").replace("R", "").strip()
try:
return (float(s) if s else 0.0), mirror
except ValueError:
return 0.0, mirror
def _krot(eagle_deg: float, mirror: bool = False) -> int:
"""
Convert an Eagle rotation to the KiCad placement angle.
The symbol geometry is stored in the library in the original Eagle-local
frame (Y-up); instances are placed at (eagle_x, y_off - eagle_y).
KiCad's *placement* angle equals the Eagle rotation for BOTH mirrored and
non-mirrored parts — the mirror itself is expressed with a separate
"(mirror y)" flag (see mirror_field below), never by folding it into angle.
This was reverse-engineered directly from KiCad's own source
(SCH_SYMBOL::SetOrientation + the S-expr parser's (at .. angle)/(mirror ..)
handling in sch_io_kicad_sexpr_parser.cpp) and then confirmed by exporting
a calibration schematic to SVG. KiCad's effective lib->sheet transform is:
angle a, no mirror : (lx, ly) -> R(a) with the built-in Y-flip
angle a, mirror y : same, then negate sheet X == Eagle's mirror
(reflect about Y)
Eagle's "M" mirrors about the Y axis (flip X), which is exactly KiCad's
"(mirror y)" — NOT "(mirror x)". The earlier "(mirror x)"+(180-a) rule
only coincided for a=0/180 (where (A0,mirror x) == (A180,mirror y)), which
is why IC1@MR180 looked fine but IC13/FET3@MR90 rendered 180-degrees off
(i.e. double-mirrored).
"""
return int(eagle_deg % 360)
def _render_local(lx: float, ly: float, krot: int, mirror: bool) -> Tuple[float, float]:
"""
Transform a symbol-local point (Eagle-local, Y-up) to the KiCad sheet
delta from the placement origin, matching KiCad's REAL effective transform
(angle == Eagle angle, mirror expressed as "(mirror y)"). Calibrated
against KiCad SVG output; used only for bounding-box computation.
delta = (a*lx + b*ly, c*lx + d*ly)
"""
a, b, c, d = _TEFF[(int(krot) % 360, "y" if mirror else "none")]
return (a * lx + b * ly, c * lx + d * ly)
# KiCad effective library->sheet transform (includes the built-in Y flip),
# calibrated from actual SVG output. Key = (placement_angle, mirror_flag).
_TEFF: Dict[Tuple[int, str], Tuple[int, int, int, int]] = {
(0, "none"): (1, 0, 0, -1),
(90, "none"): (0, -1, -1, 0),
(180, "none"): (-1, 0, 0, 1),
(270, "none"): (0, 1, 1, 0),
(0, "y"): (-1, 0, 0, -1),
(90, "y"): (0, 1, -1, 0),
(180, "y"): (1, 0, 0, 1),
(270, "y"): (0, -1, 1, 0),
}
def _symbol_local_points(sg: "_EagleSymGeom") -> List[Tuple[float, float]]:
"""All extreme local points of a symbol (body + pin ends) for bbox use."""
pts: List[Tuple[float, float]] = []
for (x1, y1, x2, y2, *_rest) in sg.wires:
pts.append((x1, y1))
pts.append((x2, y2))
for cx, cy, r in sg.circles:
pts += [(cx - r, cy - r), (cx + r, cy + r)]
for x1, y1, x2, y2 in sg.rectangles:
pts += [(x1, y1), (x2, y2)]
for poly in sg.polygons:
pts += list(poly)
for pin in sg.pins:
pts.append((pin.x, pin.y))
a = math.radians(pin.angle or 0.0)
L = pin.length_mm or 0.0
pts.append((pin.x + L * math.cos(a), pin.y + L * math.sin(a)))
return pts
def _pin_lead(pin: "_EaglePin", indent: str) -> str:
"""Graphical lead line that replaces a pin's stub.
Pins are emitted with length 0 so that KiCad's electrical connection point
sits exactly on the pin origin (pin.x, pin.y) for *every* orientation —
KiCad computes the connection point of a non-zero-length pin differently for
vertical (90/270) vs. horizontal (0/180) pins, which left vertically drawn
pins (power/GND symbols, rotated device pins) electrically unconnected even
though the wire visually touched them. The visible lead is preserved here
as a plain polyline so symbols look identical. Returns '' for zero length.
"""
L = pin.length_mm or 0.0
if L <= 0:
return ""
a = math.radians(pin.angle or 0.0)
ex = pin.x + L * math.cos(a)
ey = pin.y + L * math.sin(a)
# Lead starts at the grid-snapped connection point (see pin emission below).
sx = round(pin.x / _GRID_MM) * _GRID_MM
sy = round(pin.y / _GRID_MM) * _GRID_MM
return (
f"{indent}(polyline (pts (xy {_fmt(sx)} {_fmt(sy)})"
f" (xy {_fmt(ex)} {_fmt(ey)}))"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
# Standard KiCad paper sizes, landscape (name, width_mm, height_mm), small→large.
_PAPER_SIZES = [
("A4", 297.0, 210.0),
("A3", 420.0, 297.0),
("A2", 594.0, 420.0),
("A1", 841.0, 594.0),
("A0", 1189.0, 841.0),
]
_SHEET_MARGIN = 15.0 # mm clear border kept around the content
_GRID_MM = 1.27 # KiCad default schematic grid (50 mil)
# Max distance (in grid steps) a net label may be moved onto its wire's free end
# / nearest segment when snapping. Kept as a module global so it can be tuned.
_LABEL_FE_STEPS = 8.0
_LABEL_SEG_STEPS = 10.0
def _gridsnap(v: float) -> float:
"""Snap a single coordinate to the KiCad schematic grid."""
return round(v / _GRID_MM) * _GRID_MM
def _on_wire_segment(pt: Tuple[float, float], seg: Tuple[float, float, float, float]) -> bool:
"""True if *pt* lies on wire segment *seg* (including endpoints)."""
x, y = pt
x1, y1, x2, y2 = seg
cross = (x - x1) * (y2 - y1) - (y - y1) * (x2 - x1)
if abs(cross) > 1e-3:
return False
l2 = (x2 - x1) ** 2 + (y2 - y1) ** 2
if l2 == 0:
return abs(x - x1) < 1e-3 and abs(y - y1) < 1e-3
t = ((x - x1) * (x2 - x1) + (y - y1) * (y2 - y1)) / l2
return -1e-6 <= t <= 1 + 1e-6
def _pt_key(x: float, y: float) -> Tuple[float, float]:
return (round(x, 3), round(y, 3))
def _trim_wires(
wires: List[Tuple[float, float, float, float]],
conn_pts: "set",
) -> List[Tuple[float, float, float, float]]:
"""Trim/remove dangling wire stubs left over from the Eagle source.
Eagle frequently draws wires that overshoot their label or pin by a grid
step (or leaves an isolated stub), which KiCad flags as an unconnected wire
endpoint and which visually looks like a label/pin sitting off the wire.
For every wire we gather its *anchor* points — connection points
(pins/labels/junctions) lying on it plus points where another wire meets it.
The wire is then shortened to span only between its outermost anchors, and a
wire with fewer than two anchors (i.e. it bridges nothing) is dropped. This
never disconnects two connection points: anchors are exactly the points that
matter, and only empty overshoot beyond them is removed.
"""
def key(x: float, y: float) -> Tuple[float, float]:
return _pt_key(x, y)
def on_seg(pt: Tuple[float, float], seg: Tuple[float, float, float, float]) -> bool:
return _on_wire_segment(pt, seg)
normalized: List[Tuple[float, float, float, float]] = [
(round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)) for x1, y1, x2, y2 in wires
]
wires = normalized
changed = True
while changed:
changed = False
endcnt: Dict[Tuple[float, float], int] = {}
for x1, y1, x2, y2 in wires:
endcnt[key(x1, y1)] = endcnt.get(key(x1, y1), 0) + 1
endcnt[key(x2, y2)] = endcnt.get(key(x2, y2), 0) + 1
new_wires: List[Tuple[float, float, float, float]] = []
for i, (x1, y1, x2, y2) in enumerate(wires):
seg = (x1, y1, x2, y2)
anchors: "set" = set()
for cp in conn_pts:
if on_seg(cp, seg):
anchors.add(key(cp[0], cp[1]))
# endpoints supported by other wires (shared endpoint or T-contact)
for j, other in enumerate(wires):
if j == i:
continue
for ep in ((other[0], other[1]), (other[2], other[3])):
if on_seg(ep, seg):
anchors.add(key(ep[0], ep[1]))
# own endpoints only count as anchors if another wire meets there
if endcnt[key(x1, y1)] >= 2:
anchors.add(key(x1, y1))
if endcnt[key(x2, y2)] >= 2:
anchors.add(key(x2, y2))
if len(anchors) < 2:
changed = True # pure stub / appendage → drop
continue
dx, dy = x2 - x1, y2 - y1
l2 = dx * dx + dy * dy or 1.0
ts = [((ax - x1) * dx + (ay - y1) * dy) / l2 for ax, ay in anchors]
tmin, tmax = max(0.0, min(ts)), min(1.0, max(ts))
nx1, ny1 = round(x1 + tmin * dx, 3), round(y1 + tmin * dy, 3)
nx2, ny2 = round(x1 + tmax * dx, 3), round(y1 + tmax * dy, 3)
if (nx1, ny1) == (nx2, ny2):
changed = True # collapsed to a point → drop
continue
nw = (nx1, ny1, nx2, ny2)
if nw != seg:
changed = True
new_wires.append(nw)
wires = new_wires
return wires
def _snap_wire_endpoints(
wires: List[Tuple[float, float, float, float]],
conn_pts: "set",
max_dist: float = _GRID_MM * 0.6,
) -> List[Tuple[float, float, float, float]]:
"""Snap wire endpoints to the nearest connection point within *max_dist*."""
if not conn_pts:
return wires
conn_list = list(conn_pts)
max_d2 = max_dist * max_dist
snapped: List[Tuple[float, float, float, float]] = []
for x1, y1, x2, y2 in wires:
for idx, (x, y) in enumerate(((x1, y1), (x2, y2))):
best: Optional[Tuple[float, float, float]] = None
for cx, cy in conn_list:
d2 = (x - cx) ** 2 + (y - cy) ** 2
if d2 <= max_d2 and (best is None or d2 < best[0]):
best = (d2, cx, cy)
if best is not None:
if idx == 0:
x1, y1 = best[1], best[2]
else:
x2, y2 = best[1], best[2]
snapped.append((x1, y1, x2, y2))
return snapped
def _endpoint_connected(
x: float,
y: float,
wires: List[Tuple[float, float, float, float]],
conn_keys: set,
) -> bool:
k = _pt_key(x, y)
if k in conn_keys:
return True
endpoint_hits = sum(
1
for wx1, wy1, wx2, wy2 in wires
for ex, ey in ((wx1, wy1), (wx2, wy2))
if _pt_key(ex, ey) == k
)
if endpoint_hits >= 2:
return True
for wx1, wy1, wx2, wy2 in wires:
if _on_wire_segment((x, y), (wx1, wy1, wx2, wy2)):
wk1, wk2 = _pt_key(wx1, wy1), _pt_key(wx2, wy2)
if k != wk1 and k != wk2:
return True
return False
def _prune_dangling_wires(
wires: List[Tuple[float, float, float, float]],
conn_pts: "set",
) -> List[Tuple[float, float, float, float]]:
"""Drop wire segments with any endpoint not connected to a pin/label/junction/wire."""
conn_keys = {_pt_key(x, y) for x, y in conn_pts}
current = list(wires)
changed = True
while changed:
changed = False
kept: List[Tuple[float, float, float, float]] = []
for seg in current:
x1, y1, x2, y2 = seg
if _endpoint_connected(x1, y1, current, conn_keys) and _endpoint_connected(
x2, y2, current, conn_keys
):
kept.append(seg)
else:
changed = True
current = kept
return current
def _count_dangling_wires(
wires: List[Tuple[float, float, float, float]],
conn_pts: "set",
) -> int:
"""Count wire segments with at least one unconnected endpoint (KiCad wire_dangling)."""
conn_keys = {_pt_key(x, y) for x, y in conn_pts}
count = 0
for x1, y1, x2, y2 in wires:
if not _endpoint_connected(x1, y1, wires, conn_keys) or not _endpoint_connected(
x2, y2, wires, conn_keys
):
count += 1
return count
def _choose_paper(w: float, h: float) -> Tuple[str, float, float]:
"""Smallest standard landscape sheet that fits w×h plus margins (fallback A0)."""
for name, pw, ph in _PAPER_SIZES:
if w + 2 * _SHEET_MARGIN <= pw and h + 2 * _SHEET_MARGIN <= ph:
return name, pw, ph
return _PAPER_SIZES[-1]
def _arc_segments(
x1: float, y1: float, x2: float, y2: float, curve_deg: float
) -> List[Tuple[float, float, float, float]]:
"""
Approximate an Eagle arc (chord + CCW angle) with line segments.
Returns list of (sx, sy, ex, ey) in the SAME coordinate system (no Y-flip here).
"""
alpha = math.radians(curve_deg)
dx, dy = x2 - x1, y2 - y1
chord = math.hypot(dx, dy)
if chord < 1e-10 or abs(alpha) < 1e-10:
return [(x1, y1, x2, y2)]
mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
px, py = -dy / chord, dx / chord
# distance from chord midpoint to arc centre
try:
d = chord / 2.0 / math.tan(alpha / 2.0)
except ZeroDivisionError:
return [(x1, y1, x2, y2)]
cx, cy = mx + d * px, my + d * py
r = math.hypot(x1 - cx, y1 - cy)
ts = math.atan2(y1 - cy, x1 - cx)
steps = max(3, int(abs(curve_deg) / 30.0))
segs: List[Tuple[float, float, float, float]] = []
for i in range(steps):
a1 = ts + alpha * i / steps
a2 = ts + alpha * (i + 1) / steps
segs.append(
(
cx + r * math.cos(a1),
cy + r * math.sin(a1),
cx + r * math.cos(a2),
cy + r * math.sin(a2),
)
)
return segs
# ── Eagle data containers ──────────────────────────────────────────────────────
class _EaglePin:
"""One pin as read from an Eagle symbol definition."""
__slots__ = ("name", "x", "y", "length_mm", "direction", "angle")
def __init__(self, el: ET.Element) -> None:
self.name = el.get("name", "?")
self.x = float(el.get("x", 0))
self.y = float(el.get("y", 0))
self.length_mm = _PIN_LEN.get(el.get("length", "middle"), 5.08)
self.direction = el.get("direction", "io")
self.angle, _ = _parse_rot(el.get("rot", "R0"))
class _EagleSymGeom:
"""Complete geometry of one Eagle symbol (from a library)."""
__slots__ = (
"name",
"kicad_id",
"lib_name",
"is_power",
"ref_prefix",
"wires", # [(x1,y1,x2,y2, curve_deg|None), ...]
"circles", # [(cx, cy, radius), ...]
"rectangles", # [(x1,y1,x2,y2), ...]
"polygons", # [[(x,y), ...], ...]
"pins", # [_EaglePin, ...]
"pin_pads", # {pin_name: pad_number} (from deviceset <connect>)
)
def __init__(self, name: str, kicad_id: str, lib_name: str) -> None:
self.name = name
self.kicad_id = kicad_id
self.lib_name = lib_name
self.is_power = lib_name.lower() in _POWER_LIBS
self.ref_prefix = "P" if self.is_power else "U"
self.wires: List[Tuple] = []
self.circles: List[Tuple] = []
self.rectangles: List[Tuple] = []
self.polygons: List[List[Tuple]] = []
self.pins: List[_EaglePin] = []
self.pin_pads: Dict[str, str] = {}
def pin_number(self, pin_name: str, index: int) -> str:
"""
KiCad pin number for a pin. Uses the physical pad from the Eagle
<connect> map; falls back to a 1-based sequential number when the
symbol has no pad mapping (e.g. power/supply symbols).
"""
pad = self.pin_pads.get(pin_name)
return pad if pad else str(index + 1)
def with_pads(self, kicad_id: str, pin_pads: Dict[str, str]) -> "_EagleSymGeom":
"""
Return a variant of this symbol that shares the geometry but carries
its own pad map and kicad_id. Needed because one Eagle symbol can be
reused by several gates/devices with different pad numbers (connectors,
resistor arrays, ...), which KiCad expresses as separate lib symbols.
"""
v = _EagleSymGeom(self.name, kicad_id, self.lib_name)
v.ref_prefix = self.ref_prefix
v.wires = self.wires
v.circles = self.circles
v.rectangles = self.rectangles
v.polygons = self.polygons
v.pins = self.pins
v.pin_pads = dict(pin_pads)
return v
class _EagleInst:
"""One gate placement on an Eagle schematic sheet (a part may have many)."""
__slots__ = ("part", "gate", "x", "y", "angle", "mirror", "sym_geom", "part_ref")
def __init__(self, el: ET.Element) -> None:
self.part = el.get("part", "")
self.gate = el.get("gate", "G$1")
self.x = float(el.get("x", 0))
self.y = float(el.get("y", 0))
self.angle, self.mirror = _parse_rot(el.get("rot", "R0"))
self.sym_geom: Optional[_EagleSymGeom] = None
self.part_ref: Optional[_EaglePart] = None
class _EaglePart:
"""Eagle part (logical component: library + deviceset + device + value)."""
__slots__ = (
"name",
"value",
"lib_name",
"ds_name",
"device",
"is_power",
"is_frame",
"sym_geom",
)
def __init__(self, el: ET.Element) -> None:
self.name = el.get("name", "")
self.value = el.get("value", "")
self.lib_name = el.get("library", "")
self.ds_name = el.get("deviceset", "")
self.device = el.get("device", "")
self.is_power = self.lib_name.lower() in _POWER_LIBS
self.is_frame = self.ds_name.lower() in _FRAME_DEVICESETS
self.sym_geom: Optional[_EagleSymGeom] = None
# ── Eagle XML parser ───────────────────────────────────────────────────────────
def parse_eagle_schematic(sch_path: str):
"""
Parse an Eagle .sch XML file.
Handles multi-sheet designs (sheets laid side-by-side) and multi-gate
parts (each gate becomes its own placement).
Returns
-------
parts : dict part_name → _EaglePart (sym_geom filled in)
instances : list of _EagleInst (one per gate, all sheets)
net_wires : list of (net_name, x1, y1, x2, y2)
net_labels : list of (net_name, x, y, angle_deg)
junctions : list of (x, y)
"""
root = ET.parse(sch_path).getroot()
sch = root.find("drawing/schematic")
if sch is None:
raise ValueError("Not an Eagle schematic (missing <drawing/schematic>)")
# ── 1. Collect symbol geometry from embedded libraries ─────────────────
# (lib_name, sym_name) → _EagleSymGeom
sym_geoms: Dict[Tuple[str, str], _EagleSymGeom] = {}
# (lib_name, ds_name, gate_name) → eagle_sym_name
gate_to_sym: Dict[Tuple[str, str, str], str] = {}
# (lib_name, ds_name, device_name, gate_name) → {pin_name: pad}
connects: Dict[Tuple[str, str, str, str], Dict[str, str]] = {}
# Dedup: track which kicad_ids are already in use
used_kicad_ids: Dict[str, Tuple[str, str]] = {} # kicad_id → (lib, sym)
for lib in sch.findall("libraries/library"):
lib_name = lib.get("name", "")
# -- symbols --
for sym_el in lib.findall("symbols/symbol"):
sym_name = sym_el.get("name", "")
kicad_id = f"eagle_import:{_sanitize(sym_name)}"
if kicad_id in used_kicad_ids and used_kicad_ids[kicad_id] != (lib_name, sym_name):
kicad_id = f"eagle_import:{_sanitize(lib_name)}_{_sanitize(sym_name)}"
used_kicad_ids[kicad_id] = (lib_name, sym_name)
sg = _EagleSymGeom(sym_name, kicad_id, lib_name)
for w in sym_el.findall("wire"):
try:
curve_s = w.get("curve")
sg.wires.append(
(
float(w.get("x1", 0)),
float(w.get("y1", 0)),
float(w.get("x2", 0)),
float(w.get("y2", 0)),
float(curve_s) if curve_s else None,
)
)
except (ValueError, TypeError):
pass
for c in sym_el.findall("circle"):
try:
sg.circles.append(
(
float(c.get("x", 0)),
float(c.get("y", 0)),
float(c.get("radius", 0)),
)
)
except (ValueError, TypeError):
pass
for r in sym_el.findall("rectangle"):
try:
sg.rectangles.append(
(
float(r.get("x1", 0)),
float(r.get("y1", 0)),
float(r.get("x2", 0)),
float(r.get("y2", 0)),
)
)
except (ValueError, TypeError):
pass
for poly in sym_el.findall("polygon"):
verts: List[Tuple[float, float]] = []
for v in poly.findall("vertex"):
try:
verts.append((float(v.get("x", 0)), float(v.get("y", 0))))
except (ValueError, TypeError):
pass
if len(verts) >= 2:
sg.polygons.append(verts)
sg.pins = [_EaglePin(p) for p in sym_el.findall("pin")]
sym_geoms[(lib_name, sym_name)] = sg
# -- devicesets: gate→symbol map, ref prefix, and per-device pad maps --
for ds in lib.findall("devicesets/deviceset"):
ds_name = ds.get("name", "")
prefix = ds.get("prefix", "U")
for gate in ds.findall("gates/gate"):
gate_name = gate.get("name", "")
sym_name = gate.get("symbol", "")
gate_to_sym[(lib_name, ds_name, gate_name)] = sym_name
gate_sg = sym_geoms.get((lib_name, sym_name))
if gate_sg is not None:
gate_sg.ref_prefix = prefix or "U"
# Pad map per (device, gate): {(dev_name, gate): {pin: pad}}
for dev in ds.findall("devices/device"):
dev_name = dev.get("name", "")
for con in dev.findall("connects/connect"):
g = con.get("gate", "")
pin = con.get("pin", "")
pad = con.get("pad", "")
if g and pin and pad:
connects.setdefault((lib_name, ds_name, dev_name, g), {})[pin] = pad
# ── 2. Parse parts ─────────────────────────────────────────────────────
parts: Dict[str, _EaglePart] = {}
for el in sch.findall("parts/part"):
p = _EaglePart(el)
parts[p.name] = p
# ── 3+4. Parse ALL sheets: instances (one per gate) + nets ─────────────
# Each Eagle sheet is laid out side-by-side in X so nothing overlaps.
instances: List[_EagleInst] = []
net_wires: List[Tuple[str, float, float, float, float]] = []
net_labels: List[Tuple[str, float, float, float]] = []
junctions: List[Tuple[float, float]] = []
# Variant cache: (lib, sym, pad_signature) → _EagleSymGeom (with pad map)
variant_geoms: Dict[Tuple[str, str, Tuple], _EagleSymGeom] = {}
# base kicad_id → number of distinct variants seen (for id suffixing)
variant_count: Dict[str, int] = {}
def _resolve_pads(part: _EaglePart, gate: str, sym_name: str) -> Dict[str, str]:
key = (part.lib_name, part.ds_name, part.device, gate)
if key in connects:
return connects[key]
# Fallback: any device of this deviceset/gate that has a pad map
for (ln, dn, _dev, g), pads in connects.items():
if ln == part.lib_name and dn == part.ds_name and g == gate:
return pads
return {}
def _resolve_geom(part: _EaglePart, gate: str) -> Optional[_EagleSymGeom]:
sym_name = gate_to_sym.get((part.lib_name, part.ds_name, gate), "")
if not sym_name:
# Fallback: first gate defined for this library+deviceset
for (ln, dn, _), sn in gate_to_sym.items():
if ln == part.lib_name and dn == part.ds_name:
sym_name = sn
break
base = sym_geoms.get((part.lib_name, sym_name)) if sym_name else None
if base is None:
return None
pads = _resolve_pads(part, gate, sym_name)
sig = tuple(sorted(pads.items()))
vkey = (part.lib_name, sym_name, sig)
if vkey in variant_geoms:
return variant_geoms[vkey]
# First variant for this base symbol keeps the base id; later distinct
# pad maps get a numeric suffix so KiCad sees them as separate symbols.
n = variant_count.get(base.kicad_id, 0)
kid = base.kicad_id if n == 0 else f"{base.kicad_id}_{n + 1}"
variant_count[base.kicad_id] = n + 1
variant = base.with_pads(kid, pads)
variant_geoms[vkey] = variant
return variant
SHEET_DX = 500.0 # mm between successive sheets in the merged KiCad sheet
for si, sheet in enumerate(sch.findall("sheets/sheet")):
dx = si * SHEET_DX
# -- instances (each gate is its own placement) --
for el in sheet.findall("instances/instance"):
inst = _EagleInst(el)
inst.x += dx
part = parts.get(inst.part)
inst.part_ref = part
if part and not part.is_frame:
inst.sym_geom = _resolve_geom(part, inst.gate)
# Record on the part too (used for value/metadata lookups)
if part.sym_geom is None and inst.sym_geom is not None:
part.sym_geom = inst.sym_geom
instances.append(inst)
# -- nets --
for net in sheet.findall("nets/net"):
net_name = net.get("name", "")
for seg in net.findall("segment"):
for w in seg.findall("wire"):
try:
net_wires.append(
(
net_name,
float(w.get("x1", 0)) + dx,
float(w.get("y1", 0)),
float(w.get("x2", 0)) + dx,
float(w.get("y2", 0)),
)
)
except (ValueError, TypeError):
pass
for lbl in seg.findall("label"):
try:
angle, _ = _parse_rot(lbl.get("rot", "R0"))
net_labels.append(
(
net_name,
float(lbl.get("x", 0)) + dx,
float(lbl.get("y", 0)),
angle,
)
)
except (ValueError, TypeError):
pass
for junc in seg.findall("junction"):
try:
junctions.append(
(
float(junc.get("x", 0)) + dx,
float(junc.get("y", 0)),
)
)
except (ValueError, TypeError):
pass
return parts, instances, net_wires, net_labels, junctions
# ── KiCad lib_symbol generator ─────────────────────────────────────────────────
def _baked_kicad_id(sg: "_EagleSymGeom", krot: int) -> str:
"""lib_id for the rotation/mirror-baked variant of a symbol."""
return f"{sg.kicad_id}__m{int(krot) % 360}"
def _bake_xform(krot: int):
"""Return an (x, y) -> (x, y) transform that bakes a mirrored instance's
(rotation krot + mirror-y) placement into the symbol geometry, so the
instance can be placed with an identity transform (at x y 0, no mirror).
Placing the baked symbol at (origin, 0, no-mirror) makes KiCad apply the
identity library transform _TEFF[(0,"none")] = (1,0,0,-1); we therefore
store (dx, -dy) where (dx, dy) is the sheet-space offset the original
mirrored placement would have produced. KiCad cannot electrically connect
wires to pins on mirrored symbols, so eliminating the mirror flag is what
makes those nets connect.
"""
def _x(lx: float, ly: float) -> Tuple[float, float]:
dx, dy = _render_local(lx, ly, krot, True)
return (dx, -dy)
return _x
def _gen_multi_unit_lib_symbol(
sym_name: str,
ref_prefix: str,
is_power: bool,
value: str,
units: List[Tuple[int, "_EagleSymGeom", "Optional[Callable]"]],
) -> str:
"""Generate a multi-unit KiCad lib_symbol combining several gate geometries.
Each gate becomes its own unit sub-symbol ``_N_1`` with body drawing and
pins. Sub-symbol ``_0_1`` is an empty common body. ``units`` is a list of
``(unit_number, sg, xform_or_None)`` triples in ascending unit order.
"""
leaf = sym_name.split(":")[-1]
ref = _escape(ref_prefix or "U")
sym = _escape(sym_name)
power_flag = " (power)" if is_power else ""
hide = (
" (pin_numbers (hide yes)) (pin_names (hide yes))"
if (is_power or (ref_prefix or "").upper() in _PASSIVE_PREFIXES)
else ""
)
L: List[str] = []
L.append(f' (symbol "{sym}"{hide} (in_bom yes) (on_board yes){power_flag}\n')
L.append(
f' (property "Reference" "{ref}" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27))))\n"
)
L.append(
f' (property "Value" "{_escape(value)}" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27))))\n"
)
L.append(
f' (property "Footprint" "" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27)) hide))\n"
)
L.append(
f' (property "Datasheet" "" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27)) hide))\n"
)
# Common body — empty (each unit has its own geometry)
L.append(f' (symbol "{_escape(leaf)}_0_1"\n')
L.append(" )\n")
def _polyline(points: List[Tuple[float, float]], fill: str = "none") -> str:
pts = " ".join(f"(xy {_fmt(x)} {_fmt(y)})" for x, y in points)
return (
f" (polyline (pts {pts})"
f" (stroke (width 0) (type default)) (fill (type {fill})))\n"
)
def _gsnap(pt: Tuple[float, float]) -> Tuple[float, float]:
return (round(pt[0] / _GRID_MM) * _GRID_MM, round(pt[1] / _GRID_MM) * _GRID_MM)
for unit_num, sg, xform in sorted(units, key=lambda u: u[0]):
def T(x: float, y: float) -> Tuple[float, float]:
return xform(x, y) if xform else (x, y)
L.append(f' (symbol "{_escape(leaf)}_{unit_num}_1"\n')
for x1, y1, x2, y2, curve in sg.wires:
if curve is not None:
for sx, sy, ex, ey in _arc_segments(x1, y1, x2, y2, curve):
L.append(_polyline([T(sx, sy), T(ex, ey)]))
else:
L.append(_polyline([T(x1, y1), T(x2, y2)]))
for cx, cy, r in sg.circles:
tcx, tcy = T(cx, cy)
L.append(
f" (circle (center {_fmt(tcx)} {_fmt(tcy)}) (radius {_fmt(r)})"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
for x1, y1, x2, y2 in sg.rectangles:
if xform is None:
L.append(
f" (rectangle (start {_fmt(x1)} {_fmt(y1)})"
f" (end {_fmt(x2)} {_fmt(y2)})"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
else:
L.append(_polyline([T(x1, y1), T(x2, y1), T(x2, y2), T(x1, y2), T(x1, y1)]))
for verts in sg.polygons:
tv = [T(x, y) for x, y in verts]
L.append(_polyline(tv + [tv[0]], fill="outline"))
# Pin leads
for pin in sg.pins:
L2 = pin.length_mm or 0.0
if L2 <= 0:
continue
a = math.radians(pin.angle or 0.0)
p0 = _gsnap(T(pin.x, pin.y))
p1 = T(pin.x + L2 * math.cos(a), pin.y + L2 * math.sin(a))
L.append(_polyline([p0, p1]))
# Pins
for idx, pin in enumerate(sg.pins):
ptype = _PIN_TYPE.get(pin.direction.lower(), "bidirectional")
number = sg.pin_number(pin.name, idx)
pax, pay = _gsnap(T(pin.x, pin.y))
pang = int(pin.angle % 360)
if xform is not None:
a = math.radians(pin.angle or 0.0)
bx, by = T(pin.x + math.cos(a), pin.y + math.sin(a))
pang = int(round(math.degrees(math.atan2(by - pay, bx - pax))) % 360)
pang = (pang // 90) * 90
L.append(
f" (pin {ptype} line"
f" (at {_fmt(pax)} {_fmt(pay)} {pang})"
f" (length 0)\n"
f' (name "{_escape(pin.name)}" (effects (font (size 1.016 1.016))))\n'
f' (number "{_escape(number)}" (effects (font (size 1.016 1.016)))))\n'
)
L.append(" )\n")
L.append(" )\n")
return "".join(L)
def _gen_lib_symbol(
sg: _EagleSymGeom,
xform: Optional[Callable[[float, float], Tuple[float, float]]] = None,
name_id: Optional[str] = None,
) -> str:
"""
Generate the KiCad lib_symbol S-expression for an Eagle symbol.
Key rule: Eagle lib-symbols and KiCad lib-symbols both use Y-up,
so no Y coordinate flip is needed inside the symbol definition.
Pin angles are also kept as-is (both use CCW from +X).
When ``xform`` is given (used for mirrored instances) every geometry point
and pin position is passed through it, producing a "baked" variant named
``name_id`` that is placed with an identity instance transform.
"""
# Sub-symbol names MUST start with the parent symbol's leaf name (the
# part after the ':' in the lib_id). The dedup logic may prefix the
# kicad_id with the library name, so derive the leaf from kicad_id — not
# from sg.name — otherwise KiCad rejects the mismatched sub-symbol.
kid = name_id or sg.kicad_id
leaf = kid.split(":")[-1]
ref = _escape(sg.ref_prefix or "U")
sym = _escape(kid)
power_flag = " (power)" if sg.is_power else ""
hide = (
" (pin_numbers (hide yes)) (pin_names (hide yes))"
if (sg.is_power or (sg.ref_prefix or "").upper() in _PASSIVE_PREFIXES)
else ""
)
def T(x: float, y: float) -> Tuple[float, float]:
return xform(x, y) if xform else (x, y)
L: List[str] = []
L.append(f' (symbol "{sym}"{hide} (in_bom yes) (on_board yes){power_flag}\n')
L.append(
f' (property "Reference" "{ref}" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27))))\n"
)
L.append(
f' (property "Value" "{_escape(sg.name)}" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27))))\n"
)
L.append(
f' (property "Footprint" "" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27)) hide))\n"
)
L.append(
f' (property "Datasheet" "" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27)) hide))\n"
)
# ── Drawing body in sub-symbol _0_1 ─────────────────────────────────
L.append(f' (symbol "{_escape(leaf)}_0_1"\n')
def _polyline(points: List[Tuple[float, float]], fill: str = "none") -> str:
pts = " ".join(f"(xy {_fmt(x)} {_fmt(y)})" for x, y in points)
return (
f" (polyline (pts {pts})"
f" (stroke (width 0) (type default)) (fill (type {fill})))\n"
)
for x1, y1, x2, y2, curve in sg.wires:
if curve is not None:
# Approximate arc with polyline segments (segment first, then bake).
for sx, sy, ex, ey in _arc_segments(x1, y1, x2, y2, curve):
L.append(_polyline([T(sx, sy), T(ex, ey)]))
else:
L.append(_polyline([T(x1, y1), T(x2, y2)]))
for cx, cy, r in sg.circles:
tcx, tcy = T(cx, cy)
L.append(
f" (circle (center {_fmt(tcx)} {_fmt(tcy)}) (radius {_fmt(r)})"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
for x1, y1, x2, y2 in sg.rectangles:
if xform is None:
L.append(
f" (rectangle (start {_fmt(x1)} {_fmt(y1)})"
f" (end {_fmt(x2)} {_fmt(y2)})"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
else:
# A rotated/mirrored rectangle is no longer axis-aligned → polyline.
L.append(_polyline([T(x1, y1), T(x2, y1), T(x2, y2), T(x1, y2), T(x1, y1)]))
for verts in sg.polygons:
tv = [T(x, y) for x, y in verts]
L.append(_polyline(tv + [tv[0]], fill="outline"))
# Visible pin leads (pins themselves are emitted with length 0 below).
# The lead starts at the grid-snapped pin connection point so it stays
# visually attached to the (snapped) pin marker.
def _gsnap(pt: Tuple[float, float]) -> Tuple[float, float]:
return (round(pt[0] / _GRID_MM) * _GRID_MM, round(pt[1] / _GRID_MM) * _GRID_MM)
for pin in sg.pins:
L2 = pin.length_mm or 0.0
if L2 <= 0:
continue
a = math.radians(pin.angle or 0.0)
p0 = _gsnap(T(pin.x, pin.y))
p1 = T(pin.x + L2 * math.cos(a), pin.y + L2 * math.sin(a))
L.append(_polyline([p0, p1]))
L.append(" )\n")
# ── Pins in sub-symbol _1_1 ──────────────────────────────────────────
L.append(f' (symbol "{_escape(leaf)}_1_1"\n')
for idx, pin in enumerate(sg.pins):
ptype = _PIN_TYPE.get(pin.direction.lower(), "bidirectional")
number = sg.pin_number(pin.name, idx)
# Snap the (length-0) electrical connection point to the schematic grid.
# Most Eagle symbols already define pins on-grid (no-op); a few (e.g. the
# N-MOSFET symbol) place pins off-grid. Instance origins and wires are
# grid-snapped as well, and grid snapping is invariant under whole-grid
# shifts, so the pin and the wire that meets it still coincide.
pax, pay = _gsnap(T(pin.x, pin.y))
# Pin orientation only affects the (hidden, zero-length) pin marker and
# name placement; bake it so it still points along the drawn lead.
pang = int(pin.angle % 360)
if xform is not None:
a = math.radians(pin.angle or 0.0)
bx, by = T(pin.x + math.cos(a), pin.y + math.sin(a))
pang = int(round(math.degrees(math.atan2(by - pay, bx - pax))) % 360)
pang = (pang // 90) * 90
# Pins are length 0 so KiCad's connection point is exactly the pin
# position for all orientations; the drawn lead is added to the body.
L.append(
f" (pin {ptype} line"
f" (at {_fmt(pax)} {_fmt(pay)} {pang})"
f" (length 0)\n"
f' (name "{_escape(pin.name)}" (effects (font (size 1.016 1.016))))\n'
f' (number "{_escape(number)}" (effects (font (size 1.016 1.016)))))\n'
)
L.append(" )\n")
L.append(" )\n")
return "".join(L)
# ── KiCad symbol library generator (.kicad_sym) ────────────────────────────────
def generate_sym_lib(sym_geoms: List["_EagleSymGeom"], lib_path: str) -> None:
"""
Write a stand-alone KiCad symbol library (.kicad_sym) from a list of
Eagle symbol geometries.
The library nickname used in the schematic is "eagle_import" (matching the
lib_id prefix "eagle_import:SYMBOLNAME"). A matching sym-lib-table entry
lets KiCad resolve the symbols both from the embedded lib_symbols section
and from this library file.
"""
out: List[str] = []
out.append(
f"(kicad_symbol_lib (version {KICAD9_SYMBOL_LIB_VERSION})"
' (generator "kicad_symbol_editor")\n'
)
for sg in sym_geoms:
# In a .kicad_sym file the symbol name has NO library prefix — the
# prefix comes from the library nickname in sym-lib-table. Use the
# kicad_id leaf (after ':') so the name matches what the schematic
# references (eagle_import:<leaf>) and stays unique across libraries.
leaf = sg.kicad_id.split(":")[-1]
power = "\n (power)" if sg.is_power else ""
ref = _escape(sg.ref_prefix or "U")
name = _escape(leaf)
hide = (
" (pin_numbers (hide yes)) (pin_names (hide yes))"
if (sg.is_power or (sg.ref_prefix or "").upper() in _PASSIVE_PREFIXES)
else ""
)
out.append(f' (symbol "{name}"{hide} (in_bom yes) (on_board yes){power}\n')
out.append(
f' (property "Reference" "{ref}" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27))))\n"
)
out.append(
f' (property "Value" "{name}" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27))))\n"
)
out.append(
f' (property "Footprint" "" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27)) hide))\n"
)
out.append(
f' (property "Datasheet" "" (at 0 0 0)'
f"\n (effects (font (size 1.27 1.27)) hide))\n"
)
# ── Drawing body ──────────────────────────────────────────────
out.append(f' (symbol "{_escape(leaf)}_0_1"\n')
for x1, y1, x2, y2, curve in sg.wires:
if curve is not None:
for sx, sy, ex, ey in _arc_segments(x1, y1, x2, y2, curve):
out.append(
f" (polyline (pts (xy {_fmt(sx)} {_fmt(sy)})"
f" (xy {_fmt(ex)} {_fmt(ey)}))"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
else:
out.append(
f" (polyline (pts (xy {_fmt(x1)} {_fmt(y1)})"
f" (xy {_fmt(x2)} {_fmt(y2)}))"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
for cx, cy, r in sg.circles:
out.append(
f" (circle (center {_fmt(cx)} {_fmt(cy)}) (radius {_fmt(r)})"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
for x1, y1, x2, y2 in sg.rectangles:
out.append(
f" (rectangle (start {_fmt(x1)} {_fmt(y1)})"
f" (end {_fmt(x2)} {_fmt(y2)})"
f" (stroke (width 0) (type default)) (fill (type none)))\n"
)
for verts in sg.polygons:
pts = " ".join(f"(xy {_fmt(x)} {_fmt(y)})" for x, y in verts)
pts += f" (xy {_fmt(verts[0][0])} {_fmt(verts[0][1])})"
out.append(
f" (polyline (pts {pts})"
f" (stroke (width 0) (type default)) (fill (type outline)))\n"
)
# Visible pin leads (pins themselves are emitted with length 0 below).
for pin in sg.pins:
out.append(_pin_lead(pin, " "))
out.append(" )\n")
# ── Pins ─────────────────────────────────────────────────────
out.append(f' (symbol "{_escape(leaf)}_1_1"\n')
for idx, pin in enumerate(sg.pins):
ptype = _PIN_TYPE.get(pin.direction.lower(), "bidirectional")
number = sg.pin_number(pin.name, idx)
# Snap the length-0 connection point to the schematic grid so library
# pins stay grid-aligned (matches the schematic's embedded symbols).
spx = round(pin.x / _GRID_MM) * _GRID_MM
spy = round(pin.y / _GRID_MM) * _GRID_MM
out.append(
f" (pin {ptype} line"
f" (at {_fmt(spx)} {_fmt(spy)} {int(pin.angle % 360)})"
f" (length 0)\n"
f' (name "{_escape(pin.name)}"'
f" (effects (font (size 1.016 1.016))))\n"
f' (number "{_escape(number)}"'
f" (effects (font (size 1.016 1.016)))))\n"
)
out.append(" )\n")
out.append(" )\n")
out.append(")\n")
os.makedirs(os.path.dirname(os.path.abspath(lib_path)), exist_ok=True)
with open(lib_path, "w", encoding="utf-8", newline="\n") as fh:
fh.writelines(out)
def generate_sym_lib_table(project_dir: str, lib_filename: str) -> None:
"""
Write a project-level sym-lib-table so KiCad can resolve
'eagle_import:SYMBOLNAME' lib-ids from the local .kicad_sym file.
"""
table_path = os.path.join(project_dir, "sym-lib-table")
content = (
"(sym_lib_table\n"
f' (lib (name "eagle_import") (type "KiCad")'
f' (uri "${{KIPRJMOD}}/{lib_filename}")'
f' (options "") (descr "Eagle imported symbols"))\n'
")\n"
)
with open(table_path, "w", encoding="utf-8", newline="\n") as fh:
fh.write(content)
# ── KiCad schematic generator ──────────────────────────────────────────────────
def generate_kicad_sch(
parts: Dict[str, _EaglePart],
instances: List[_EagleInst],
net_wires: List[Tuple[str, float, float, float, float]],
net_labels: List[Tuple[str, float, float, float]],
junctions: List[Tuple[float, float]],
output_path: str,
) -> Tuple[str, int]:
"""
Write a .kicad_sch from parsed Eagle data.
Returns (schematic UUID, dangling wire segment count after trimming).
"""
# Y offset: flip Eagle Y (Y-up) to KiCad Y (Y-down). Snap the base to the
# grid so that everything on-grid in Eagle also lands on KiCad's grid.
all_ys = [inst.y for inst in instances]
y_off = round(((max(all_ys) if all_ys else 200.0) + 20.0) / _GRID_MM) * _GRID_MM
def ky(y: float) -> float:
"""Eagle schematic Y → KiCad schematic Y."""
return y_off - y
# ── Collect the lib_symbols we need (from every gate instance) ──────
needed: Dict[str, _EagleSymGeom] = {}
for inst in instances:
sg = inst.sym_geom
if sg is not None and sg.kicad_id not in needed:
needed[sg.kicad_id] = sg
# ── Detect multi-gate parts and build combined multi-unit symbols ──
from collections import defaultdict
part_gate_insts: Dict[str, List["_EagleInst"]] = defaultdict(list)
for inst in instances:
part = inst.part_ref
if part is not None and not part.is_frame and inst.sym_geom is not None:
part_gate_insts[part.name].append(inst)
inst_unit: Dict[int, int] = {} # id(inst) → unit number (1-based)
# part_name → (combined_kicad_id, ref_prefix, is_power, value, [(unit, sg, xform)])
inst_combined: Dict[
str,
Tuple[
str,
str,
bool,
str,
List[
Tuple[
int, "_EagleSymGeom", "Optional[Callable[[float, float], Tuple[float, float]]]"
]
],
],
] = {}
multi_gate_sgs: "set[str]" = set() # kicad_ids subsumed by a multi-unit symbol
for part_name, insts in part_gate_insts.items():
if len(insts) <= 1:
continue
first = insts[0]
sg0 = first.sym_geom
base_id = f"eagle_import:{_sanitize(part_name)}"
used_ids = set(needed.keys()) | {cid for cid, _, _, _, _ in inst_combined.values()}
combined_id = base_id
n = 0
while combined_id in used_ids:
n += 1
combined_id = f"{base_id}_{n}"
units: List[Tuple[int, "_EagleSymGeom", "Optional[Callable]"]] = []
for i, inst in enumerate(insts, 1):
inst_unit[id(inst)] = i
multi_gate_sgs.add(inst.sym_geom.kicad_id)
if inst.mirror:
krot = _krot(inst.angle, inst.mirror)
xform = _bake_xform(krot)
else:
xform = None
units.append((i, inst.sym_geom, xform))
inst_combined[part_name] = (
combined_id,
sg0.ref_prefix or "U",
bool(first.part_ref.is_power),
first.part_ref.value or sg0.name,
units,
)
# ── Pre-pass: content bounding box (symbol geometry + wires/labels) ──
bxs: List[float] = []
bys: List[float] = []
for inst in instances:
part = inst.part_ref
if part is None or part.is_frame or inst.sym_geom is None:
continue
krot = _krot(inst.angle, inst.mirror)
ox0, oy0 = inst.x, ky(inst.y)
for lx, ly in _symbol_local_points(inst.sym_geom):
dx, dy = _render_local(lx, ly, krot, inst.mirror)
bxs.append(ox0 + dx)
bys.append(oy0 + dy)
for _n, x1, y1, x2, y2 in net_wires:
bxs += [x1, x2]
bys += [ky(y1), ky(y2)]
for jx, jy in junctions:
bxs.append(jx)
bys.append(ky(jy))
for _n, lx, ly, _a in net_labels:
bxs.append(lx)
bys.append(ky(ly))
if bxs:
min_x, max_x = min(bxs), max(bxs)
min_y, max_y = min(bys), max(bys)
else:
min_x = max_x = min_y = max_y = 0.0
content_w, content_h = max_x - min_x, max_y - min_y
# Pick a sheet that fits, then centre the content on it (grid-snapped).
paper, pw, ph = _choose_paper(content_w, content_h)
ox = round((pw / 2.0 - (min_x + max_x) / 2.0) / _GRID_MM) * _GRID_MM
oy = round((ph / 2.0 - (min_y + max_y) / 2.0) / _GRID_MM) * _GRID_MM
def px(x: float) -> float:
"""Final KiCad X: apply centring offset, snapped to the schematic grid.
Some Eagle sources place parts (and their wires) off the 1.27 mm grid.
Snapping every emitted coordinate to the grid is safe for connectivity:
rounding is invariant under integer-grid shifts, and every symbol's pin
offsets are whole grid multiples, so a pin and the wire endpoint that
touched it (identical Eagle coordinates) always snap to the same grid
node and stay electrically connected.
"""
return round((x + ox) / _GRID_MM) * _GRID_MM
def py(y_kicad: float) -> float:
"""Final KiCad Y (input already Y-flipped): centring offset + grid snap."""
return round((y_kicad + oy) / _GRID_MM) * _GRID_MM
# ── Write schematic ─────────────────────────────────────────────────
sch_uuid = _uid()
out: List[str] = []
out.append(
'(kicad_sch (version 20260101) (generator "eeschema")' ' (generator_version "10.0")\n\n'
)
out.append(f" (uuid {sch_uuid})\n\n")
out.append(f' (paper "{paper}")\n\n')
# lib_symbols section
out.append(" (lib_symbols\n")
for sg in needed.values():
if sg.kicad_id not in multi_gate_sgs:
out.append(_gen_lib_symbol(sg))
# Combined multi-unit symbols for multi-gate parts
for _part_name, (combined_id, ref_prefix, is_power, value, units) in inst_combined.items():
out.append(_gen_multi_unit_lib_symbol(combined_id, ref_prefix, is_power, value, units))
# Baked (rotation+mirror) variants for mirrored instances. KiCad cannot
# electrically connect wires to pins on mirrored symbols, so mirrored
# placements reference a geometry-baked variant placed with an identity
# instance transform (at x y 0, no mirror) instead.
# Mirrored multi-gate parts already bake the transform into their unit
# sub-symbols — skip those individual geometries here.
baked_needed: Dict[str, Tuple[_EagleSymGeom, int]] = {}
for inst in instances:
if inst.mirror and inst.sym_geom is not None:
# Multi-gate mirrored instances are baked into the combined symbol
if id(inst) in inst_unit:
continue
krot = _krot(inst.angle, inst.mirror)
bid = _baked_kicad_id(inst.sym_geom, krot)
if bid not in baked_needed:
baked_needed[bid] = (inst.sym_geom, krot)
for bid, (sg, krot) in baked_needed.items():
out.append(_gen_lib_symbol(sg, xform=_bake_xform(krot), name_id=bid))
out.append(" )\n\n")
# Component placements — one KiCad symbol per Eagle gate instance
pin_world_pts: List[Tuple[float, float]] = [] # every pin's connection point
pwr_counter = 0
for inst in instances:
part = inst.part_ref
if part is None or part.is_frame or inst.sym_geom is None:
continue
sg = inst.sym_geom
kx = px(inst.x)
kycmp = py(ky(inst.y))
krot = _krot(inst.angle, inst.mirror)
# Multi-gate parts: reference the combined multi-unit symbol, with
# mirror transforms already baked into the unit sub-symbol geometry.
unit_num = inst_unit.get(id(inst), 1)
if id(inst) in inst_unit:
lib_id_use = inst_combined[part.name][0]
place_rot = krot
mirror_field = ""
if inst.mirror:
# Geometry is baked; place with identity rotation
place_rot = 0
elif inst.mirror:
# Single-gate mirrored instances reference a geometry-baked variant
# and are placed with an identity transform so wires can connect.
lib_id_use = _baked_kicad_id(sg, krot)
place_rot = 0
else:
lib_id_use = sg.kicad_id
place_rot = krot
mirror_field = ""
# Rendered bounding box of this instance (delta from its origin) so the
# Reference/Value labels can be placed clear of the symbol body instead
# of a fixed origin offset that lands inside large/rotated symbols.
_pts = [_render_local(lx, ly, krot, inst.mirror) for lx, ly in _symbol_local_points(sg)]
if _pts:
_dxs = [p[0] for p in _pts]
_dys = [p[1] for p in _pts]
bb_min_x, bb_max_x = min(_dxs), max(_dxs)
bb_top, bb_bot = min(_dys), max(_dys)
else:
bb_min_x, bb_max_x = -1.27, 1.27
bb_top, bb_bot = -1.27, 1.27
bb_cx = (bb_min_x + bb_max_x) / 2.0
bb_cy = (bb_top + bb_bot) / 2.0
# Two-terminal R/L/C and diodes/LEDs drawn vertically: the top/bottom of
# the body are the pin ends (with wires attached), so put the labels to the
# SIDES instead — Reference on the left, Value on the right — off the wires.
# Use the actual designator prefix (what the user sees) so diodes whose
# deviceset prefix is generic (e.g. "U") are still recognised.
_refm = re.match(r"[A-Za-z]+", part.name or "")
_desig_pfx = _refm.group(0).upper() if _refm else (sg.ref_prefix or "").upper()
is_passive = _desig_pfx in _VERT_LABEL_PREFIXES
# "Vertical" is decided by the actual pin geometry (pins stacked
# top/bottom), which is exactly what makes an R/L/C look vertical, rather
# than the symbol's bounding-box aspect (electrolytic caps etc. can have a
# wide body yet vertical pins). The pin midpoint is the true body centre,
# so we centre the rotated labels on it even for asymmetric symbols.
_pin_r = [_render_local(pp.x, pp.y, krot, inst.mirror) for pp in sg.pins]
if len(_pin_r) >= 2:
_pxs = [p[0] for p in _pin_r]
_pys = [p[1] for p in _pin_r]
vertical = (max(_pys) - min(_pys)) > (max(_pxs) - min(_pxs))
pin_cx = (min(_pxs) + max(_pxs)) / 2.0
pin_cy = (min(_pys) + max(_pys)) / 2.0
else:
vertical = (bb_bot - bb_top) > (bb_max_x - bb_min_x)
pin_cx, pin_cy = bb_cx, bb_cy
ref_just = ""
val_just = ""
ref_rot = 0
val_rot = 0
if is_passive and vertical:
# Vertical R/L/C: rotate the Reference/Value text 90° so it runs
# alongside the body (KiCad's own convention), Reference on the left
# and Value on the right, both centred on the component body.
#
# KiCad ADDS the symbol instance's placement rotation to a property's
# text angle, so to make the text land vertical on the sheet the stored
# angle must compensate for place_rot (e.g. an instance placed at 90°
# needs a stored 0° to display vertically). Mirror-baked variants are
# placed at place_rot 0, so they simply store 90°.
ref_px = px(inst.x + bb_min_x - 1.27)
ref_py = py(ky(inst.y) + pin_cy)
val_px = px(inst.x + bb_max_x + 1.27)
val_py = py(ky(inst.y) + pin_cy)
ref_rot = (90 - place_rot) % 180
val_rot = (90 - place_rot) % 180
else:
ref_px = px(inst.x + bb_cx)
ref_py = py(ky(inst.y) + bb_top - 1.27)
val_px = px(inst.x + bb_cx)
val_py = py(ky(inst.y) + bb_bot + 1.27)
if part.is_power:
pwr_counter += 1
ref_val = f"#PWR{pwr_counter:04d}"
in_bom, on_board = "no", "no"
else:
ref_val = part.name
in_bom, on_board = "yes", "yes"
val_str = part.value or sg.name
out.append(
f' (symbol (lib_id "{_escape(lib_id_use)}")'
f" (at {_fmt(kx)} {_fmt(kycmp)} {place_rot}){mirror_field} (unit {unit_num})\n"
f" (in_bom {in_bom}) (on_board {on_board}) (dnp no)"
f" (uuid {_uid()})\n"
)
# Power-symbol references (#PWR…) are auto-generated and carry no useful
# information, so keep them hidden like KiCad's own power symbols do.
ref_hide = " hide" if part.is_power else ""
out.append(
f' (property "Reference" "{_escape(ref_val)}"'
f" (at {_fmt(ref_px)} {_fmt(ref_py)} {ref_rot})\n"
f" (effects (font (size 1.27 1.27)){ref_just}{ref_hide}))\n"
)
out.append(
f' (property "Value" "{_escape(val_str)}"'
f" (at {_fmt(val_px)} {_fmt(val_py)} {val_rot})\n"
f" (effects (font (size 1.27 1.27)){val_just}))\n"
)
out.append(
f' (property "Footprint" ""'
f" (at {_fmt(kx)} {_fmt(kycmp)} 0)\n"
f" (effects (font (size 1.27 1.27)) hide))\n"
)
out.append(
f' (property "Datasheet" "~"'
f" (at {_fmt(kx)} {_fmt(kycmp)} 0)\n"
f" (effects (font (size 1.27 1.27)) hide))\n"
)
# Pin UUID entries (KiCad references pins here by NUMBER, not name)
for idx, pin in enumerate(sg.pins):
number = sg.pin_number(pin.name, idx)
out.append(f' (pin "{_escape(number)}" (uuid {_uid()}))\n')
out.append(" )\n\n")
# Record each pin's electrical connection point in KiCad space (matches
# the length-0 pin emitted in the lib symbol), used to trim dangling wire
# stubs later. Mirrored instances use the geometry-baked variant.
_bx = _bake_xform(krot) if inst.mirror else None
_pa = math.radians(place_rot)
_ca, _sa = math.cos(_pa), math.sin(_pa)
for pin in sg.pins:
_lx, _ly = _bx(pin.x, pin.y) if _bx else (pin.x, pin.y)
_lx, _ly = _gridsnap(_lx), _gridsnap(_ly)
_wx = kx + (_lx * _ca - _ly * _sa)
_wy = kycmp - (_lx * _sa + _ly * _ca)
pin_world_pts.append((round(_wx, 3), round(_wy, 3)))
# ── Net labels: move each label ONTO its net's wire ─────────────────
# Eagle attaches a label to a net by segment membership, so the label anchor
# may float a grid step or two off the actual wire. KiCad only treats a label
# as connected when its anchor lies exactly on a wire/pin, so the label is
# *moved onto* the wire (in Eagle space, before the page transform, so it stays
# grid-aligned) — the wire is never moved to the label. We prefer the wire's
# nearest free end (a clean terminus) and rotate/justify the text to run
# outward from the wire so it never sits on top of it.
_wires_by_net: Dict[str, List[Tuple[float, float, float, float]]] = {}
for _nn, _x1, _y1, _x2, _y2 in net_wires:
_wires_by_net.setdefault(_nn, []).append((_x1, _y1, _x2, _y2))
# Per-net free ends (degree-1 endpoints) with the neighbour they connect to.
_free_ends_by_net: Dict[str, List[Tuple[Tuple[float, float], Tuple[float, float]]]] = {}
for _nn, _segs in _wires_by_net.items():
_deg: Dict[Tuple[float, float], int] = {}
_nbr: Dict[Tuple[float, float], Tuple[float, float]] = {}
for _x1, _y1, _x2, _y2 in _segs:
_ea = (float(round(_x1, 4)), float(round(_y1, 4)))
_eb = (float(round(_x2, 4)), float(round(_y2, 4)))
_deg[_ea] = _deg.get(_ea, 0) + 1
_deg[_eb] = _deg.get(_eb, 0) + 1
_nbr[_ea] = _eb
_nbr[_eb] = _ea
_free_ends_by_net[_nn] = [(p, _nbr[p]) for p, d in _deg.items() if d == 1]
def _snap_to_net(name: str, lx: float, ly: float):
"""Snap a label onto its net's wire.
Returns (sx, sy, dx, dy) in Eagle space, where (dx, dy) is the direction
the incident wire leaves the snap point (0,0 if unknown).
"""
segs = _wires_by_net.get(name)
if not segs:
return lx, ly, 0.0, 0.0
# 1) Prefer the nearest free end (clean terminus, kills the overshoot stub).
best_fe = None
for (ex, ey), (ox, oy) in _free_ends_by_net.get(name, []):
d2 = (lx - ex) ** 2 + (ly - ey) ** 2
if best_fe is None or d2 < best_fe[0]:
best_fe = (d2, ex, ey, ox, oy)
if best_fe is not None and best_fe[0] <= (_LABEL_FE_STEPS * _GRID_MM) ** 2:
_, ex, ey, ox, oy = best_fe
return ex, ey, ox - ex, oy - ey
# 2) Otherwise project onto the nearest wire segment of the same net.
best = None
for x1, y1, x2, y2 in segs:
dx, dy = x2 - x1, y2 - y1
seg_len2 = dx * dx + dy * dy
if seg_len2 == 0:
qx, qy = x1, y1
else:
t = ((lx - x1) * dx + (ly - y1) * dy) / seg_len2
t = max(0.0, min(1.0, t))
qx, qy = x1 + t * dx, y1 + t * dy
d2 = (lx - qx) ** 2 + (ly - qy) ** 2
if best is None or d2 < best[0]:
best = (d2, qx, qy, dx, dy)
if best is not None and best[0] <= (_LABEL_SEG_STEPS * _GRID_MM) ** 2:
_, qx, qy, dx, dy = best
return qx, qy, dx, dy
return lx, ly, 0.0, 0.0
# Final label placement in KiCad space, with outward-pointing justification.
snapped_labels: List[Tuple[str, float, float, int, str]] = []
for net_name, lx, ly, angle in net_labels:
slx, sly, dxe, dye = _snap_to_net(net_name, lx, ly)
kdx, kdy = dxe, -dye # wire direction in KiCad space
# A label has two justify axes. The vertical axis is always "bottom"; the
# horizontal axis follows the wire so the text always reads *outward* from
# it (anchored at the end nearest the wire).
if kdx == 0 and kdy == 0:
rot, hjust = _krot(angle), "left"
elif abs(kdx) >= abs(kdy): # horizontal wire → label rot 0
rot = 0
hjust = "right" if kdx > 0 else "left"
else: # vertical wire → label rot 90
rot = 90
hjust = "right" if kdy < 0 else "left"
just = f"{hjust} bottom"
snapped_labels.append((net_name, px(slx), py(ky(sly)), rot, just))
# ── Connection points + dangling-wire trimming ──────────────────────
# Labels ARE trim anchors: a wire that legitimately terminates at a net label
# (e.g. pin → wire → label) must be kept. Because each label now sits exactly
# on the wire, the wire is only ever *shortened* to the label, never grown.
conn_pts = set()
for _p in pin_world_pts:
conn_pts.add((round(_p[0], 3), round(_p[1], 3)))
for _nn, _lx, _ly, _r, _j in snapped_labels:
conn_pts.add((round(_lx, 3), round(_ly, 3)))
junction_pts = [(px(jx), py(ky(jy))) for jx, jy in junctions]
for _jx, _jy in junction_pts:
conn_pts.add((round(_jx, 3), round(_jy, 3)))
kicad_wires = [
(px(x1), py(ky(y1)), px(x2), py(ky(y2))) for _net_name, x1, y1, x2, y2 in net_wires
]
kicad_wires = _snap_wire_endpoints(kicad_wires, conn_pts)
kicad_wires = _trim_wires(kicad_wires, conn_pts)
kicad_wires = _prune_dangling_wires(kicad_wires, conn_pts)
dangling_wires = _count_dangling_wires(kicad_wires, conn_pts)
# Net wires (trimmed)
for x1, y1, x2, y2 in kicad_wires:
out.append(
f" (wire (pts (xy {_fmt(x1)} {_fmt(y1)})"
f" (xy {_fmt(x2)} {_fmt(y2)}))\n"
f" (stroke (width 0) (type default)) (uuid {_uid()}))\n"
)
# Junctions
for jx, jy in junction_pts:
out.append(
f" (junction (at {_fmt(jx)} {_fmt(jy)})"
f" (diameter 0) (color 0 0 0 0) (uuid {_uid()}))\n"
)
# Net labels (moved onto the wire, justified so the text runs off it)
for net_name, lxk, lyk, lrot, ljust in snapped_labels:
just_s = f" (justify {ljust})" if ljust else ""
out.append(
f' (label "{_escape(net_name)}" (at {_fmt(lxk)} {_fmt(lyk)} {lrot})\n'
f" (effects (font (size 1.016 1.016)){just_s}) (uuid {_uid()}))\n"
)
out.append(' (sheet_instances\n (path "/" (page "1"))\n )\n')
out.append(")\n")
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
with open(output_path, "w", encoding="utf-8", newline="\n") as fh:
fh.writelines(out)
return sch_uuid, dangling_wires
# ── EagleCommands (MCP handler) ────────────────────────────────────────────────
class EagleCommands:
"""Handles Eagle-related MCP commands."""
def __init__(self) -> None:
from utils.kicad_cli import resolve_kicad_cli
self._kicad_cli = resolve_kicad_cli()
# ── Public command ─────────────────────────────────────────────────────────
def import_eagle_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert an Eagle project to a KiCad project.
Params
------
board_file : path to Eagle .brd file (required)
schematic_file : path to Eagle .sch file (optional; auto-detected from .brd)
output_dir : destination directory (optional; defaults beside .brd)
project_name : project name (optional; defaults to .brd stem)
"""
board_file: Optional[str] = params.get("board_file")
sch_file: Optional[str] = params.get("schematic_file") or params.get("sch_file")
output_dir: Optional[str] = params.get("output_dir")
project_name: Optional[str] = params.get("project_name")
if not board_file:
return {"success": False, "error": "board_file is required"}
if not os.path.exists(board_file):
return {"success": False, "error": f"board_file not found: {board_file}"}
brd_base = os.path.splitext(os.path.basename(board_file))[0]
project_name = project_name or brd_base
safe_name = re.sub(r'[<>:"/\\|?* ]', "_", project_name)
if not sch_file:
candidate = os.path.splitext(board_file)[0] + ".sch"
if os.path.exists(candidate):
sch_file = candidate
if not output_dir:
output_dir = os.path.join(os.path.dirname(os.path.abspath(board_file)), safe_name)
os.makedirs(output_dir, exist_ok=True)
results: Dict[str, Any] = {
"success": False,
"project_name": safe_name,
"output_dir": output_dir,
}
# 1. Import PCB
pcb_out = os.path.join(output_dir, safe_name + ".kicad_pcb")
pcb_ok, pcb_msg = self._import_pcb(board_file, pcb_out)
results["pcb_path"] = pcb_out if pcb_ok else None
results["pcb_message"] = pcb_msg
# 2. Import schematic
sch_out = os.path.join(output_dir, safe_name + ".kicad_sch")
sch_ok = False
sch_msg = "No .sch file found"
sch_uuid = None
sch_dangling = 0
if sch_file and os.path.exists(sch_file):
sch_ok, sch_msg, sch_uuid, sch_dangling = self._import_schematic(sch_file, sch_out)
results["sch_path"] = sch_out if sch_ok else None
results["sch_message"] = sch_msg
results["dangling_wires"] = sch_dangling
if sch_dangling:
results.setdefault("warnings", []).append(
f"{sch_dangling} dangling wire segment(s) remain after trim — review ERC"
)
# 2b. Ground-truth ERC via kicad-cli. dangling_wires above counts against
# the importer's own connection model, which can accept endpoints (label
# anchors, synthesized pin points) that eeschema does not — so it can
# report 0 while KiCad's ERC still flags wire_dangling errors. Running
# real ERC on the output gives the caller the number KiCad will show,
# not the number the importer hopes for.
if sch_ok:
erc = self._erc_check(sch_out)
results["erc"] = erc if erc is not None else {"ran": False}
if erc and erc.get("wire_dangling"):
results.setdefault("warnings", []).append(
f"KiCad ERC reports {erc['wire_dangling']} dangling wire(s) on the "
"imported schematic — review before trusting connectivity"
)
# 3. Create .kicad_pro (with correct UUID so KiCad links it to the .kicad_sch)
pro_path = os.path.join(output_dir, safe_name + ".kicad_pro")
self._create_project_file(pro_path, safe_name, sch_uuid)
results["project_path"] = pro_path
results["success"] = pcb_ok or sch_ok
results["message"] = (
f"PCB: {'OK' if pcb_ok else 'FAILED'}; "
f"Schematic: {'OK' if sch_ok else 'FAILED/SKIPPED'}"
+ (f"; dangling wires: {sch_dangling}" if sch_dangling else "")
)
logger.info("Eagle import done: %s", results["message"])
return results
# ── Private helpers ────────────────────────────────────────────────────────
def _erc_check(self, sch_path: str) -> Optional[Dict[str, Any]]:
"""Run kicad-cli sch erc on the generated schematic and parse the report.
Returns {"ran": True, "errors": E, "warnings": W, "wire_dangling": D}
or None when kicad-cli is unavailable or the run/parse fails (the
import result then carries {"ran": False} so callers can tell the
difference between "clean" and "unverified").
"""
cli = self._kicad_cli
if not cli:
return None
report_path = sch_path + ".erc.rpt"
try:
result = subprocess.run(
[cli, "sch", "erc", sch_path, "-o", report_path],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0 or not os.path.exists(report_path):
return None
with open(report_path, "r", encoding="utf-8", errors="replace") as fh:
report = fh.read()
summary = re.search(r"ERC messages:\s*\d+\s+Errors\s+(\d+)\s+Warnings\s+(\d+)", report)
if not summary:
return None
return {
"ran": True,
"errors": int(summary.group(1)),
"warnings": int(summary.group(2)),
"wire_dangling": report.count("[wire_dangling]"),
}
except Exception as e: # noqa: BLE001 - ERC is advisory, never fail the import
logger.warning("Post-import ERC check skipped: %s", e)
return None
finally:
try:
if os.path.exists(report_path):
os.remove(report_path)
except OSError:
pass
def _import_pcb(self, brd_file: str, pcb_out: str) -> Tuple[bool, str]:
from utils.kicad_cli import kicad_cli_not_found_message
cli = self._kicad_cli
if not cli:
return False, kicad_cli_not_found_message()
try:
result = subprocess.run(
[cli, "pcb", "import", "--format", "eagle", "--output", pcb_out, brd_file],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0 and os.path.exists(pcb_out):
return True, "PCB imported successfully"
return False, result.stderr or result.stdout or "kicad-cli non-zero"
except Exception as e:
return False, str(e)
def _import_schematic(
self, sch_file: str, sch_out: str
) -> Tuple[bool, str, Optional[str], int]:
try:
parts, instances, net_wires, net_labels, junctions = parse_eagle_schematic(sch_file)
sch_uuid, dangling_wires = generate_kicad_sch(
parts, instances, net_wires, net_labels, junctions, sch_out
)
if not (os.path.exists(sch_out) and os.path.getsize(sch_out) > 50):
return False, "Output file too small", None, 0
# ── Generate .kicad_sym library ──────────────────────────────────
proj_dir = os.path.dirname(sch_out)
proj_stem = os.path.splitext(os.path.basename(sch_out))[0]
lib_filename = f"{proj_stem}_eagle.kicad_sym"
lib_path = os.path.join(proj_dir, lib_filename)
# Collect unique symbol geometries used by any gate instance
unique_syms: Dict[str, "_EagleSymGeom"] = {}
for inst in instances:
sg = inst.sym_geom
if sg is not None and sg.kicad_id not in unique_syms:
unique_syms[sg.kicad_id] = sg
generate_sym_lib(list(unique_syms.values()), lib_path)
generate_sym_lib_table(proj_dir, lib_filename)
logger.info("Symbol library written: %s", lib_path)
# ── Canonical KiCad schematic formatting ─────────────────────────
try:
from utils.sexpr_format import prettify
text = Path(sch_out).read_text(encoding="utf-8")
with Path(sch_out).open("w", encoding="utf-8", newline="\n") as f:
f.write(prettify(text))
logger.info("Prettified schematic: %s", sch_out)
except Exception as e:
logger.warning("Schematic prettify skipped: %s", e)
n_comp = sum(
1
for inst in instances
if inst.part_ref and not inst.part_ref.is_frame and inst.sym_geom
)
n_nets = len({w[0] for w in net_wires})
msg = (
f"Schematic converted ({n_comp} placements, "
f"{n_nets} nets, {len(junctions)} junctions,"
f" {len(unique_syms)} unique symbols"
f"{f', {dangling_wires} dangling wires' if dangling_wires else ''})"
)
return True, msg, sch_uuid, dangling_wires
except Exception:
logger.exception("Eagle schematic import failed")
return False, "Conversion error (see log)", None, 0
@staticmethod
def _create_project_file(pro_path: str, name: str, sch_uuid: Optional[str] = None) -> None:
from utils.kicad_project import write_kicad_pro
write_kicad_pro(pro_path, sch_uuid)
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.