feat: add import_svg_logo tool — converts SVG to PCB silkscreen polygons
This commit is contained in:
596
python/commands/svg_import.py
Normal file
596
python/commands/svg_import.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""
|
||||
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 re
|
||||
import math
|
||||
import uuid
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
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]:
|
||||
return [tok for tok, _ in _TOKEN_RE.findall(d) if tok] + \
|
||||
[num for _, num in _TOKEN_RE.findall(d) if num]
|
||||
|
||||
|
||||
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, uy, vx, vy):
|
||||
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():
|
||||
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
||||
|
||||
def mat_mul(A, B):
|
||||
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, B):
|
||||
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():
|
||||
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)}
|
||||
@@ -382,6 +382,7 @@ class KiCADInterface:
|
||||
"generate_netlist": self._handle_generate_netlist,
|
||||
"list_schematic_libraries": self._handle_list_schematic_libraries,
|
||||
"export_schematic_pdf": self._handle_export_schematic_pdf,
|
||||
"import_svg_logo": self._handle_import_svg_logo,
|
||||
# UI/Process management commands
|
||||
"check_kicad_ui": self._handle_check_kicad_ui,
|
||||
"launch_kicad_ui": self._handle_launch_kicad_ui,
|
||||
@@ -1471,6 +1472,32 @@ class KiCADInterface:
|
||||
logger.error(f"Error generating netlist: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_import_svg_logo(self, params):
|
||||
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
||||
logger.info("Importing SVG logo into PCB")
|
||||
try:
|
||||
from commands.svg_import import import_svg_to_pcb
|
||||
|
||||
pcb_path = params.get("pcbPath")
|
||||
svg_path = params.get("svgPath")
|
||||
x = float(params.get("x", 0))
|
||||
y = float(params.get("y", 0))
|
||||
width = float(params.get("width", 10))
|
||||
layer = params.get("layer", "F.SilkS")
|
||||
stroke_width = float(params.get("strokeWidth", 0))
|
||||
filled = bool(params.get("filled", True))
|
||||
|
||||
if not pcb_path or not svg_path:
|
||||
return {"success": False, "message": "Missing required parameters: pcbPath, svgPath"}
|
||||
|
||||
return import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing SVG logo: {str(e)}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_check_kicad_ui(self, params):
|
||||
"""Check if KiCAD UI is running"""
|
||||
logger.info("Checking if KiCAD UI is running")
|
||||
|
||||
@@ -264,6 +264,53 @@ BOARD_TOOLS = [
|
||||
"required": ["x", "y", "diameter"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "import_svg_logo",
|
||||
"title": "Import SVG Logo to PCB",
|
||||
"description": "Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS). Curves are linearised automatically. Supports path, rect, circle, ellipse, polygon and group transforms.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pcbPath": {
|
||||
"type": "string",
|
||||
"description": "Path to the .kicad_pcb file"
|
||||
},
|
||||
"svgPath": {
|
||||
"type": "string",
|
||||
"description": "Path to the SVG logo file"
|
||||
},
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "X position of the logo top-left corner in mm"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"description": "Y position of the logo top-left corner in mm"
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"description": "Target width of the logo in mm (height scaled to preserve aspect ratio)",
|
||||
"minimum": 0.1
|
||||
},
|
||||
"layer": {
|
||||
"type": "string",
|
||||
"description": "PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)",
|
||||
"default": "F.SilkS"
|
||||
},
|
||||
"strokeWidth": {
|
||||
"type": "number",
|
||||
"description": "Outline stroke width in mm (0 = no outline, default 0)",
|
||||
"default": 0
|
||||
},
|
||||
"filled": {
|
||||
"type": "boolean",
|
||||
"description": "Fill polygons with solid layer colour (default true)",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["pcbPath", "svgPath", "x", "y", "width"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "add_board_text",
|
||||
"title": "Add Text to Board",
|
||||
|
||||
@@ -346,4 +346,41 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu
|
||||
);
|
||||
|
||||
logger.info('Board management tools registered');
|
||||
|
||||
// Import SVG logo onto PCB layer (silkscreen)
|
||||
server.tool(
|
||||
"import_svg_logo",
|
||||
"Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS / front silkscreen). Curves are linearised automatically. Ideal for placing a company or project logo on the board.",
|
||||
{
|
||||
pcbPath: z.string().describe("Path to the .kicad_pcb file"),
|
||||
svgPath: z.string().describe("Path to the SVG logo file"),
|
||||
x: z.number().describe("X position of the logo top-left corner in mm"),
|
||||
y: z.number().describe("Y position of the logo top-left corner in mm"),
|
||||
width: z.number().describe("Target width of the logo in mm (height is scaled to preserve aspect ratio)"),
|
||||
layer: z.string().optional().describe("PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)"),
|
||||
strokeWidth: z.number().optional().describe("Outline stroke width in mm (0 = no outline, default 0)"),
|
||||
filled: z.boolean().optional().describe("Fill polygons with solid colour (default true)"),
|
||||
},
|
||||
async (args: { pcbPath: string; svgPath: string; x: number; y: number; width: number; layer?: string; strokeWidth?: number; filled?: boolean }) => {
|
||||
const result = await callKicadScript("import_svg_logo", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: [
|
||||
result.message,
|
||||
`Polygons: ${result.polygon_count}`,
|
||||
`Size: ${result.logo_width_mm?.toFixed(2)} × ${result.logo_height_mm?.toFixed(2)} mm`,
|
||||
`Layer: ${result.layer}`,
|
||||
].join("\n"),
|
||||
}],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: "text", text: `SVG import failed: ${result.message || "Unknown error"}` }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user