Files
kicad-mcp-server/python/commands/svg_import.py
Eugene Mikhantyev 9b1024a8f3 chore: enable strict mypy checks and fix pre-commit mypy hook
Add type annotations to all previously untyped functions and remove 9
suppressed error codes (call-arg, assignment, return-value, operator,
has-type, dict-item, misc, list-item, annotation-unchecked) by fixing
the underlying type issues.

Add [[tool.mypy.overrides]] with ignore_missing_imports for KiCAD-specific
modules (pcbnew, sexpdata, skip, cairosvg, kipy, PIL) so the pre-commit
mypy hook passes in its isolated venv. Add types-requests and pytest to
additional_dependencies in .pre-commit-config.yaml.

Also fixes several real bugs uncovered by stricter checks: incorrect static
calls to instance methods in swig_backend, wrong return type on get_size,
missing value param in BoardAPI.place_component, variable shadowing in
kicad_process.py, unqualified LibraryManager reference in kicad_interface,
and missing top-level Path import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:50:54 +01:00

640 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
SVG Logo Import for KiCAD PCB
Converts an SVG file into KiCAD PCB graphic polygons (gr_poly) on the silkscreen
or any other given layer. Uses only Python standard library (xml, re, math).
No external dependencies required.
Supported SVG elements:
<path d="..."> M L H V Z C S Q T A commands (curves are linearised)
<rect> → 4-point polygon
<circle> → N-gon approximation
<polygon> / <polyline> → direct point list
<g> with transform → nested group transforms are applied
SVG coordinate system: Y increases downward (same as KiCAD mm), so no Y-flip needed.
"""
import logging
import math
import os
import re
import uuid
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
# ---------------------------------------------------------------------------
# Type aliases
# ---------------------------------------------------------------------------
Point = Tuple[float, float]
Polygon = List[Point]
# ---------------------------------------------------------------------------
# SVG path tokenizer
# ---------------------------------------------------------------------------
_TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
def _tokenize_path(d: str) -> List[str]:
tokens = []
for tok, num in _TOKEN_RE.findall(d):
if tok:
tokens.append(tok)
elif num:
tokens.append(num)
return tokens
def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
"""
Parse SVG path tokens into a list of closed and open subpaths.
Curves are linearised with ~0.5 mm step tolerance.
Returns a list of point-lists (each is a subpath/polygon).
"""
polygons: List[Polygon] = []
current: Polygon = []
cx, cy = 0.0, 0.0 # current point
sx, sy = 0.0, 0.0 # subpath start
last_ctrl = None # last bezier control point (for S/T commands)
last_cmd = ""
i = 0
cmd = "M"
num_tokens = []
# --- helpers ---
def consume(n: int) -> List[float]:
nonlocal i
vals = [float(tokens[i + k]) for k in range(n)]
i += n
return vals
def cubic_bezier_points(
p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16
) -> List[Point]:
pts = []
for k in range(1, steps + 1):
t = k / steps
mt = 1 - t
x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0]
y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1]
pts.append((x, y))
return pts
def quad_bezier_points(p0: Point, p1: Point, p2: Point, steps: int = 12) -> List[Point]:
pts = []
for k in range(1, steps + 1):
t = k / steps
mt = 1 - t
x = mt**2 * p0[0] + 2 * mt * t * p1[0] + t**2 * p2[0]
y = mt**2 * p0[1] + 2 * mt * t * p1[1] + t**2 * p2[1]
pts.append((x, y))
return pts
def arc_points(
x1: float,
y1: float,
rx: float,
ry: float,
phi_deg: float,
large_arc: int,
sweep: int,
x2: float,
y2: float,
steps: int = 20,
) -> List[Point]:
"""Approximate SVG arc as polygon points (endpoint parameterization → centre)."""
if rx == 0 or ry == 0:
return [(x2, y2)]
phi = math.radians(phi_deg)
cos_phi, sin_phi = math.cos(phi), math.sin(phi)
dx, dy = (x1 - x2) / 2, (y1 - y2) / 2
x1p = cos_phi * dx + sin_phi * dy
y1p = -sin_phi * dx + cos_phi * dy
rx, ry = abs(rx), abs(ry)
lam = (x1p / rx) ** 2 + (y1p / ry) ** 2
if lam > 1:
lam = math.sqrt(lam)
rx *= lam
ry *= lam
num = max(0.0, (rx * ry) ** 2 - (rx * y1p) ** 2 - (ry * x1p) ** 2)
den = (rx * y1p) ** 2 + (ry * x1p) ** 2
sq = math.sqrt(num / den) if den != 0 else 0
if large_arc == sweep:
sq = -sq
cxp = sq * rx * y1p / ry
cyp = -sq * ry * x1p / rx
cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
def angle(ux: float, uy: float, vx: float, vy: float) -> float:
a = math.acos(
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
)
if ux * vy - uy * vx < 0:
a = -a
return a
theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
dtheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry)
if not sweep and dtheta > 0:
dtheta -= 2 * math.pi
elif sweep and dtheta < 0:
dtheta += 2 * math.pi
pts = []
for k in range(1, steps + 1):
t = k / steps
angle_ = theta1 + t * dtheta
x_ = cos_phi * rx * math.cos(angle_) - sin_phi * ry * math.sin(angle_) + cx_
y_ = sin_phi * rx * math.cos(angle_) + cos_phi * ry * math.sin(angle_) + cy_
pts.append((x_, y_))
return pts
# --- main loop ---
while i < len(tokens):
tok = tokens[i]
if tok.lstrip("+-").replace(".", "", 1).replace("e", "", 1).replace("E", "", 1).lstrip(
"+-"
).isdigit() or re.match(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", tok):
# implicit repeat of last command
pass
else:
cmd = tok
i += 1
last_ctrl = None # reset smooth control on new command letter
rel = cmd.islower()
if cmd in ("M", "m"):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
else:
cx, cy = x, y
if current:
polygons.append(current)
current = [(cx, cy)]
sx, sy = cx, cy
# subsequent coordinates are implicit L/l
cmd = "l" if rel else "L"
elif cmd in ("L", "l"):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
else:
cx, cy = x, y
current.append((cx, cy))
elif cmd in ("H", "h"):
x = float(tokens[i])
i += 1
cx = cx + x if rel else x
current.append((cx, cy))
elif cmd in ("V", "v"):
y = float(tokens[i])
i += 1
cy = cy + y if rel else y
current.append((cx, cy))
elif cmd in ("Z", "z"):
current.append((sx, sy)) # close
polygons.append(current)
current = []
cx, cy = sx, sy
elif cmd in ("C", "c"):
x1, y1, x2, y2, x, y = consume(6)
if rel:
x1 += cx
y1 += cy
x2 += cx
y2 += cy
x += cx
y += cy
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
current.extend(pts)
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ("S", "s"):
x2, y2, x, y = consume(4)
if rel:
x2 += cx
y2 += cy
x += cx
y += cy
if last_ctrl and last_cmd in ("C", "c", "S", "s"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
x1, y1 = cx, cy
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
current.extend(pts)
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ("Q", "q"):
x1, y1, x, y = consume(4)
if rel:
x1 += cx
y1 += cy
x += cx
y += cy
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
current.extend(pts)
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ("T", "t"):
x, y = consume(2)
if rel:
x += cx
y += cy
if last_ctrl and last_cmd in ("Q", "q", "T", "t"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
x1, y1 = cx, cy
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
current.extend(pts)
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ("A", "a"):
rx, ry, phi, large, sweep, x, y = consume(7)
large, sweep = int(large), int(sweep)
if rel:
x += cx
y += cy
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
current.extend(pts)
cx, cy = x, y
else:
# Unknown command — skip one token
i += 1
last_cmd = cmd.upper()
if current:
polygons.append(current)
return [p for p in polygons if len(p) >= 2]
# ---------------------------------------------------------------------------
# Transform parsing
# ---------------------------------------------------------------------------
def _parse_transform(transform_str: str) -> List[List[float]]:
"""Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1]."""
def identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
result = identity()
for m in re.finditer(
r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str
):
func = m.group(1)
args = [float(v) for v in re.split(r"[\s,]+", m.group(2).strip()) if v]
mat = identity()
if func == "matrix" and len(args) == 6:
a, b, c, d, e, f = args
mat = [[a, c, e], [b, d, f], [0, 0, 1]]
elif func == "translate":
tx = args[0]
ty = args[1] if len(args) > 1 else 0
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
elif func == "scale":
sx = args[0]
sy = args[1] if len(args) > 1 else sx
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
elif func == "rotate":
angle = math.radians(args[0])
cos, sin = math.cos(angle), math.sin(angle)
if len(args) == 3:
cx_, cy_ = args[1], args[2]
t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]]
r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]]
mat = mat_mul(mat_mul(t1, r), t2)
else:
mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
elif func == "skewX":
mat = [[1, math.tan(math.radians(args[0])), 0], [0, 1, 0], [0, 0, 1]]
elif func == "skewY":
mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]]
result = mat_mul(result, mat)
return result
def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
out = []
for x, y in pts:
nx = mat[0][0] * x + mat[0][1] * y + mat[0][2]
ny = mat[1][0] * x + mat[1][1] * y + mat[1][2]
out.append((nx, ny))
return out
def _mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
# ---------------------------------------------------------------------------
# SVG element → polygon extractor
# ---------------------------------------------------------------------------
SVG_NS = re.compile(r"\{[^}]+\}")
def _tag(el: ET.Element) -> str:
return SVG_NS.sub("", el.tag)
def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]:
for key in el.attrib:
if SVG_NS.sub("", key) == name:
return el.attrib[key]
return default
def _identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
"""Recursively extract all polygons from an SVG element tree."""
tag = _tag(el)
display = _get_attr(el, "display", "inline")
visibility = _get_attr(el, "visibility", "visible")
if display == "none" or visibility == "hidden":
return []
# Accumulate transform
transform_str = _get_attr(el, "transform", "")
if transform_str:
local_mat = _parse_transform(transform_str)
mat = _mat_mul(parent_mat, local_mat)
else:
mat = parent_mat
result: List[Polygon] = []
if tag == "g" or tag == "svg":
for child in el:
result.extend(_extract_polygons_from_element(child, mat))
elif tag == "path":
d = _get_attr(el, "d", "")
if d:
tokens = _tokenize_path(d)
polygons = _parse_path_tokens(tokens)
for poly in polygons:
result.append(_apply_transform(poly, mat))
elif tag == "rect":
x = float(_get_attr(el, "x", "0") or 0)
y = float(_get_attr(el, "y", "0") or 0)
w = float(_get_attr(el, "width", "0") or 0)
h = float(_get_attr(el, "height", "0") or 0)
if w > 0 and h > 0:
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
result.append(_apply_transform(pts, mat))
elif tag == "circle":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
r = float(_get_attr(el, "r", "0") or 0)
if r > 0:
steps = 36
pts = [
(
cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat))
elif tag == "ellipse":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
rx = float(_get_attr(el, "rx", "0") or 0)
ry = float(_get_attr(el, "ry", "0") or 0)
if rx > 0 and ry > 0:
steps = 36
pts = [
(
cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat))
elif tag in ("polygon", "polyline"):
points_str = _get_attr(el, "points", "")
if points_str:
nums = [float(v) for v in re.split(r"[\s,]+", points_str.strip()) if v]
pts = [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)]
if tag == "polygon" and pts:
pts.append(pts[0]) # close
if pts:
result.append(_apply_transform(pts, mat))
elif tag == "line":
x1 = float(_get_attr(el, "x1", "0") or 0)
y1 = float(_get_attr(el, "y1", "0") or 0)
x2 = float(_get_attr(el, "x2", "0") or 0)
y2 = float(_get_attr(el, "y2", "0") or 0)
pts = [(x1, y1), (x2, y2)]
result.append(_apply_transform(pts, mat))
return result
# ---------------------------------------------------------------------------
# Bounding box helper
# ---------------------------------------------------------------------------
def _bounding_box(polygons: List[Polygon]) -> Tuple[float, float, float, float]:
all_x = [p[0] for poly in polygons for p in poly]
all_y = [p[1] for poly in polygons for p in poly]
return min(all_x), min(all_y), max(all_x), max(all_y)
# ---------------------------------------------------------------------------
# gr_poly builder
# ---------------------------------------------------------------------------
def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled: bool) -> str:
pts_lines = []
row: List[str] = []
for i, (x, y) in enumerate(points):
row.append(f"(xy {x:.6f} {y:.6f})")
if len(row) == 4 or i == len(points) - 1:
pts_lines.append("\t\t\t" + " ".join(row))
row = []
fill_str = "yes" if filled else "none"
uid = str(uuid.uuid4())
lines = (
[
"\t(gr_poly",
"\t\t(pts",
]
+ pts_lines
+ [
"\t\t)",
"\t\t(stroke",
f"\t\t\t(width {stroke_width:.4f})",
"\t\t\t(type solid)",
"\t\t)",
f"\t\t(fill {fill_str})",
f'\t\t(layer "{layer}")',
f'\t\t(uuid "{uid}")',
"\t)",
]
)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main public function
# ---------------------------------------------------------------------------
def import_svg_to_pcb(
pcb_path: str,
svg_path: str,
x_mm: float,
y_mm: float,
width_mm: float,
layer: str = "F.SilkS",
stroke_width: float = 0.0,
filled: bool = True,
) -> Dict[str, Any]:
"""
Import an SVG file as graphic polygons into a KiCAD PCB file.
Args:
pcb_path: Path to .kicad_pcb file (will be edited in place)
svg_path: Path to SVG file
x_mm: X position of logo top-left in mm
y_mm: Y position of logo top-left in mm
width_mm: Desired width of the logo in mm (aspect ratio preserved)
layer: PCB layer name, e.g. "F.SilkS" or "B.SilkS"
stroke_width: Outline stroke width in mm (0 = no outline)
filled: Fill polygons (True) or outline only (False)
Returns:
dict with keys: success, message, polygon_count
"""
if not os.path.exists(pcb_path):
return {"success": False, "message": f"PCB file not found: {pcb_path}"}
if not os.path.exists(svg_path):
return {"success": False, "message": f"SVG file not found: {svg_path}"}
try:
# --- 1. Parse SVG ---
tree = ET.parse(svg_path)
root = tree.getroot()
# Determine SVG viewport
vb = _get_attr(root, "viewBox")
if vb:
parts = [float(v) for v in re.split(r"[\s,]+", vb.strip()) if v]
svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3]
else:
w_str = _get_attr(root, "width", "100") or "100"
h_str = _get_attr(root, "height", "100") or "100"
svg_w = float(re.sub(r"[^\d.]", "", w_str) or 100)
svg_h = float(re.sub(r"[^\d.]", "", h_str) or 100)
svg_x0, svg_y0 = 0.0, 0.0
if svg_w == 0 or svg_h == 0:
return {"success": False, "message": "SVG has zero width or height"}
# --- 2. Extract all polygons ---
polygons = _extract_polygons_from_element(root, _identity())
if not polygons:
return {"success": False, "message": "No drawable shapes found in SVG"}
# --- 3. Compute bounding box of extracted polygons ---
bx_min, by_min, bx_max, by_max = _bounding_box(polygons)
poly_w = bx_max - bx_min
poly_h = by_max - by_min
if poly_w == 0:
return {"success": False, "message": "SVG shapes have zero width"}
# --- 4. Scale and translate to target position ---
scale = width_mm / poly_w
height_mm = poly_h * scale
scaled: List[Polygon] = []
for poly in polygons:
pts: List[Point] = []
for px, py in poly:
nx = x_mm + (px - bx_min) * scale
ny = y_mm + (py - by_min) * scale
pts.append((nx, ny))
scaled.append(pts)
# --- 5. Build gr_poly strings ---
gr_lines = []
for poly in scaled:
if len(poly) < 2:
continue
gr_lines.append(_build_gr_poly(poly, layer, stroke_width, filled))
if not gr_lines:
return {"success": False, "message": "No valid polygons after scaling"}
# --- 6. Inject into PCB file ---
with open(pcb_path, "r", encoding="utf-8") as f:
pcb_content = f.read()
# Insert before the final closing ')' of the kicad_pcb block
insert_block = "\n" + "\n".join(gr_lines) + "\n"
last_paren = pcb_content.rfind(")")
if last_paren == -1:
return {
"success": False,
"message": "PCB file format error: no closing parenthesis found",
}
new_content = pcb_content[:last_paren] + insert_block + pcb_content[last_paren:]
with open(pcb_path, "w", encoding="utf-8") as f:
f.write(new_content)
logger.info(f"SVG logo import: wrote {len(gr_lines)} polygons to {pcb_path}")
return {
"success": True,
"message": (
f"Imported {len(gr_lines)} polygon(s) from SVG onto layer '{layer}'. "
f"Logo size: {width_mm:.2f} × {height_mm:.2f} mm at ({x_mm}, {y_mm})."
),
"polygon_count": len(gr_lines),
"logo_width_mm": round(width_mm, 4),
"logo_height_mm": round(height_mm, 4),
"position": {"x": x_mm, "y": y_mm},
"layer": layer,
}
except ET.ParseError as e:
logger.error(f"SVG parse error: {e}")
return {"success": False, "message": f"SVG parse error: {e}"}
except Exception as e:
logger.error(f"SVG import failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}