1375 lines
54 KiB
Python
1375 lines
54 KiB
Python
#!/usr/bin/env python3
|
|
# Author: Jan Mrázek
|
|
# License: MIT
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sysconfig
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from typing import Callable, Dict, List, Optional, Tuple, TypeVar, Union, Any
|
|
|
|
from . import np
|
|
from .unit import read_resistance
|
|
from lxml import etree, objectify # type: ignore
|
|
from .pcbnew_transition import KICAD_VERSION, isV6, isV7, pcbnew # type: ignore
|
|
from ..gs import GS
|
|
|
|
T = TypeVar("T")
|
|
Numeric = Union[int, float]
|
|
Point = Tuple[Numeric, Numeric]
|
|
Box = Tuple[Numeric, Numeric, Numeric, Numeric]
|
|
Matrix = List[List[float]]
|
|
|
|
|
|
PKG_BASE = os.path.dirname(__file__)
|
|
|
|
etree.register_namespace("xlink", "http://www.w3.org/1999/xlink")
|
|
|
|
LEGACY_KICAD = not isV6() and not isV7()
|
|
|
|
default_style = {
|
|
"copper": "#417e5a",
|
|
"board": "#4ca06c",
|
|
"silk": "#f0f0f0",
|
|
"pads": "#b5ae30",
|
|
"outline": "#000000",
|
|
"clad": "#9c6b28",
|
|
"vcut": "#bf2600",
|
|
"paste": "#8a8a8a",
|
|
"highlight-on-top": False,
|
|
"highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;",
|
|
"highlight-padding": 1.5,
|
|
"highlight-offset": 0,
|
|
"tht-resistor-band-colors": {
|
|
0: '#000000',
|
|
1: '#805500',
|
|
2: '#ff0000',
|
|
3: '#ff8000',
|
|
4: '#ffff00',
|
|
5: '#00cc11',
|
|
6: '#0000cc',
|
|
7: '#cc00cc',
|
|
8: '#666666',
|
|
9: '#cccccc',
|
|
-1: '#ffc800',
|
|
-2: '#d9d9d9',
|
|
'1%': '#805500',
|
|
'2%': '#ff0000',
|
|
'0.5%': '#00cc11',
|
|
'0.25%': '#0000cc',
|
|
'0.1%': '#cc00cc',
|
|
'0.05%': '#666666',
|
|
'5%': '#ffc800',
|
|
'10%': '#d9d9d9',
|
|
'20%': '#ffe598',
|
|
}
|
|
}
|
|
|
|
float_re = r'([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)'
|
|
|
|
class SvgPathItem:
|
|
def __init__(self, path: str) -> None:
|
|
path = re.sub(r"([MLA])(-?\d+)", r"\1 \2", path)
|
|
path_elems = re.split("[, ]", path)
|
|
path_elems = list(filter(lambda x: x, path_elems))
|
|
if path_elems[0] != "M":
|
|
raise SyntaxError("Only paths with absolute position are supported")
|
|
self.start: Point = tuple(map(float, path_elems[1:3])) # type: ignore
|
|
self.end: Point = (0, 0)
|
|
self.args: Optional[List[Numeric]] = None
|
|
path_elems = path_elems[3:]
|
|
if path_elems[0] == "L":
|
|
x = float(path_elems[1])
|
|
y = float(path_elems[2])
|
|
self.end = (x, y)
|
|
self.type = path_elems[0]
|
|
self.args = None
|
|
elif path_elems[0] == "A":
|
|
args = list(map(float, path_elems[1:8]))
|
|
self.end = (args[5], args[6])
|
|
self.args = args[0:5]
|
|
self.type = path_elems[0]
|
|
else:
|
|
raise SyntaxError("Unsupported path element " + path_elems[0])
|
|
|
|
@staticmethod
|
|
def is_same(p1: Point, p2: Point) -> bool:
|
|
dx = p1[0] - p2[0]
|
|
dy = p1[1] - p2[1]
|
|
pseudo_distance = dx*dx + dy*dy
|
|
if isV7():
|
|
return pseudo_distance < 0.01 ** 2
|
|
return pseudo_distance < 100 ** 2
|
|
|
|
def format(self, first: bool) -> str:
|
|
ret = ""
|
|
if first:
|
|
ret += " M {} {} ".format(*self.start)
|
|
ret += self.type
|
|
if self.args:
|
|
ret += " " + " ".join(map(lambda x: str(x).rstrip('0').rstrip('.'), self.args))
|
|
ret += " {} {} ".format(*self.end)
|
|
return ret
|
|
|
|
def flip(self) -> None:
|
|
self.start, self.end = self.end, self.start
|
|
if self.type == "A":
|
|
assert(self.args is not None)
|
|
self.args[4] = 1 if self.args[4] < 0.5 else 0
|
|
|
|
def matrix(data: List[List[Numeric]]) -> Matrix:
|
|
return np.array(data, dtype=np.float32)
|
|
|
|
def pseudo_distance(a: Point, b: Point) -> Numeric:
|
|
return (a[0] - b[0])**2 + (a[1] - b[1])**2
|
|
|
|
def distance(a: Point, b: Point) -> Numeric:
|
|
return math.sqrt((a[0] - b[0])**2 + (a[1] - b[1])**2)
|
|
|
|
def get_closest(reference: Point, elems: List[Point]) -> int:
|
|
distances = [pseudo_distance(reference, x) for x in elems]
|
|
return int(np.argmin(distances))
|
|
|
|
def extract_arg(args: List[Any], index: int, default: Any=None) -> Any:
|
|
"""
|
|
Return n-th element of array or default if out of range
|
|
"""
|
|
if index >= len(args):
|
|
return default
|
|
return args[index]
|
|
|
|
def to_trans_matrix(transform: str) -> Matrix:
|
|
"""
|
|
Given SVG transformation string returns corresponding matrix
|
|
"""
|
|
m = matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
|
|
if transform is None:
|
|
return m
|
|
trans = re.findall(r'[a-z]+?\(.*?\)', transform)
|
|
for t in trans:
|
|
op, args = t.split('(')
|
|
args = [float(x) for x in re.findall(float_re, args)]
|
|
if op == 'matrix':
|
|
m = np.matmul(m, matrix([
|
|
[args[0], args[2], args[4]],
|
|
[args[1], args[3], args[5]],
|
|
[0, 0, 1]]))
|
|
if op == 'translate':
|
|
x = args[0]
|
|
y = extract_arg(args, 1, 0)
|
|
m = np.matmul(m, matrix([
|
|
[1, 0, x],
|
|
[0, 1, y],
|
|
[0, 0, 1]]))
|
|
if op == 'scale':
|
|
x = args[0]
|
|
y = extract_arg(args, 1, 1)
|
|
m = np.matmul(m, matrix([
|
|
[x, 0, 0],
|
|
[0, y, 0],
|
|
[0, 0, 1]]))
|
|
if op == 'rotate':
|
|
cosa: float = math.cos(math.radians(args[0]))
|
|
sina: float = math.sin(math.radians(args[0]))
|
|
if len(args) != 1:
|
|
x, y = args[1:3]
|
|
m = np.matmul(m, matrix([
|
|
[1, 0, x],
|
|
[0, 1, y],
|
|
[0, 0, 1]]))
|
|
m = np.matmul(m, matrix([
|
|
[cosa, -sina, 0],
|
|
[sina, cosa, 0],
|
|
[0, 0, 1]]))
|
|
if len(args) != 1:
|
|
m = np.matmul(m, matrix([
|
|
[1, 0, -x],
|
|
[0, 1, -y],
|
|
[0, 0, 1]]))
|
|
tana: float = math.tan(math.radians(args[0]))
|
|
if op == 'skewX':
|
|
m = np.matmul(m, matrix([
|
|
[1, tana, 0],
|
|
[0, 1, 0],
|
|
[0, 0, 1]]))
|
|
if op == 'skewY':
|
|
m = np.matmul(m, matrix([
|
|
[1, 0, 0],
|
|
[tana, 1, 0],
|
|
[0, 0, 1]]))
|
|
return m
|
|
|
|
def collect_transformation(element: etree.Element, root: Optional[etree.Element]=None) -> Matrix:
|
|
"""
|
|
Collect all the transformation applied to an element and return it as matrix
|
|
"""
|
|
if root is None:
|
|
if element.getparent() is not None:
|
|
m = collect_transformation(element.getparent(), root)
|
|
else:
|
|
m = matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
|
|
else:
|
|
if element.getparent() != root:
|
|
m = collect_transformation(element.getparent(), root)
|
|
else:
|
|
m = matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
|
|
if "transform" not in element.attrib:
|
|
return m
|
|
trans = element.attrib["transform"]
|
|
# There is a strange typing behavior in CI, ignore it at the moment
|
|
return np.matmul(m, to_trans_matrix(trans)) # type: ignore
|
|
|
|
def element_position(element: etree.Element, root: Optional[etree.Element]=None) -> Point:
|
|
position = matrix([
|
|
[element.attrib["x"]],
|
|
[element.attrib["y"]],
|
|
[1]])
|
|
r = root
|
|
trans = collect_transformation(element, root=r)
|
|
position = np.matmul(trans, position)
|
|
return position[0][0] / position[2][0], position[1][0] / position[2][0]
|
|
|
|
def get_global_datapaths() -> List[str]:
|
|
paths = []
|
|
share = os.path.join('share', 'pcbdraw')
|
|
scheme_names = sysconfig.get_scheme_names()
|
|
if os.name == 'posix':
|
|
if 'posix_user' in scheme_names:
|
|
paths.append(os.path.join(sysconfig.get_path('data', 'posix_user'), share))
|
|
if 'posix_prefix' in scheme_names:
|
|
paths.append(os.path.join(sysconfig.get_path('data', 'posix_prefix'), share))
|
|
elif os.name == 'nt':
|
|
if 'nt_user' in scheme_names:
|
|
paths.append(os.path.join(sysconfig.get_path('data', 'nt_user'), share))
|
|
if 'nt' in scheme_names:
|
|
paths.append(os.path.join(sysconfig.get_path('data', 'nt'), share))
|
|
if len(paths) == 0:
|
|
paths.append(os.path.join(sysconfig.get_path('data'), share))
|
|
return paths
|
|
|
|
def find_data_file(name: str, extension: str, data_paths: List[str], subdir: Optional[str]=None) -> Optional[str]:
|
|
if not name.endswith(extension):
|
|
name += extension
|
|
if os.path.isfile(name):
|
|
return name
|
|
for path in data_paths:
|
|
if subdir is not None:
|
|
fname = os.path.join(path, subdir, name)
|
|
if os.path.isfile(fname):
|
|
return fname
|
|
fname = os.path.join(path, name)
|
|
if os.path.isfile(fname):
|
|
return fname
|
|
return None
|
|
|
|
def ki2dmil(val: int) -> float:
|
|
return val // 2540
|
|
|
|
def dmil2ki(val: float) -> int:
|
|
return int(val * 2540)
|
|
|
|
def ki2mm(val: int) -> float:
|
|
return val / 1000000.0
|
|
|
|
def mm2ki(val: float) -> int:
|
|
return int(val * 1000000)
|
|
|
|
def to_kicad_basic_units(val: str) -> int:
|
|
"""
|
|
Read string value and return it as KiCAD base units
|
|
"""
|
|
x = float_re + r'\s*(pt|pc|mm|cm|in)?'
|
|
value, unit = re.findall(x, val)[0]
|
|
value = float(value)
|
|
if unit == "" or unit == "px":
|
|
return mm2ki(value * 25.4 / 96)
|
|
if unit == "pt":
|
|
return mm2ki(value * 25.4 / 72)
|
|
if unit == "pc":
|
|
return mm2ki(value * 25.4 / 6)
|
|
if unit == "mm":
|
|
return mm2ki(value)
|
|
if unit == "cm":
|
|
return mm2ki(value * 10)
|
|
if unit == "in":
|
|
return mm2ki(25.4 * value)
|
|
raise RuntimeError(f"Unknown units in '{val}'")
|
|
|
|
def to_user_units(val: str) -> float:
|
|
x = float_re + r'\s*(pt|pc|mm|cm|in)?'
|
|
value_str, unit = re.findall(x, val)[0]
|
|
value = float(value_str)
|
|
if unit == "" or unit == "px":
|
|
return value
|
|
if unit == "pt":
|
|
return 1.25 * value
|
|
if unit == "pc":
|
|
return 15 * value
|
|
if unit == "mm":
|
|
return 3.543307 * value
|
|
if unit == "cm":
|
|
return 35.43307 * value
|
|
if unit == "in":
|
|
return 90
|
|
raise RuntimeError(f"Unknown units in '{val}'")
|
|
|
|
|
|
def make_XML_identifier(s: str) -> str:
|
|
"""
|
|
Given a name, strip invalid characters from XML identifier
|
|
"""
|
|
s = re.sub('[^0-9a-zA-Z_]', '', s)
|
|
s = re.sub('^[^a-zA-Z_]+', '', s)
|
|
return s
|
|
|
|
def read_svg_unique(filename: str, prefix: str) -> etree.Element:
|
|
root, _ = read_svg_unique2(filename, prefix)
|
|
return root
|
|
|
|
def read_svg_unique2(filename: str, prefix: str) -> etree.Element:
|
|
root = etree.parse(filename).getroot()
|
|
# We have to ensure all Ids in SVG are unique. Let's make it nasty by
|
|
# collecting all ids and doing search & replace
|
|
# Potentially dangerous (can break user text)
|
|
ids = []
|
|
for el in root.getiterator():
|
|
if "id" in el.attrib and el.attrib["id"] != "origin":
|
|
ids.append(el.attrib["id"])
|
|
with open(filename) as f:
|
|
content = f.read()
|
|
for i in ids:
|
|
content = content.replace("#"+i, "#" + prefix + i)
|
|
root = etree.fromstring(str.encode(content))
|
|
for el in root.getiterator():
|
|
if "id" in el.attrib and el.attrib["id"] != "origin":
|
|
el.attrib["id"] = prefix + el.attrib["id"]
|
|
return root, prefix
|
|
|
|
def extract_svg_content(root: etree.Element) -> List[etree.Element]:
|
|
# Remove SVG namespace to ease our lives and change ids
|
|
for el in root.getiterator():
|
|
if '}' in str(el.tag):
|
|
el.tag = el.tag.split('}', 1)[1]
|
|
return [ x for x in root if x.tag and x.tag not in ["title", "desc"]]
|
|
|
|
def strip_style_svg(root: etree.Element, keys: List[str], forbidden_colors: List[str]) -> bool:
|
|
elements_to_remove = []
|
|
for el in root.getiterator():
|
|
if "style" in el.attrib:
|
|
s = el.attrib["style"].strip().split(";")
|
|
styles = {}
|
|
for x in s:
|
|
if len(x) == 0:
|
|
continue
|
|
key, val = tuple(x.split(":"))
|
|
key = key.strip()
|
|
val = val.strip()
|
|
styles[key] = val
|
|
fill = styles.get("fill", "").lower()
|
|
stroke = styles.get("stroke", "").lower()
|
|
if fill in forbidden_colors or stroke in forbidden_colors:
|
|
elements_to_remove.append(el)
|
|
el.attrib["style"] = ";" \
|
|
.join([f"{key}: {val}" for key, val in styles.items() if key not in keys]) \
|
|
.replace(" ", " ") \
|
|
.strip()
|
|
for el in elements_to_remove:
|
|
el.getparent().remove(el)
|
|
return root in elements_to_remove
|
|
|
|
def empty_svg(**attrs: str) -> etree.ElementTree:
|
|
document = etree.ElementTree(etree.fromstring(
|
|
"""<?xml version="1.0" standalone="no"?>
|
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
|
|
width="29.7002cm" height="21.0007cm" viewBox="0 0 116930 82680 ">
|
|
<title>Picture generated by PcbDraw </title>
|
|
<desc>Picture generated by PcbDraw</desc>
|
|
</svg>"""))
|
|
root = document.getroot()
|
|
for key, value in attrs.items():
|
|
root.attrib[key] = value
|
|
return document
|
|
|
|
def get_board_polygon(svg_elements: etree.Element) -> etree.Element:
|
|
"""
|
|
Try to connect independents segments on Edge.Cuts and form a polygon
|
|
return SVG path element with the polygon
|
|
"""
|
|
elements = []
|
|
path = ""
|
|
for group in svg_elements:
|
|
for svg_element in group:
|
|
if svg_element.tag == "path":
|
|
path = svg_element.attrib["d"]
|
|
# Check if this is a closed polygon (KiCad 7.0.1+)
|
|
polygon = re.fullmatch(r"M ((\d+\.\d+),(\d+\.\d+) )+Z", path)
|
|
if polygon:
|
|
# Yes, decompose it in lines
|
|
polygon = re.findall(r"(\d+\.\d+),(\d+\.\d+) ", path)
|
|
start = polygon[0]
|
|
# Close it
|
|
polygon.append(polygon[0])
|
|
# Add the lines
|
|
for end in polygon[1:]:
|
|
path = 'M'+start[0]+' '+start[1]+' L'+end[0]+' '+end[1]
|
|
elements.append(SvgPathItem(path))
|
|
start = end
|
|
else:
|
|
elements.append(SvgPathItem(path))
|
|
elif svg_element.tag == "circle":
|
|
# Convert circle to path
|
|
att = svg_element.attrib
|
|
s = " M {0} {1} m-{2} 0 a {2} {2} 0 1 0 {3} 0 a {2} {2} 0 1 0 -{3} 0 ".format(
|
|
att["cx"], att["cy"], att["r"], 2 * float(att["r"]))
|
|
path += s
|
|
while len(elements) > 0:
|
|
# Initiate seed for the outline
|
|
outline = [elements[0]]
|
|
elements = elements[1:]
|
|
size = 0
|
|
# Append new segments to the ends of outline until there is none to append.
|
|
while size != len(outline) and len(elements) > 0:
|
|
size = len(outline)
|
|
|
|
i = get_closest(outline[0].start, [x.end for x in elements])
|
|
if SvgPathItem.is_same(outline[0].start, elements[i].end):
|
|
outline.insert(0, elements[i])
|
|
del elements[i]
|
|
continue
|
|
|
|
i = get_closest(outline[0].start, [x.start for x in elements])
|
|
if SvgPathItem.is_same(outline[0].start, elements[i].start):
|
|
e = elements[i]
|
|
e.flip()
|
|
outline.insert(0, e)
|
|
del elements[i]
|
|
continue
|
|
|
|
i = get_closest(outline[-1].end, [x.start for x in elements])
|
|
if SvgPathItem.is_same(outline[-1].end, elements[i].start):
|
|
outline.insert(0, elements[i])
|
|
del elements[i]
|
|
continue
|
|
|
|
i = get_closest(outline[-1].end, [x.end for x in elements])
|
|
if SvgPathItem.is_same(outline[-1].end, elements[i].end):
|
|
e = elements[i]
|
|
e.flip()
|
|
outline.insert(0, e)
|
|
del elements[i]
|
|
continue
|
|
# ...then, append it to path.
|
|
first = True
|
|
for x in outline:
|
|
path += x.format(first)
|
|
first = False
|
|
e = etree.Element("path", d=path, style="fill-rule: evenodd;")
|
|
return e
|
|
|
|
def load_style(style_file: str) -> Dict[str, Any]:
|
|
try:
|
|
with open(style_file, "r") as f:
|
|
style = json.load(f)
|
|
except IOError:
|
|
raise RuntimeError("Cannot open style " + style_file)
|
|
if not isinstance(style, dict):
|
|
raise RuntimeError("Stylesheet has to be a dictionary")
|
|
required = set(["copper", "board", "clad", "silk", "pads", "outline",
|
|
"vcut", "highlight-style", "highlight-offset", "highlight-on-top",
|
|
"highlight-padding"])
|
|
missing = required - set(style.keys())
|
|
if missing:
|
|
raise RuntimeError("Missing following keys in style {}: {}"
|
|
.format(style_file, ", ".join(missing)))
|
|
return style
|
|
|
|
def load_remapping(remap_file: str) -> Dict[str, Tuple[str, str]]:
|
|
def readMapping(s: str) -> Tuple[str, str]:
|
|
x = s.split(":")
|
|
if len(x) != 2:
|
|
raise RuntimeError(f"Invalid remmaping value {s}")
|
|
return x[0], x[1]
|
|
if remap_file is None:
|
|
return {}
|
|
try:
|
|
with open(remap_file, "r") as f:
|
|
j = json.load(f)
|
|
if not isinstance(j, dict):
|
|
raise RuntimeError("Invalid format of remapping file")
|
|
return {ref: readMapping(val) for ref, val in j.items()}
|
|
except IOError:
|
|
raise RuntimeError("Cannot open remapping file " + remap_file)
|
|
|
|
def merge_bbox(left: Box, right: Box) -> Box:
|
|
"""
|
|
Merge bounding boxes in format (xmin, xmax, ymin, ymax)
|
|
"""
|
|
return tuple([
|
|
f(l, r) for l, r, f in zip(left, right, [min, max, min, max])
|
|
]) # type: ignore
|
|
|
|
def hack_is_valid_bbox(box: Any): # type: ignore
|
|
return all(-1e15 < c < 1e15 for c in box)
|
|
|
|
def remove_empty_elems(tree: etree.Element) -> None:
|
|
"""
|
|
Given SVG tree, remove empty groups and defs
|
|
"""
|
|
for elem in tree:
|
|
remove_empty_elems(elem)
|
|
toDel = []
|
|
for elem in tree:
|
|
if elem.tag in ["g", "defs"] and len(elem.getchildren()) == 0:
|
|
toDel.append(elem)
|
|
for elem in toDel:
|
|
tree.remove(elem)
|
|
|
|
def remove_inkscape_annotation(tree: etree.Element) -> None:
|
|
for elem in tree:
|
|
remove_inkscape_annotation(elem)
|
|
for key in tree.attrib.keys():
|
|
if "inkscape" in key:
|
|
tree.attrib.pop(key)
|
|
# Comments have callable tag...
|
|
if not callable(tree.tag):
|
|
objectify.deannotate(tree, cleanup_namespaces=True)
|
|
|
|
@dataclass
|
|
class Hole:
|
|
position: Tuple[int, int]
|
|
orientation: pcbnew.EDA_ANGLE
|
|
drillsize: Tuple[int, int]
|
|
|
|
def get_svg_path_d(self, ki2svg: Callable[[int], float]) -> str:
|
|
w, h = [ki2svg(x) for x in self.drillsize]
|
|
if w > h:
|
|
ew = w - h
|
|
eh = h
|
|
commands = f"M {-ew / 2} {-eh / 2} "
|
|
commands += f"A {eh / 2} {eh / 2} 0 1 1 {-ew / 2} {eh / 2} "
|
|
commands += f"L {ew / 2} {eh / 2} "
|
|
commands += f"A {eh / 2} {eh / 2} 0 1 1 {ew / 2} {-eh / 2} "
|
|
commands += f"Z"
|
|
return commands
|
|
else:
|
|
ew = w
|
|
eh = h - w
|
|
commands = f"M {-ew / 2} {eh / 2} "
|
|
commands += f"A {ew / 2} {ew / 2} 0 1 1 {ew / 2} {eh / 2} "
|
|
commands += f"L {ew / 2} {-eh / 2} "
|
|
commands += f"A {ew / 2} {ew / 2} 0 1 1 {-ew / 2} {-eh / 2} "
|
|
commands += f"Z"
|
|
return commands
|
|
|
|
@dataclass
|
|
class PlotAction:
|
|
name: str
|
|
layers: List[int]
|
|
action: Callable[[str, str], None]
|
|
|
|
@dataclass
|
|
class ResistorValue:
|
|
value: Optional[str] = None
|
|
flip_bands: bool=False
|
|
|
|
|
|
def collect_holes(board: pcbnew.BOARD) -> List[Hole]:
|
|
holes: List[Hole] = [] # Tuple: position, orientation, drillsize
|
|
for module in board.GetFootprints():
|
|
if module.GetPadCount() == 0:
|
|
continue
|
|
for pad in module.Pads():
|
|
pos = pad.GetPosition()
|
|
drs = pad.GetDrillSize()
|
|
holes.append(Hole(
|
|
position=(pos[0], pos[1]),
|
|
orientation=pad.GetOrientation(),
|
|
drillsize=(drs.x, drs.y)
|
|
))
|
|
via_type = 'VIA' if LEGACY_KICAD else 'PCB_VIA'
|
|
for track in board.GetTracks():
|
|
if track.GetClass() != via_type:
|
|
continue
|
|
pos = track.GetPosition()
|
|
holes.append(Hole(
|
|
position=(pos[0], pos[1]),
|
|
orientation=pcbnew.EDA_ANGLE(0, pcbnew.DEGREES_T),
|
|
drillsize=(track.GetDrillValue(), track.GetDrillValue())
|
|
))
|
|
return holes
|
|
|
|
|
|
class PlotInterface:
|
|
def render(self, plotter: PcbPlotter) -> None:
|
|
raise NotImplementedError("Plot interface wasn't implemented")
|
|
|
|
|
|
SUBSTRATE_ELEMENTS = {
|
|
"board": (pcbnew.Edge_Cuts, pcbnew.Edge_Cuts),
|
|
"clad": (pcbnew.F_Mask, pcbnew.B_Mask),
|
|
"copper": (pcbnew.F_Cu, pcbnew.B_Cu),
|
|
"pads": (pcbnew.F_Cu, pcbnew.B_Cu),
|
|
"pads-mask": (pcbnew.F_Mask, pcbnew.B_Mask),
|
|
"silk": (pcbnew.F_SilkS, pcbnew.B_SilkS),
|
|
"outline": (pcbnew.Edge_Cuts, pcbnew.Edge_Cuts)
|
|
}
|
|
ELEMENTS_USED = (
|
|
# Normal plot, all the elements
|
|
("board", "clad", "copper", "pads", "pads-mask", "silk", "outline"),
|
|
# Solder mask plot
|
|
("board", "pads-mask")
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PlotSubstrate(PlotInterface):
|
|
drill_holes: bool = True
|
|
outline_width: int = mm2ki(0.1)
|
|
only_mask: bool = False
|
|
|
|
def render(self, plotter: PcbPlotter) -> None:
|
|
self._plotter = plotter # ...so we don't have to pass it explicitly
|
|
SUBSTRATE_PROCESS = {
|
|
"board": self._process_baselayer,
|
|
"clad": self._process_layer,
|
|
"copper": self._process_layer,
|
|
"pads": self._process_layer,
|
|
"pads-mask": self._process_mask,
|
|
"silk": self._process_layer,
|
|
"outline": self._process_outline
|
|
}
|
|
|
|
to_plot: List[PlotAction] = []
|
|
for e in ELEMENTS_USED[self.only_mask]:
|
|
to_plot.append(PlotAction(e, [SUBSTRATE_ELEMENTS[e][plotter.render_back]], SUBSTRATE_PROCESS[e]))
|
|
|
|
self._container = etree.Element("g", id="substrate")
|
|
self._container.attrib["clip-path"] = "url(#cut-off)"
|
|
self._boardsize = self._plotter.boardsize
|
|
self._plotter.execute_plot_plan(to_plot)
|
|
|
|
if self.drill_holes:
|
|
self._build_hole_mask()
|
|
self._container.attrib["mask"] = "url(#hole-mask)"
|
|
self._plotter.append_board_element(self._container)
|
|
|
|
def _process_layer(self,name: str, source_filename: str) -> None:
|
|
layer = etree.SubElement(self._container, "g", id="substrate-" + name,
|
|
style="fill:{0}; stroke:{0};".format(self._plotter.get_style(name)))
|
|
if name == "pads":
|
|
layer.attrib["mask"] = "url(#pads-mask)"
|
|
if name == "silk":
|
|
layer.attrib["mask"] = "url(#pads-mask-silkscreen)"
|
|
for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())):
|
|
# Forbidden colors = workaround - KiCAD plots vias white
|
|
# See https://gitlab.com/kicad/code/kicad/-/issues/10491
|
|
if not strip_style_svg(element, keys=["fill", "stroke"],
|
|
forbidden_colors=["#ffffff"]):
|
|
layer.append(element)
|
|
|
|
def _process_outline(self, name: str, source_filename: str) -> None:
|
|
if self.outline_width == 0:
|
|
return
|
|
layer = etree.SubElement(self._container, "g", id="substrate-" + name,
|
|
style="fill:{0}; stroke:{0}; stroke-width: {1}".format(
|
|
self._plotter.get_style(name),
|
|
self._plotter.ki2svg(self.outline_width)))
|
|
if name == "pads":
|
|
layer.attrib["mask"] = "url(#pads-mask)"
|
|
if name == "silk":
|
|
layer.attrib["mask"] = "url(#pads-mask-silkscreen)"
|
|
for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())):
|
|
# Forbidden colors = workaround - KiCAD plots vias white
|
|
# See https://gitlab.com/kicad/code/kicad/-/issues/10491
|
|
if not strip_style_svg(element, keys=["fill", "stroke", "stroke-width"],
|
|
forbidden_colors=["#ffffff"]):
|
|
layer.append(element)
|
|
for hole in collect_holes(self._plotter.board):
|
|
position = [self._plotter.ki2svg(coord) for coord in hole.position]
|
|
size = [self._plotter.ki2svg(coord) for coord in hole.drillsize]
|
|
if size[0] == 0 or size[1] == 0:
|
|
continue
|
|
el = etree.SubElement(layer, "path")
|
|
el.attrib["d"] = hole.get_svg_path_d(self._plotter.ki2svg)
|
|
el.attrib["transform"] = "translate({} {}) rotate({})".format(
|
|
position[0], position[1], -hole.orientation.AsDegrees())
|
|
|
|
def _process_baselayer(self, name: str, source_filename: str) -> None:
|
|
clipPath = self._plotter.get_def_slot(tag_name="clipPath", id="cut-off")
|
|
clipPath.append(
|
|
get_board_polygon(
|
|
extract_svg_content(
|
|
read_svg_unique(source_filename, self._plotter.unique_prefix()))))
|
|
|
|
layer = etree.SubElement(self._container, "g", id="substrate-"+name,
|
|
style="fill:{0}; stroke:{0};".format(self._plotter.get_style(name)))
|
|
layer.append(
|
|
get_board_polygon(
|
|
extract_svg_content(
|
|
read_svg_unique(source_filename, self._plotter.unique_prefix()))))
|
|
for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())):
|
|
# Forbidden colors = workaround - KiCAD plots vias white
|
|
# See https://gitlab.com/kicad/code/kicad/-/issues/10491
|
|
if not strip_style_svg(element, keys=["fill", "stroke"],
|
|
forbidden_colors=["#ffffff"]):
|
|
layer.append(element)
|
|
|
|
def _process_mask(self, name: str, source_filename: str) -> None:
|
|
mask = self._plotter.get_def_slot(tag_name="mask", id=name)
|
|
for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())):
|
|
for item in element.getiterator():
|
|
if "style" in item.attrib:
|
|
# KiCAD plots in black, for mask we need white
|
|
item.attrib["style"] = item.attrib["style"].replace("#000000", "#ffffff")
|
|
mask.append(element)
|
|
silkMask = self._plotter.get_def_slot(tag_name="mask", id=f"{name}-silkscreen")
|
|
bg = etree.SubElement(silkMask, "rect", attrib={
|
|
"x": str(self._plotter.ki2svg(self._boardsize.GetX())),
|
|
"y": str(self._plotter.ki2svg(self._boardsize.GetY())),
|
|
"width": str(self._plotter.ki2svg(self._boardsize.GetWidth())),
|
|
"height": str(self._plotter.ki2svg(self._boardsize.GetHeight())),
|
|
"fill": "white"
|
|
})
|
|
for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())):
|
|
# KiCAD plots black, no need to change fill
|
|
silkMask.append(element)
|
|
|
|
def _build_hole_mask(self) -> None:
|
|
mask = self._plotter.get_def_slot(tag_name="mask", id="hole-mask")
|
|
container = etree.SubElement(mask, "g")
|
|
|
|
bb = self._plotter.boardsize
|
|
bg = etree.SubElement(container, "rect", x="0", y="0", fill="white")
|
|
bg.attrib["x"] = str(self._plotter.ki2svg(bb.GetX()))
|
|
bg.attrib["y"] = str(self._plotter.ki2svg(bb.GetY()))
|
|
bg.attrib["width"] = str(self._plotter.ki2svg(bb.GetWidth()))
|
|
bg.attrib["height"] = str(self._plotter.ki2svg(bb.GetHeight()))
|
|
|
|
for hole in collect_holes(self._plotter.board):
|
|
position = list(map(self._plotter.ki2svg, hole.position))
|
|
size = list(map(self._plotter.ki2svg, hole.drillsize))
|
|
if size[0] > 0 and size[1] > 0:
|
|
if size[0] < size[1]:
|
|
stroke = size[0]
|
|
length = size[1] - size[0]
|
|
points = "{} {} {} {}".format(0, -length / 2, 0, length / 2)
|
|
else:
|
|
stroke = size[1]
|
|
length = size[0] - size[1]
|
|
points = "{} {} {} {}".format(-length / 2, 0, length / 2, 0)
|
|
el = etree.SubElement(container, "polyline")
|
|
el.attrib["stroke-linecap"] = "round"
|
|
el.attrib["stroke"] = "black"
|
|
el.attrib["stroke-width"] = str(stroke)
|
|
el.attrib["points"] = points
|
|
el.attrib["transform"] = "translate({} {}) rotate({})".format(
|
|
position[0], position[1], -hole.orientation.AsDegrees())
|
|
|
|
@dataclass
|
|
class PlacedComponentInfo:
|
|
id: str
|
|
origin: Tuple[float, float]
|
|
svg_offset: Tuple[float, float]
|
|
scale: Tuple[float, float]
|
|
size: Tuple[float, float]
|
|
|
|
@dataclass
|
|
class PlotComponents(PlotInterface):
|
|
filter: Callable[[str], bool] = lambda x: True # Components to show
|
|
highlight: Callable[[str], bool] = lambda x: False # References to highlight
|
|
remapping: Callable[[str, str, str], Tuple[str, str]] = lambda ref, lib, name: (lib, name)
|
|
resistor_values: Dict[str, ResistorValue] = field(default_factory=dict)
|
|
no_warn_back: bool = False
|
|
|
|
def render(self, plotter: PcbPlotter) -> None:
|
|
self._plotter = plotter
|
|
self._prefix = plotter.unique_prefix()
|
|
self._used_components: Dict[str, PlacedComponentInfo] = {}
|
|
plotter.walk_components(invert_side=False, callback=self._append_component)
|
|
plotter.walk_components(invert_side=True, callback=self._append_back_component)
|
|
|
|
def _get_unique_name(self, lib: str, name: str, value: str) -> str:
|
|
return f"{self._prefix}_{lib}__{name}_{value}"
|
|
|
|
def _append_back_component(self, lib: str, name: str, ref: str, value: str,
|
|
position: Tuple[int, int, float]) -> None:
|
|
return self._append_component(lib, name + ".back", ref, value, position)
|
|
|
|
def _append_component(self, lib: str, name: str, ref: str, value: str,
|
|
position: Tuple[int, int, float]) -> None:
|
|
if not self.filter(ref) or name == "":
|
|
return
|
|
# Override resistor values
|
|
if ref in self.resistor_values:
|
|
v = self.resistor_values[ref].value
|
|
if v is not None:
|
|
value = v
|
|
|
|
lib, name = self.remapping(ref, lib, name)
|
|
|
|
unique_name = self._get_unique_name(lib, name, value)
|
|
if unique_name in self._used_components:
|
|
component_info = self._used_components[unique_name]
|
|
component_element = etree.Element("use",
|
|
attrib={"{http://www.w3.org/1999/xlink}href": "#" + component_info.id})
|
|
else:
|
|
ret = self._create_component(lib, name, ref, value)
|
|
if ret is None:
|
|
if name[-5:] != '.back' or not self.no_warn_back:
|
|
self._plotter.yield_warning("component", f"Component {lib}:{name} has no footprint.")
|
|
return
|
|
component_element, component_info = ret
|
|
self._used_components[unique_name] = component_info
|
|
|
|
self._plotter.append_component_element(etree.Comment(f"{lib}:{name}:{ref}"))
|
|
group = etree.Element("g")
|
|
group.append(component_element)
|
|
ci = component_info
|
|
group.attrib["transform"] = \
|
|
f"translate({self._plotter.ki2svg(position[0])} {self._plotter.ki2svg(position[1])}) " + \
|
|
f"scale({ci.scale[0]}, {ci.scale[1]}) " + \
|
|
f"rotate({-math.degrees(position[2])}) " + \
|
|
f"translate({-ci.origin[0]} {-ci.origin[1]})"
|
|
self._plotter.append_component_element(group)
|
|
|
|
if self.highlight(ref):
|
|
self._build_highlight(ref, component_info, position)
|
|
|
|
def _create_component(self, lib: str, name: str, ref: str, value: str) \
|
|
-> Optional[Tuple[etree.Element, PlacedComponentInfo]]:
|
|
f = self._plotter._get_model_file(lib, name)
|
|
if f is None:
|
|
return None
|
|
xml_id = make_XML_identifier(self._get_unique_name(lib, name, value))
|
|
component_element = etree.Element("g", attrib={"id": xml_id})
|
|
|
|
svg_tree, id_prefix = read_svg_unique2(f, self._plotter.unique_prefix())
|
|
for x in extract_svg_content(svg_tree):
|
|
if x.tag in ["namedview", "metadata"]:
|
|
continue
|
|
component_element.append(x)
|
|
origin_x: Numeric = 0
|
|
origin_y: Numeric = 0
|
|
origin = component_element.find(".//*[@id='origin']")
|
|
if origin is not None:
|
|
origin_x, origin_y = element_position(origin, root=component_element)
|
|
origin.getparent().remove(origin)
|
|
else:
|
|
self._plotter.yield_warning("origin", f"component: Component {lib}:{name} has no origin")
|
|
svg_scale_x, svg_scale_y, svg_offset_x, svg_offset_y = self._component_to_board_scale_and_offset(svg_tree)
|
|
component_info = PlacedComponentInfo(
|
|
id=xml_id,
|
|
origin=(origin_x, origin_y),
|
|
svg_offset=(svg_offset_x, svg_offset_y),
|
|
scale=(svg_scale_x, svg_scale_y),
|
|
size=(to_kicad_basic_units(svg_tree.attrib["width"]), to_kicad_basic_units(svg_tree.attrib["height"]))
|
|
)
|
|
self._apply_resistor_code(component_element, id_prefix, ref, value)
|
|
return component_element, component_info
|
|
|
|
def _component_to_board_scale_and_offset(self, svg: etree.Element) \
|
|
-> Tuple[float, float, float, float]:
|
|
width = self._plotter.ki2svg(to_kicad_basic_units(svg.attrib["width"]))
|
|
height = self._plotter.ki2svg(to_kicad_basic_units(svg.attrib["height"]))
|
|
x, y, vw, vh = [float(x) for x in svg.attrib["viewBox"].split()]
|
|
return width / vw, height / vh, x, y
|
|
|
|
def _build_highlight(self, ref: str, info: PlacedComponentInfo,
|
|
position: Tuple[int, int, float]) -> None:
|
|
padding = mm2ki(self._plotter.get_style("highlight-padding"))
|
|
h = etree.Element("rect", id=f"h_{ref}",
|
|
x=str(self._plotter.ki2svg(-padding)),
|
|
y=str(self._plotter.ki2svg(-padding)),
|
|
width=str(self._plotter.ki2svg(int(info.size[0] + 2 * padding))),
|
|
height=str(self._plotter.ki2svg(int(info.size[1] + 2 * padding))),
|
|
style=self._plotter.get_style("highlight-style"))
|
|
h.attrib["transform"] = \
|
|
f"translate({self._plotter.ki2svg(position[0])} {self._plotter.ki2svg(position[1])}) " + \
|
|
f"rotate({-math.degrees(position[2])}) " + \
|
|
f"translate({-(info.origin[0] - info.svg_offset[0]) * info.scale[0]}, {-(info.origin[1] - info.svg_offset[1]) * info.scale[1]})"
|
|
self._plotter.append_highlight_element(h)
|
|
|
|
def _apply_resistor_code(self, root: etree.Element, id_prefix: str, ref: str, value: str) -> None:
|
|
if root.find(f".//*[@id='{id_prefix}res_band1']") is None:
|
|
return
|
|
try:
|
|
res, tolerance = self._get_resistance_from_value(value)
|
|
power = math.floor(res.log10()) - 1
|
|
res = str(Decimal(int(res / Decimal(10) ** power)))
|
|
if power == -3:
|
|
power += 1
|
|
res = '0'+res
|
|
elif power < -3:
|
|
raise UserWarning(f"Resistor value must be 0.01 or bigger")
|
|
resistor_colors = [
|
|
self._plotter.get_style("tht-resistor-band-colors", int(res[0])),
|
|
self._plotter.get_style("tht-resistor-band-colors", int(res[1])),
|
|
self._plotter.get_style("tht-resistor-band-colors", int(power)),
|
|
self._plotter.get_style("tht-resistor-band-colors", tolerance)
|
|
]
|
|
|
|
if ref in self.resistor_values:
|
|
if self.resistor_values[ref].flip_bands:
|
|
resistor_colors.reverse()
|
|
|
|
for res_i, res_c in enumerate(resistor_colors):
|
|
band = root.find(f".//*[@id='{id_prefix}res_band{res_i+1}']")
|
|
s = band.attrib["style"].split(";")
|
|
for i in range(len(s)):
|
|
if s[i].startswith('fill:'):
|
|
s_split = s[i].split(':')
|
|
s_split[1] = res_c
|
|
s[i] = ':'.join(s_split)
|
|
elif s[i].startswith('display:'):
|
|
s_split = s[i].split(':')
|
|
s_split[1] = 'inline'
|
|
s[i] = ':'.join(s_split)
|
|
band.attrib["style"] = ";".join(s)
|
|
except UserWarning as e:
|
|
self._plotter.yield_warning("resistor", f"Cannot color-code resistor {ref}: {e}")
|
|
return
|
|
|
|
def _get_resistance_from_value(self, value: str) -> Tuple[Decimal, str]:
|
|
res, tolerance = None, str(GS.global_default_resistor_tolerance)+"%"
|
|
value_l = value.split(" ", maxsplit=1)
|
|
try:
|
|
res = read_resistance(value_l[0])
|
|
except ValueError:
|
|
raise UserWarning(f"Invalid resistor value {value_l[0]}")
|
|
if len(value_l) > 1:
|
|
t_string = value_l[1].strip().replace(" ", "")
|
|
if "%" in t_string:
|
|
s = self._plotter.get_style("tht-resistor-band-colors")
|
|
if not isinstance(s, dict):
|
|
raise RuntimeError(f"Invalid style specified, tht-resistor-band-colors should be dictionary, got {type(s)}")
|
|
if t_string.strip() not in s:
|
|
raise UserWarning(f"Invalid resistor tolerance {value_l[1]}")
|
|
tolerance = t_string
|
|
return res, tolerance
|
|
|
|
|
|
@dataclass
|
|
class PlotPlaceholders(PlotInterface):
|
|
def render(self, plotter: PcbPlotter) -> None:
|
|
self._plotter = plotter
|
|
plotter.walk_components(invert_side=False, callback=self._append_placeholder)
|
|
|
|
def _append_placeholder(self, lib: str, name: str, ref: str, value: str,
|
|
position: Tuple[int, int, float]) -> None:
|
|
p = etree.Element("rect",
|
|
x=str(self._plotter.ki2svg(position[0] - mm2ki(0.5))),
|
|
y=str(self._plotter.ki2svg(position[1] - mm2ki(0.5))),
|
|
width=str(self._plotter.ki2svg(mm2ki(1))), height=str(self._plotter.ki2svg(mm2ki(1))), style="fill:red;")
|
|
self._plotter.append_component_element(p)
|
|
|
|
@dataclass
|
|
class PlotVCuts(PlotInterface):
|
|
layer: int = pcbnew.Cmts_User
|
|
|
|
def render(self, plotter: PcbPlotter) -> None:
|
|
self._plotter = plotter
|
|
self._plotter.execute_plot_plan([
|
|
PlotAction("vcuts", [self.layer], self._process_vcuts)
|
|
])
|
|
|
|
def _process_vcuts(self, name: str, source_filename: str) -> None:
|
|
layer = etree.Element("g", id="substrate-vcuts",
|
|
style="fill:{0}; stroke:{0};".format(self._plotter.get_style("vcut")))
|
|
for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())):
|
|
# Forbidden colors = workaround - KiCAD plots vias white
|
|
# See https://gitlab.com/kicad/code/kicad/-/issues/10491
|
|
if not strip_style_svg(element, keys=["fill", "stroke"],
|
|
forbidden_colors=["#ffffff"]):
|
|
layer.append(element)
|
|
self._plotter.append_board_element(layer)
|
|
|
|
@dataclass
|
|
class PlotPaste(PlotInterface):
|
|
def render(self, plotter: PcbPlotter) -> None:
|
|
plan: List[PlotAction] = []
|
|
if plotter.render_back:
|
|
plan = [PlotAction("paste", [pcbnew.B_Paste], self._process_paste)]
|
|
else:
|
|
plan = [PlotAction("paste", [pcbnew.F_Paste], self._process_paste)]
|
|
self._plotter = plotter
|
|
self._plotter.execute_plot_plan(plan)
|
|
|
|
def _process_paste(self, name: str, source_filename: str) -> None:
|
|
layer = etree.Element("g", id="substrate-paste",
|
|
style="fill:{0}; stroke:{0};".format(self._plotter.get_style("paste")))
|
|
for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())):
|
|
if not strip_style_svg(element, keys=["fill", "stroke"],
|
|
forbidden_colors=["#ffffff"]):
|
|
layer.append(element)
|
|
self._plotter.append_board_element(layer)
|
|
|
|
|
|
class PcbPlotter():
|
|
"""
|
|
PcbPlotter encapsulates all the machinery with PcbDraw plotting of SVG. It
|
|
mainly serves as a builder (to step-by-step specify all options) and also to
|
|
avoid passing many arguments between auxiliary functions
|
|
"""
|
|
def __init__(self, boardFile: Union[str, pcbnew.BOARD]):
|
|
self._unique_counter: int = 1
|
|
if isinstance(boardFile, str):
|
|
try:
|
|
self.board: pcbnew.BOARD = pcbnew.LoadBoard(boardFile)
|
|
except IOError:
|
|
raise IOError(f"Cannot open board '{boardFile}'") from None
|
|
else:
|
|
self.board = boardFile
|
|
self.render_back: bool = False
|
|
self.mirror: bool = False
|
|
self.plot_plan: List[PlotInterface] = [
|
|
PlotSubstrate(),
|
|
PlotComponents(),
|
|
]
|
|
|
|
self.data_path: List[str] = [] # Base paths for libraries lookup
|
|
self.libs: List[str] = [] # Names of available libraries
|
|
self._libs_path: List[str] = []
|
|
self._svg_precision = 6 # The SVG precision for KiCAD 6 plotting
|
|
self._svg_divider = 1
|
|
|
|
self.style: Any = {} # Color scheme
|
|
self.margin: tuple = (0, 0, 0, 0) # Margin of the resulting document
|
|
self.compute_bbox: bool = False # Adjust the bbox using the SVG drawings
|
|
self.kicad_bb_only_edge: bool = False # Use the PCB edge when asking the BBox to KiCad
|
|
self.svg_precision: int = 4 # KiCad 6 SVG scale (1 mm == 10 ** svg_precision)
|
|
|
|
self.yield_warning: Callable[[str, str], None] = lambda tag, msg: None # Handle warnings
|
|
|
|
if isV7():
|
|
self.ki2svg = self._ki2svg_v7
|
|
self.svg2ki = self._svg2ki_v7
|
|
elif isV6():
|
|
self.ki2svg = self._ki2svg_v6
|
|
self.svg2ki = self._svg2ki_v6
|
|
else:
|
|
self.ki2svg = self._ki2svg_v5
|
|
self.svg2ki = self._svg2ki_v5
|
|
|
|
@property
|
|
def svg_precision(self) -> int:
|
|
return self._svg_precision
|
|
|
|
@svg_precision.setter
|
|
def svg_precision(self, value: int) -> None:
|
|
# We need a setter as KiCAD silently clamps the value, so we also have
|
|
# to clamp.
|
|
if value < 3:
|
|
value = 3
|
|
if value > 6:
|
|
value = 6
|
|
self._svg_precision = value
|
|
self._svg_divider = 10 ** (6 - self.svg_precision)
|
|
|
|
def plot(self) -> etree.ElementTree:
|
|
"""
|
|
Plot the board based on the arguments stored in this class. Returns
|
|
SVG tree that you can either save or post-process as you wish.
|
|
"""
|
|
self._build_libs_path()
|
|
self._setup_document(self.render_back, self.mirror)
|
|
for plotter in self.plot_plan:
|
|
plotter.render(self)
|
|
remove_empty_elems(self._document.getroot())
|
|
remove_inkscape_annotation(self._document.getroot())
|
|
self._shrink_svg(self._document, self.margin, self.compute_bbox)
|
|
return self._document
|
|
|
|
|
|
def walk_components(self, invert_side: bool,
|
|
callback: Callable[[str, str, str, str, Tuple[int, int, float]], None]) -> None:
|
|
"""
|
|
Invokes callback on all components in the board. The callback takes:
|
|
- library name of the component
|
|
- footprint name of the component
|
|
- reference of the component
|
|
- value of the component
|
|
- position of the component
|
|
|
|
The position is adjusted based on what side we are rendering
|
|
"""
|
|
render_back = not self.render_back if invert_side else self.render_back
|
|
for footprint in self.board.GetFootprints():
|
|
if (str(footprint.GetLayerName()) in ["Back", "B.Cu"] and not render_back) or \
|
|
(str(footprint.GetLayerName()) in ["Top", "F.Cu"] and render_back):
|
|
continue
|
|
lib = str(footprint.GetFPID().GetLibNickname()).strip()
|
|
name = str(footprint.GetFPID().GetLibItemName()).strip()
|
|
value = footprint.GetValue().strip()
|
|
if not LEGACY_KICAD:
|
|
# Look for a tolerance in the properties
|
|
prop = footprint.GetProperties()
|
|
tol = next(filter(lambda x: x, map(prop.get, GS.global_field_tolerance)), None)
|
|
if tol:
|
|
value = value+' '+tol
|
|
ref = footprint.GetReference().strip()
|
|
center = footprint.GetPosition()
|
|
orient = math.radians(footprint.GetOrientation().AsDegrees())
|
|
pos = (center.x, center.y, orient)
|
|
callback(lib, name, ref, value, pos)
|
|
|
|
def get_def_slot(self, tag_name: str, id: str) -> etree.SubElement:
|
|
"""
|
|
Creates a new definition slot and returns the tag
|
|
"""
|
|
return etree.SubElement(self._defs, tag_name, id=id)
|
|
|
|
def append_board_element(self, element: etree.Element) -> None:
|
|
"""
|
|
Add new element into the board container
|
|
"""
|
|
self._board_cont.append(element)
|
|
|
|
def append_component_element(self, element: etree.Element) -> None:
|
|
"""
|
|
Add new element into board container
|
|
"""
|
|
self._comp_cont.append(element)
|
|
|
|
def append_highlight_element(self, element: etree.Element) -> None:
|
|
"""
|
|
Add new element into highlight container
|
|
"""
|
|
self._high_cont.append(element)
|
|
|
|
def setup_builtin_data_path(self) -> None:
|
|
"""
|
|
Add PcbDraw built-in libraries to the search path for libraries
|
|
"""
|
|
self.data_path.append(os.path.join(PKG_BASE, "resources"))
|
|
|
|
def setup_global_data_path(self) -> None:
|
|
"""
|
|
Add global installation paths to the search path for libraries.
|
|
"""
|
|
self.data_path += get_global_datapaths()
|
|
|
|
def setup_arbitrary_data_path(self, path: str) -> None:
|
|
"""
|
|
Add an arbitrary data path
|
|
"""
|
|
self.data_path.append(os.path.realpath(path))
|
|
|
|
def setup_env_data_path(self) -> None:
|
|
"""
|
|
Add search paths from the env variable PCBDRAW_LIB_PATH
|
|
"""
|
|
paths = os.environ.get("PCBDRAW_LIB_PATH", "").split(":")
|
|
self.data_path += filter(lambda x: len(x) > 0, paths)
|
|
|
|
def resolve_style(self, name: str) -> None:
|
|
"""
|
|
Given a name of style, find the corresponding file and load it
|
|
"""
|
|
path = self._find_data_file(name, ".json", "styles")
|
|
if path is None:
|
|
raise RuntimeError(f"Cannot locate resource {name}; explored paths:\n"
|
|
+ "\n".join([f"- {x}" for x in self.data_path]))
|
|
self.style = load_style(path)
|
|
|
|
def unique_prefix(self) -> str:
|
|
pref = f"pref_{self._unique_counter}"
|
|
self._unique_counter += 1
|
|
return pref
|
|
|
|
def _find_data_file(self, name: str, extension: str, subdir: str) -> Optional[str]:
|
|
return find_data_file(name, extension, self.data_path, subdir)
|
|
|
|
def _build_libs_path(self) -> None:
|
|
self._libs_path = []
|
|
for l in self.libs:
|
|
self._libs_path += [os.path.join(p, l) for p in self.data_path]
|
|
for l in self.libs:
|
|
self._libs_path += [os.path.join(p, "footprints", l) for p in self.data_path]
|
|
self._libs_path = [x for x in self._libs_path if os.path.exists(x)]
|
|
|
|
def _get_model_file(self, lib: str, name: str) -> Optional[str]:
|
|
"""
|
|
Find model file in the configured libraries. If it doesn't exists,
|
|
return None.
|
|
"""
|
|
for path in self._libs_path:
|
|
f = os.path.join(path, lib, name + ".svg")
|
|
if os.path.isfile(f):
|
|
return f
|
|
return None
|
|
|
|
def get_style(self, *args: Union[str, int]) -> Any:
|
|
try:
|
|
value = self.style
|
|
for key in args:
|
|
value = value[key]
|
|
return value
|
|
except KeyError:
|
|
try:
|
|
value = default_style
|
|
for key in args:
|
|
value = value[key]
|
|
return value
|
|
except KeyError as e:
|
|
raise e from None
|
|
|
|
def execute_plot_plan(self, to_plot: List[PlotAction]) -> None:
|
|
"""
|
|
Given a plotting plan, plots the layers and invokes a post-processing
|
|
callback on the generated files
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
pctl = pcbnew.PLOT_CONTROLLER(self.board)
|
|
popt = pctl.GetPlotOptions()
|
|
popt.SetOutputDirectory(tmp)
|
|
popt.SetScale(1)
|
|
popt.SetMirror(False)
|
|
popt.SetSubtractMaskFromSilk(True)
|
|
popt.SetDrillMarksType(0) # NO_DRILL_SHAPE
|
|
try:
|
|
popt.SetPlotOutlineMode(False)
|
|
except:
|
|
# Method does not exist in older versions of KiCad
|
|
pass
|
|
popt.SetTextMode(pcbnew.PLOT_TEXT_MODE_STROKE)
|
|
if isV6():
|
|
popt.SetSvgPrecision(self.svg_precision, False)
|
|
elif isV7():
|
|
popt.SetSvgPrecision(self.svg_precision)
|
|
for action in to_plot:
|
|
if len(action.layers) == 0:
|
|
continue
|
|
# Set the filename before opening the file as KiCAD 6.0.8
|
|
# requires it even for the SVG format
|
|
pctl.SetLayer(action.layers[0])
|
|
pctl.OpenPlotfile(action.name, pcbnew.PLOT_FORMAT_SVG, action.name)
|
|
for l in action.layers:
|
|
pctl.SetColorMode(False)
|
|
pctl.SetLayer(l)
|
|
pctl.PlotLayer()
|
|
pctl.ClosePlot()
|
|
for action in to_plot:
|
|
for svg_file in os.listdir(tmp):
|
|
if svg_file.endswith(f"-{action.name}.svg"):
|
|
action.action(action.name, os.path.join(tmp, svg_file))
|
|
|
|
def _ki2svg_v6(self, x: int) -> float:
|
|
"""
|
|
Convert dimensions from KiCAD to SVG. This method assumes the dimensions
|
|
use self.svg_precision.
|
|
"""
|
|
return x / self._svg_divider
|
|
|
|
|
|
def _svg2ki_v6(self, x: float) -> int:
|
|
"""
|
|
Convert dimensions from SVG to KiCAD. This method assumes the dimensions
|
|
use self.svg_precision.
|
|
"""
|
|
return int(x * self._svg_divider)
|
|
|
|
def _ki2svg_v5(self, x: int) -> float:
|
|
return ki2dmil(x)
|
|
|
|
def _svg2ki_v5(self, x: float) -> int:
|
|
return dmil2ki(x)
|
|
|
|
def _svg2ki_v7(self, x: float) -> int:
|
|
return int(pcbnew.FromMM(x))
|
|
|
|
def _ki2svg_v7(self, x: int) -> float:
|
|
return float(pcbnew.ToMM(x))
|
|
|
|
def _shrink_svg(self, svg: etree.ElementTree, margin: tuple, compute_bbox: bool=False) -> None:
|
|
"""
|
|
Shrink the SVG canvas to the size of the drawing. Add margin in
|
|
KiCAD units.
|
|
"""
|
|
root = svg.getroot()
|
|
if compute_bbox:
|
|
# Compute the bbox using the SVG drawings, so things outside the PCB
|
|
# outline are counted.
|
|
# We have to overcome the limitation of different base types between
|
|
# PcbDraw and svgpathtools
|
|
from xml.etree.ElementTree import fromstring as xmlParse
|
|
|
|
from lxml.etree import tostring as serializeXml # type: ignore
|
|
from . import svgpathtools # type: ignore
|
|
paths = svgpathtools.document.flattened_paths(xmlParse(serializeXml(svg)))
|
|
|
|
if len(paths) == 0:
|
|
return
|
|
bbox = paths[0].bbox()
|
|
for x in paths:
|
|
b = x.bbox()
|
|
if hack_is_valid_bbox(b):
|
|
bbox = b
|
|
break
|
|
for x in paths:
|
|
box = x.bbox()
|
|
if not hack_is_valid_bbox(box):
|
|
# This is a hack due to instability in svpathtools
|
|
continue
|
|
bbox = merge_bbox(bbox, box)
|
|
bbox = list(bbox)
|
|
else:
|
|
# Get the current viewBox
|
|
# This is computed by KiCad using the PCB edge
|
|
x, y, vw, vh = [float(x) for x in root.attrib["viewBox"].split()]
|
|
bbox = [x, x+vw, y, y+vh]
|
|
|
|
# Apply the margin
|
|
bbox[0] -= self.ki2svg(margin[0])
|
|
bbox[1] += self.ki2svg(margin[1])
|
|
bbox[2] -= self.ki2svg(margin[2])
|
|
bbox[3] += self.ki2svg(margin[3])
|
|
|
|
root.attrib["viewBox"] = "{} {} {} {}".format(
|
|
bbox[0], bbox[2],
|
|
bbox[1] - bbox[0], bbox[3] - bbox[2]
|
|
)
|
|
root.attrib["width"] = str(ki2mm(self.svg2ki(bbox[1] - bbox[0]))) + "mm"
|
|
root.attrib["height"] = str(ki2mm(self.svg2ki(bbox[3] - bbox[2]))) + "mm"
|
|
|
|
def _setup_document(self, render_back: bool, mirror: bool) -> None:
|
|
bb = self.board.ComputeBoundingBox(aBoardEdgesOnly=self.kicad_bb_only_edge)
|
|
self.boardsize = bb
|
|
transform_string = ""
|
|
# Let me briefly explain what's going on. KiCAD outputs SVG in user units,
|
|
# where 1 unit is 1/10 of an inch (in v5) or KiCAD native unit (v6). So to
|
|
# make our life easy, we respect it and make our document also in the
|
|
# corresponding units. Therefore we specify the outer dimensions in
|
|
# millimeters and specify the board area.
|
|
if(render_back ^ mirror):
|
|
transform_string = "scale(-1,1)"
|
|
self._document = empty_svg(
|
|
width=f"{ki2mm(bb.GetWidth())}mm",
|
|
height=f"{ki2mm(bb.GetHeight())}mm",
|
|
viewBox=f"{self.ki2svg(-bb.GetWidth() - bb.GetX())} {self.ki2svg(bb.GetY())} {self.ki2svg(bb.GetWidth())} {self.ki2svg(bb.GetHeight())}")
|
|
else:
|
|
self._document = empty_svg(
|
|
width=f"{ki2mm(bb.GetWidth())}mm",
|
|
height=f"{ki2mm(bb.GetHeight())}mm",
|
|
viewBox=f"{self.ki2svg(bb.GetX())} {self.ki2svg(bb.GetY())} {self.ki2svg(bb.GetWidth())} {self.ki2svg(bb.GetHeight())}")
|
|
|
|
self._defs = etree.SubElement(self._document.getroot(), "defs")
|
|
self._board_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string)
|
|
if self.get_style("highlight-on-top"):
|
|
self._comp_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string)
|
|
self._high_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string)
|
|
else:
|
|
self._high_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string)
|
|
self._comp_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string)
|
|
|
|
self._board_cont.attrib["id"] = "boardContainer"
|
|
self._comp_cont.attrib["id"] = "componentContainer"
|
|
self._high_cont.attrib["id"] = "highlightContainer"
|