#!/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( """ Picture generated by PcbDraw Picture generated by PcbDraw """)) 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"