diff --git a/pcbdraw/convert.py b/pcbdraw/convert.py
index 522a590..9aeeff2 100644
--- a/pcbdraw/convert.py
+++ b/pcbdraw/convert.py
@@ -1,6 +1,11 @@
import platform
import subprocess
import textwrap
+import os
+from typing import Union
+from tempfile import TemporaryDirectory
+from PIL import Image
+from lxml.etree import _ElementTree
# Converting SVG to bitmap is a hard problem. We used Wand (and thus
# imagemagick) to do the conversion. However, imagemagick is really hard to
@@ -59,6 +64,36 @@ def svgToPng(inputFilename, outputFilename, dpi=300):
message += textwrap.indent(m, " ")
raise RuntimeError(message)
+def save(image: Union[_ElementTree, Image.Image], filename: str, dpi: int=600):
+ """
+ Given an SVG tree or an image, save to a filename. The format is deduced
+ from the extension.
+ """
+ ftype = os.path.splitext(filename)[1][1:].lower()
+ if isinstance(image, Image.Image):
+ if ftype not in ["jpg", "jpeg", "png", "bmp"]:
+ raise TypeError(f"Cannot save bitmap image into {ftype}")
+ image.save(filename)
+ return
+ if isinstance(image, _ElementTree):
+ if ftype == "svg":
+ image.write(filename)
+ return
+ with TemporaryDirectory() as d:
+ svg_filename = os.path.join(d, "image.svg")
+ if ftype == "png":
+ png_filename = filename
+ else:
+ png_filename = os.path.join(d, "image.png")
+ image.write(svg_filename)
+ svgToPng(svg_filename, png_filename, dpi=dpi)
+ if ftype == "png":
+ return
+ Image.open(png_filename).convert("RGB").save(filename)
+ return
+ raise TypeError(f"Unknown image type: {type(image)}")
+
+
if __name__ == "__main__":
import sys
svgToPng(sys.argv[1], sys.argv[2])
diff --git a/pcbdraw/pcbdraw.py b/pcbdraw/pcbdraw.py
deleted file mode 100755
index 7d865ba..0000000
--- a/pcbdraw/pcbdraw.py
+++ /dev/null
@@ -1,1095 +0,0 @@
-#!/usr/bin/env python3
-
-import argparse
-import json
-import math
-import os
-import re
-import sys
-import tempfile
-import sysconfig
-import numpy as np
-import svgpathtools
-import engineering_notation
-import decimal
-
-from pcbnewTransition import pcbnew, KICAD_VERSION, isV6
-from lxml import etree, objectify
-
-from pcbdraw import convert
-
-
-# Give more priority to local modules than installed versions
-PKG_BASE = os.path.dirname(__file__)
-sys.path.insert(0, os.path.dirname(os.path.abspath(PKG_BASE)))
-from pcbdraw import __version__
-
-etree.register_namespace("xlink", "http://www.w3.org/1999/xlink")
-
-STYLES_SUBDIR = 'styles'
-FOOTPRINTS_SUBDIR = 'footprints'
-data_path = [PKG_BASE]
-
-default_style = {
- "copper": "#417e5a",
- "board": "#4ca06c",
- "silk": "#f0f0f0",
- "pads": "#b5ae30",
- "outline": "#000000",
- "clad": "#9c6b28",
- "vcut": "#bf2600",
- "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%': '#805500',
- '2%': '#ff0000',
- '0.5%': '#00cc11',
- '0.25%': '#0000cc',
- '0.1%': '#cc00cc',
- '0.05%': '#666666',
- '5%': '#ffc800',
- '10%': '#d9d9d9',
- }
-}
-
-float_re = r'([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)'
-
-class SvgPathItem:
- def __init__(self, path):
- path = re.sub(r"([MLA])(-?\d+)", r"\1 \2", path)
- path = re.split("[, ]", path)
- path = list(filter(lambda x: x, path))
- if path[0] != "M":
- raise SyntaxError("Only paths with absolute position are supported")
- self.start = tuple(map(float, path[1:3]))
- path = path[3:]
- if path[0] == "L":
- x = float(path[1])
- y = float(path[2])
- self.end = (x, y)
- self.type = path[0]
- self.args = None
- elif path[0] == "A":
- args = list(map(float, path[1:8]))
- self.end = (args[5], args[6])
- self.args = args[0:5]
- self.type = path[0]
- else:
- raise SyntaxError("Unsupported path element " + path[0])
-
- @staticmethod
- def is_same(p1, p2):
- dx = p1[0] - p2[0]
- dy = p1[1] - p2[1]
- return math.sqrt(dx*dx+dy*dy) < 5
-
- def format(self, first):
- 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):
- self.start, self.end = self.end, self.start
- if self.type == "A":
- self.args[4] = 1 if self.args[4] < 0.5 else 0
-
-def unique_prefix():
- unique_prefix.counter += 1
- return "pref_" + str(unique_prefix.counter)
-unique_prefix.counter = 0
-
-def matrix(data):
- return np.array(data, dtype=np.float32)
-
-def extract_arg(args, index, default=None):
- """
- 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):
- """
- 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 = np.cos(np.radians(args[0]))
- sina = np.sin(np.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]]))
- if op == 'skewX':
- tana = np.tan(np.radians(args[0]))
- m = np.matmul(m, matrix([
- [1, tana, 0],
- [0, 1, 0],
- [0, 0, 1]]))
- if op == 'skewY':
- tana = np.tan(np.radians(args[0]))
- m = np.matmul(m, matrix([
- [1, 0, 0],
- [tana, 1, 0],
- [0, 0, 1]]))
- return m
-
-def collect_transformation(element, root=None):
- """
- 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"]
- return np.matmul(m, to_trans_matrix(trans))
-
-def element_position(element, root=None):
- 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 ki2dmil(val):
- return val // 2540
-
-def dmil2ki(val):
- return val * 2540
-
-def ki2mm(val):
- return val / 1000000.0
-
-def mm2ki(val):
- return val * 1000000
-
-# KiCAD 5 and KiCAD 6 use different units of the SVG
-ki2svg = (lambda x: x) if isV6(KICAD_VERSION) else ki2dmil
-svg2ki = (lambda x: x) if isV6(KICAD_VERSION) else dmil2ki
-
-def to_kicad_basic_units(val):
- """
- 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)
-
-def to_user_units(val):
- 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 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
-
-def make_XML_identifier(s):
- """
- 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 extract_resistor_settings(args):
- tht_resistor_settings = {}
- if args.resistor_values:
- split_list = args.resistor_values.split(",")
- for r in split_list:
- r_s = r.split(":")
- tht_resistor_settings[r_s[0]] = {'override_val': r_s[1]}
- if args.resistor_flip:
- for r in args.resistor_flip.split(","):
- if r in tht_resistor_settings:
- tht_resistor_settings[r]['flip'] = True
- else:
- tht_resistor_settings[r] = {'flip': True}
- return tht_resistor_settings
-
-def read_svg_unique(filename, return_prefix = False):
- prefix = unique_prefix() + "_"
- 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"]
- if return_prefix:
- return root, prefix
- return root
-
-def extract_svg_content(root):
- # 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_fill_svg(root, forbidden_colors):
- keys = ["fill", "stroke"]
- 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):
- document = etree.ElementTree(etree.fromstring(
- """
-
- """))
- root = document.getroot()
- for key, value in attrs.items():
- root.attrib[key] = value
- return document
-
-def get_board_polygon(svg_elements):
- """
- 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":
- elements.append(SvgPathItem(svg_element.attrib["d"]))
- 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):
- size = len(outline)
- for i, e in enumerate(elements):
- if SvgPathItem.is_same(outline[0].start, e.end):
- outline.insert(0, e)
- elif SvgPathItem.is_same(outline[0].start, e.start):
- e.flip()
- outline.insert(0, e)
- elif SvgPathItem.is_same(outline[-1].end, e.start):
- outline.append(e)
- elif SvgPathItem.is_same(outline[-1].end, e.end):
- e.flip()
- outline.append(e)
- else:
- continue
- del elements[i]
- break
- # ...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 process_board_substrate_layer(container, defs, name, source, colors, boardsize):
- layer = etree.SubElement(container, "g", id="substrate-" + name,
- style="fill:{0}; stroke:{0};".format(colors[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)):
- # Forbidden colors = workaround - KiCAD plots vias white
- # See https://gitlab.com/kicad/code/kicad/-/issues/10491
- if not strip_fill_svg(element, forbidden_colors=["#ffffff"]):
- layer.append(element)
-
-def process_board_substrate_base(container, defs, name, source, colors, boardsize):
- clipPath = etree.SubElement(defs, "clipPath")
- clipPath.attrib["id"] = "cut-off"
- clipPath.append(get_board_polygon(extract_svg_content(read_svg_unique(source))))
-
- layer = etree.SubElement(container, "g", id="substrate-"+name,
- style="fill:{0}; stroke:{0};".format(colors[name]))
- layer.append(get_board_polygon(extract_svg_content(read_svg_unique(source))))
- outline = etree.SubElement(layer, "g",
- style="fill:{0}; stroke: {0};".format(colors["outline"]))
- for element in extract_svg_content(read_svg_unique(source)):
- # Forbidden colors = workaround - KiCAD plots vias white
- # See https://gitlab.com/kicad/code/kicad/-/issues/10491
- if not strip_fill_svg(element, forbidden_colors=["#ffffff"]):
- layer.append(element)
-
-def process_board_substrate_mask(container, defs, name, source, colors, boardsize):
- mask = etree.SubElement(defs, "mask")
- mask.attrib["id"] = name
- for element in extract_svg_content(read_svg_unique(source)):
- 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 = etree.SubElement(defs, "mask")
- silkMask.attrib["id"] = name + "-silkscreen"
- bg = etree.SubElement(silkMask, "rect", attrib={
- "x": str(ki2svg(boardsize.GetX())),
- "y": str(ki2svg(boardsize.GetY())),
- "width": str(ki2svg(boardsize.GetWidth())),
- "height": str(ki2svg(boardsize.GetHeight())),
- "fill": "white"
- })
- for element in extract_svg_content(read_svg_unique(source)):
- # KiCAD plots black, no need to change fill
- silkMask.append(element)
-
-def get_layers(board, colors, defs, toPlot):
- """
- Plot given layers, process them and return them as
- """
- container = etree.Element('g')
- with tempfile.TemporaryDirectory() as tmp:
- pctl = pcbnew.PLOT_CONTROLLER(board)
- popt = pctl.GetPlotOptions()
- popt.SetOutputDirectory(tmp)
- popt.SetScale(1)
- popt.SetMirror(False)
- popt.SetSubtractMaskFromSilk(True)
- try:
- popt.SetPlotOutlineMode(False)
- except:
- # Method does not exist in older versions of KiCad
- pass
- popt.SetTextMode(pcbnew.PLOT_TEXT_MODE_STROKE)
- for f, layers, _ in toPlot:
- pctl.OpenPlotfile(f, pcbnew.PLOT_FORMAT_SVG, f)
- for l in layers:
- pctl.SetColorMode(False)
- pctl.SetLayer(l)
- pctl.PlotLayer()
- pctl.ClosePlot()
- boardsize = board.ComputeBoundingBox()
- for f, _, process in toPlot:
- for svg_file in os.listdir(tmp):
- if svg_file.endswith("-" + f + ".svg"):
- process(container, defs, f, os.path.join(tmp, svg_file), colors, boardsize)
- return container
-
-def get_board_substrate(board, colors, defs, holes, back):
- """
- Plots all front layers from the board and arranges them in a visually appealing style.
- return SVG g element with the board substrate
- """
- toPlot = []
- if back:
- toPlot = [
- ("board", [pcbnew.Edge_Cuts], process_board_substrate_base),
- ("clad", [pcbnew.B_Mask], process_board_substrate_layer),
- ("copper", [pcbnew.B_Cu], process_board_substrate_layer),
- ("pads", [pcbnew.B_Cu], process_board_substrate_layer),
- ("pads-mask", [pcbnew.B_Mask], process_board_substrate_mask),
- ("silk", [pcbnew.B_SilkS], process_board_substrate_layer),
- ("outline", [pcbnew.Edge_Cuts], process_board_substrate_layer)]
- else:
- toPlot = [
- ("board", [pcbnew.Edge_Cuts], process_board_substrate_base),
- ("clad", [pcbnew.F_Mask], process_board_substrate_layer),
- ("copper", [pcbnew.F_Cu], process_board_substrate_layer),
- ("pads", [pcbnew.F_Cu], process_board_substrate_layer),
- ("pads-mask", [pcbnew.F_Mask], process_board_substrate_mask),
- ("silk", [pcbnew.F_SilkS], process_board_substrate_layer),
- ("outline", [pcbnew.Edge_Cuts], process_board_substrate_layer)]
- container = etree.Element('g')
- container.attrib["clip-path"] = "url(#cut-off)"
-
- with tempfile.TemporaryDirectory() as tmp:
- pctl = pcbnew.PLOT_CONTROLLER(board)
- popt = pctl.GetPlotOptions()
- popt.SetOutputDirectory(tmp)
- popt.SetScale(1)
- popt.SetMirror(False)
- popt.SetSubtractMaskFromSilk(True)
- try:
- popt.SetPlotOutlineMode(False)
- except:
- # Method does not exist in older versions of KiCad
- pass
- popt.SetTextMode(pcbnew.PLOT_TEXT_MODE_STROKE)
- for f, layers, _ in toPlot:
- pctl.OpenPlotfile(f, pcbnew.PLOT_FORMAT_SVG, f)
- for l in layers:
- pctl.SetColorMode(False)
- pctl.SetLayer(l)
- pctl.PlotLayer()
- pctl.ClosePlot()
- boardsize = board.ComputeBoundingBox()
- for f, _, process in toPlot:
- for svg_file in os.listdir(tmp):
- if svg_file.endswith("-" + f + ".svg"):
- process(container, defs, f, os.path.join(tmp, svg_file), colors, boardsize)
- if holes:
- get_hole_mask(board, defs)
- container.attrib["mask"] = "url(#hole-mask)"
- return container
-
-def walk_components(board, back, export):
- for module in board.GetFootprints():
- # Top is for Eagle boards imported to KiCAD
- if (str(module.GetLayerName()) in ["Back", "B.Cu"] and not back) or \
- (str(module.GetLayerName()) in ["Top", "F.Cu"] and back):
- continue
- lib = str(module.GetFPID().GetLibNickname()).strip()
- try:
- name = str(module.GetFPID().GetFootprintName()).strip()
- except AttributeError:
- # it seems we are working on Kicad >4.0.6, which has a changed method name
- name = str(module.GetFPID().GetLibItemName()).strip()
- value = module.GetValue().strip()
- ref = module.GetReference().strip()
- center = module.GetPosition()
- orient = math.radians(module.GetOrientation() / 10)
- pos = (center.x, center.y, orient)
- export(lib, name, value, ref, pos)
-
-def get_hole_mask(board, defs):
- mask = etree.SubElement(defs, "mask", id="hole-mask")
- container = etree.SubElement(mask, "g")
-
- bb = board.ComputeBoundingBox()
- bg = etree.SubElement(container, "rect", x="0", y="0", fill="white")
- bg.attrib["x"] = str(ki2svg(bb.GetX()))
- bg.attrib["y"] = str(ki2svg(bb.GetY()))
- bg.attrib["width"] = str(ki2svg(bb.GetWidth()))
- bg.attrib["height"] = str(ki2svg(bb.GetHeight()))
-
- toPlot = [] # Tuple: position, orientation, drillsize
- for module in board.GetFootprints():
- if module.GetPadCount() == 0:
- continue
- for pad in module.Pads():
- toPlot.append((
- pad.GetPosition(),
- pad.GetOrientation(),
- pad.GetDrillSize()
- ))
- for track in board.GetTracks():
- if not isinstance(track, pcbnew.PCB_VIA) or not isV6(KICAD_VERSION):
- continue
- toPlot.append((
- track.GetPosition(),
- 0,
- (track.GetDrillValue(), track.GetDrillValue())
- ))
- for pos, padOrientation, drillSize in toPlot:
- pos.x = ki2svg(pos.x)
- pos.y = ki2svg(pos.y)
- size = list(map(ki2svg, 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(
- pos.x, pos.y, -padOrientation / 10)
-
-def resolve_remapping(lib, name, ref, remapping):
- if ref in remapping:
- lib, new_name = tuple(remapping[ref].split(":"))
- if name.endswith(".back"):
- name = new_name + ".back"
- else:
- name = new_name
- return lib, name
-
-def get_model_file(paths, lib, name, ref):
- """ Find model file in library considering component remapping """
- for path in paths:
- f = os.path.join(path, lib, name + ".svg")
- if os.path.isfile(f):
- return f
- return None
-
-def print_component(paths, lib, name, value, ref, pos, remapping={}):
- name, lib = resolve_remapping(lib, name, ref, remapping)
- f = get_model_file(paths, lib, name, ref)
- msg = "{} with package {}:{} at [{},{},{}] -> {}".format(
- ref, lib, name, pos[0], pos[1], math.degrees(pos[2]), f if f else "Not found")
- print(msg)
-
-def component_to_board_scale(svg):
- width = ki2svg(to_kicad_basic_units(svg.attrib["width"]))
- height = 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
-
-def get_resistance_from_value(value, ref, style, silent):
- res, tollerance = None, '5%'
- try:
- value = value.split(' ')
- res = engineering_notation.EngNumber(value[0])
- res = res.number
- if len(value) > 1:
- if '%' in value[1]:
- if value[1] not in style["tht-resistor-band-colors"]:
- raise UserWarning("Resistor's tolerance is invalid")
- tollerance = value[1]
- except decimal.InvalidOperation:
- if not silent:
- print("Resistor {}'s value is invalid".format(ref))
- except UserWarning:
- if not silent:
- print("Resistor {}'s tollerance ({}) is invalid, assuming 5%".format(ref, value[1]))
-
- return res, tollerance
-
-def color_resistor(ref, svg_prefix, res, tolerance, style, tht_resistor_settings, componentElement):
- if res is not None:
- power = math.floor(res.log10())-1
- res = int(res / 10**power)
- resistor_colors = [
- style["tht-resistor-band-colors"][int(str(res)[0])],
- style["tht-resistor-band-colors"][int(str(res)[1])],
- style["tht-resistor-band-colors"][int(power)],
- style["tht-resistor-band-colors"][tolerance],
- ]
- if tht_resistor_settings is not None:
- if ref in tht_resistor_settings:
- if 'flip' in tht_resistor_settings[ref]:
- if tht_resistor_settings[ref]['flip']:
- resistor_colors.reverse()
-
- for res_i, res_c in enumerate(resistor_colors):
- band = componentElement.find(".//*[@id='{}res_band{}']".format(svg_prefix, 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)
-
-def component_from_library(lib, name, value, ref, pos, usedComponents, comp,
- highlight, silent, no_warn_back, style, tht_resistor_settings):
-
- if not name:
- return
- if comp["filter"] is not None and ref not in comp["filter"]:
- return
-
- # If the part is a THT resistor, change it's value if the parameter custom_res_color has
- if tht_resistor_settings is not None:
- if ref in tht_resistor_settings:
- if 'override_val' in tht_resistor_settings[ref]:
- value = tht_resistor_settings[ref]['override_val']
-
- lib, name = resolve_remapping(lib, name, ref, comp["remapping"])
-
- unique_name = f"{lib}__{name}_{value}"
- if unique_name in usedComponents:
- componentInfo = usedComponents[unique_name]
- componentElement = etree.Element("use", attrib={"{http://www.w3.org/1999/xlink}href": "#" + componentInfo["id"]})
- else:
- f = get_model_file(comp["libraries"], lib, name, ref)
- if not f:
- if not silent:
- if name[-5:] != '.back' or not no_warn_back:
- print("Warning: component '{}' for footprint '{}' from library '{}' was not found".format(name, ref, lib))
- if comp["placeholder"]:
- etree.SubElement(comp["container"], "rect", x=str(ki2svg(pos[0] - mm2ki(0.5))), y=str(ki2svg(pos[1] - mm2ki(0.5))),
- width=str(ki2svg(mm2ki(1))), height=str(ki2svg(mm2ki(1))), style="fill:red;")
- return
- xml_id = make_XML_identifier(unique_name)
- componentElement = etree.Element("g", attrib={"id": xml_id})
- svg_tree, svg_prefix = read_svg_unique(f, True)
- for x in extract_svg_content(svg_tree):
- if x.tag in ["namedview", "metadata"]:
- continue
- componentElement.append(x)
- origin_x = 0
- origin_y = 0
- origin = componentElement.find(".//*[@id='origin']")
- if origin is not None:
- origin_x, origin_y = element_position(origin, root=componentElement)
- origin.getparent().remove(origin)
- else:
- print("Warning: component '{}' from library '{}' has no ORIGIN".format(name, lib))
- svg_scale_x, svg_scale_y = component_to_board_scale(svg_tree)
- componentInfo = {
- "id": xml_id,
- "origin_x": origin_x,
- "origin_y": origin_y,
- "scale_x": svg_scale_x,
- "scale_y": svg_scale_y,
- "width": svg_tree.attrib["width"],
- "height": svg_tree.attrib["height"]
- }
- usedComponents[unique_name] = componentInfo
-
- # If the library used is the THT resistor one, attempt to change the band colors if they exsist
- if componentElement.find(".//*[@id='{}res_band1']".format(svg_prefix)) is not None:
- res, tolerance = get_resistance_from_value(value, ref, style, value)
- if res is not None:
- color_resistor(ref, svg_prefix, res, tolerance, style, tht_resistor_settings, componentElement)
-
- comp["container"].append(etree.Comment("{}:{}".format(lib, name)))
- r = etree.SubElement(comp["container"], "g")
- r.append(componentElement)
- svg_scale_x = componentInfo["scale_x"]
- svg_scale_y = componentInfo["scale_y"]
- origin_x = componentInfo["origin_x"]
- origin_y = componentInfo["origin_y"]
- width = componentInfo["width"]
- height = componentInfo["height"]
-
- r.attrib["transform"] = \
- f"translate({ki2svg(pos[0])} {ki2svg(pos[1])}) " + \
- f"scale({svg_scale_x} {svg_scale_y}) " + \
- f"rotate({-math.degrees(pos[2])}) " + \
- f"translate({-origin_x}, {-origin_y})"
- if ref in highlight["items"]:
- w = ki2svg(to_kicad_basic_units(width))
- h = ki2svg(to_kicad_basic_units(height))
- build_highlight(highlight, w, h, pos, (origin_x, origin_y), (svg_scale_x, svg_scale_y), ref)
-
-def build_highlight(preset, width, height, pos, origin, scale, ref):
- h = etree.SubElement(preset["container"], "rect")
- h.attrib["style"] = preset["style"]
- h.attrib["x"] = str(-preset["padding"])
- h.attrib["y"] = str(-preset["padding"])
- h.attrib["width"] = str(width / scale[0] + 2 * preset["padding"])
- h.attrib["height"] = str(height / scale[1] + 2 * preset["padding"])
- h.attrib["transform"] = \
- f"translate({ki2svg(pos[0])} {ki2svg(pos[1])}) " + \
- f"scale({scale[0]} {scale[1]}) " + \
- f"rotate({-math.degrees(pos[2])}) " + \
- f"translate({-origin[0]}, {-origin[1]})"
- h.attrib["id"] = "h_" + ref
-
-def find_data_file(name, ext, subdir):
- if os.path.isfile(name):
- return name
- # Not a file here, needs extension?
- ln = len(ext)
- if name[-ln:] != ext:
- name += ext
- if os.path.isfile(name):
- return name
- # Try in the data path
- for p in data_path:
- fn = os.path.join(p, subdir, name)
- if os.path.isfile(fn):
- return fn
- raise RuntimeError("Missing '" + subdir + "' " + name)
-
-def load_style(style_file):
- if style_file.startswith("builtin:"):
- STYLES = os.path.join(PKG_BASE, "styles")
- style_file = os.path.join(STYLES, style_file[len("builtin:"):])
- else:
- style_file = find_data_file(style_file, '.json', STYLES_SUBDIR)
- try:
- with open(style_file, "r") as f:
- style = json.load(f)
- except IOError:
- raise RuntimeError("Cannot open style " + style_file)
- 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)))
- extra = set(style.keys()) - required
- for x in extra:
- print("Warning: extra key '" + x + "' in style")
- # ToDo: Check validity of colors (SVG compatible format)
- return style
-
-def load_remapping(remap_file):
- if not remap_file:
- return {}
- try:
- with open(remap_file, "r") as f:
- return json.load(f)
- except IOError:
- raise RuntimeError("Cannot open remapping file " + remap_file)
-
-def adjust_lib_path(path):
- if path == "default" or path == "kicad-default":
- return [os.path.join(p, FOOTPRINTS_SUBDIR, "KiCAD-base") for p in data_path]
- if path == "eagle-default":
- return [os.path.join(p, FOOTPRINTS_SUBDIR, "Eagle-export") for p in data_path]
- return [path]
-
-def setup_data_path():
- global data_path
- share = os.path.join('share', 'pcbdraw')
- entries = len(data_path)
- scheme_names = sysconfig.get_scheme_names()
- if os.name == 'posix':
- if 'posix_user' in scheme_names:
- data_path.append(os.path.join(sysconfig.get_path('data', 'posix_user'), share))
- if 'posix_prefix' in scheme_names:
- data_path.append(os.path.join(sysconfig.get_path('data', 'posix_prefix'), share))
- elif os.name == 'nt':
- if 'nt_user' in scheme_names:
- data_path.append(os.path.join(sysconfig.get_path('data', 'nt_user'), share))
- if 'nt' in scheme_names:
- data_path.append(os.path.join(sysconfig.get_path('data', 'nt'), share))
- if len(data_path) == entries:
- data_path.append(os.path.join(sysconfig.get_path('data'), share))
-
-def merge_bbox(left, right):
- """
- 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])
- ])
-
-def shrink_svg(svgfilepath, shrinkBorder):
- """
- Shrink the SVG canvas to the size of the drawing
- """
- document = svgpathtools.Document(svgfilepath)
- paths = document.paths()
- if len(paths) == 0:
- return
- bbox = paths[0].bbox()
- for x in paths:
- bbox = merge_bbox(bbox, x.bbox())
- bbox = list(bbox)
- bbox[0] -= ki2svg(mm2ki(shrinkBorder))
- bbox[1] += ki2svg(mm2ki(shrinkBorder))
- bbox[2] -= ki2svg(mm2ki(shrinkBorder))
- bbox[3] += ki2svg(mm2ki(shrinkBorder))
- svg = document.tree
- root = svg.getroot()
- root.attrib["viewBox"] = "{} {} {} {}".format(
- bbox[0], bbox[2],
- bbox[1] - bbox[0], bbox[3] - bbox[2]
- )
- root.attrib["width"] = str(ki2mm(svg2ki(bbox[1] - bbox[0]))) + "mm"
- root.attrib["height"] = str(ki2mm(svg2ki(bbox[3] - bbox[2]))) + "mm"
- document.save(svgfilepath)
-
-def remove_empty_elems(tree):
- """
- 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):
- 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)
-
-def postprocess_svg(svgfilepath, shrinkBorder):
- if shrinkBorder is not None:
- shrink_svg(svgfilepath, shrinkBorder)
- # TBA: Add compression and optimization
-
-def main():
- setup_data_path()
- epilog = "Searching for styles on: "
- c = len(data_path)
- for i, path in enumerate(data_path):
- epilog += "'"+os.path.join(path, 'styles')+"'"
- if i == c-2:
- epilog += " and "
- elif i != c-1:
- epilog += ", "
-
- parser = argparse.ArgumentParser(epilog=epilog)
- parser.add_argument("--version", action="version", version=f"PcbDraw {__version__}")
- parser.add_argument("-s", "--style", help="JSON file with board style")
- parser.add_argument("board", help=".kicad_pcb file to draw")
- parser.add_argument("output", help="destination for final SVG or PNG file")
- parser.add_argument("-l", "--libs", help="comma separated list of libraries; use default, kicad-default or eagle-default for built-in libraries", default="default")
- parser.add_argument("-p", "--placeholder", action="store_true",
- help="show placeholder for missing components")
- parser.add_argument("-m", "--remap",
- help="JSON file with map part reference to : to remap packages")
- parser.add_argument("-c", "--list-components", action="store_true",
- help="Dry run, just list the components")
- parser.add_argument("--no-drillholes", action="store_true", help="Do not make holes transparent")
- parser.add_argument("-b","--back", action="store_true", help="render the backside of the board")
- parser.add_argument("--mirror", action="store_true", help="mirror the board")
- parser.add_argument("-a", "--highlight", help="comma separated list of components to highlight")
- parser.add_argument("-f", "--filter", help="comma separated list of components to show")
- parser.add_argument("-v", "--vcuts", action="store_true", help="Render V-CUTS on the Cmts.User layer")
- parser.add_argument("--silent", action="store_true", help="Silent warning messages about missing footprints")
- parser.add_argument("--dpi", help="DPI for bitmap output", type=int, default=300)
- parser.add_argument("--no-warn-back", action="store_true", help="Don't show warnings about back footprints")
- parser.add_argument("--shrink", type=float, help="Shrink the canvas size to the size of the board. Specify border in millimeters")
- parser.add_argument("--resistor-values", help="A comma seperated list of what value to set to each resistor for the band colors. For example, \"R1:10k,R2:470\"")
- parser.add_argument("--resistor-flip", help="A comma seperated list of throughole resistors to flip the bands")
-
- args = parser.parse_args()
- libs = []
- for path in args.libs.split(','):
- libs.extend(adjust_lib_path(path))
- args.libs = libs
- args.highlight = args.highlight.split(',') if args.highlight is not None else []
- args.filter = args.filter.split(',') if args.filter is not None else None
-
- try:
- if args.style:
- style = load_style(args.style)
- else:
- style = default_style
- remapping = load_remapping(args.remap)
- except RuntimeError as e:
- print(e)
- sys.exit(1)
-
- # Check if there any keys in the given style that aren't in the default style (all valid keys)
- for s in style:
- if s not in default_style:
- raise UserWarning(f"Key {s} from the given style is invalid")
- # If some keys aren't in the loaded style compared to the default style, copy it from the default style
- for s in default_style:
- if s not in style:
- style[s] = default_style[s]
-
- tht_resistor_settings = extract_resistor_settings(args)
-
- if os.path.splitext(args.output)[-1].lower() not in [".svg", ".png"]:
- print("Output can be either an SVG or PNG")
- sys.exit(1)
-
- try:
- board = pcbnew.LoadBoard(args.board)
- except IOError:
- print("Cannot open board " + args.board)
- sys.exit(1)
-
- if args.list_components:
- walk_components(board, args.back,lambda lib, name, val, ref, pos:
- print_component(args.libs, lib, name, val, ref, pos,
- remapping=remapping))
- sys.exit(0)
-
- bb = board.ComputeBoundingBox()
- 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.
- document = empty_svg(
- width=f"{ki2mm(bb.GetWidth())}mm",
- height=f"{ki2mm(bb.GetHeight())}mm",
- viewBox=f"{ki2svg(bb.GetX())} {ki2svg(bb.GetY())} {ki2svg(bb.GetWidth())} {ki2svg(bb.GetHeight())}")
- if(args.back ^ args.mirror):
- transform_string = "scale(-1,1)"
- document = empty_svg(
- width=f"{ki2mm(bb.GetWidth())}mm",
- height=f"{ki2mm(bb.GetHeight())}mm",
- viewBox=f"{ki2svg(-bb.GetWidth() - bb.GetX())} {ki2svg(bb.GetY())} {ki2svg(bb.GetWidth())} {ki2svg(bb.GetHeight())}")
-
- defs = etree.SubElement(document.getroot(), "defs")
- board_cont = etree.SubElement(document.getroot(), "g", transform=transform_string)
- if style["highlight-on-top"]:
- comp_cont = etree.SubElement(document.getroot(), "g", transform=transform_string)
- high_cont = etree.SubElement(document.getroot(), "g", transform=transform_string)
- else:
- high_cont = etree.SubElement(document.getroot(), "g", transform=transform_string)
- comp_cont = etree.SubElement(document.getroot(), "g", transform=transform_string)
-
- board_cont.attrib["id"] = "boardContainer"
- comp_cont.attrib["id"] = "componentContainer"
- high_cont.attrib["id"] = "highlightContainer"
-
- components = {
- "container": comp_cont,
- "placeholder": args.placeholder,
- "remapping": remapping,
- "libraries": args.libs,
- "filter": args.filter
- }
-
- highlight = {
- "container": high_cont,
- "items": args.highlight,
- "style": style["highlight-style"],
- "padding": style["highlight-padding"]
- }
-
- board_cont.append(get_board_substrate(board, style, defs, not args.no_drillholes, args.back))
- if args.vcuts:
- board_cont.append(get_layers(board, style, defs, [("vcut", [pcbnew.Cmts_User], process_board_substrate_layer)]))
-
- usedComponents = {}
- walk_components(board, args.back, lambda lib, name, val, ref, pos:
- component_from_library(lib, name, val, ref, pos, usedComponents,
- components, highlight, args.silent, args.no_warn_back, style, tht_resistor_settings))
-
- # make another pass for search, and if found, render the back side of the component
- # the function will search for file with extension ".back.svg"
- walk_components(board, not args.back, lambda lib, name, val, ref, pos:
- component_from_library(lib, name+".back", val, ref, pos, usedComponents,
- components, highlight, args.silent, args.no_warn_back, style, tht_resistor_settings))
-
- remove_empty_elems(document.getroot())
- remove_inkscape_annotation(document.getroot())
-
- if args.output.endswith(".svg") or args.output.endswith(".SVG"):
- document.write(args.output)
- postprocess_svg(args.output, args.shrink)
- else:
- with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp_f:
- document.write(tmp_f)
- tmp_f.flush()
- postprocess_svg(tmp_f.name, args.shrink)
- tmp_f.flush()
- convert.svgToPng(tmp_f.name, args.output, dpi=args.dpi)
- tmp_f.close()
- os.unlink(tmp_f.name)
-
-if __name__ == '__main__':
- main()
diff --git a/pcbdraw/plot.py b/pcbdraw/plot.py
new file mode 100755
index 0000000..a7aea84
--- /dev/null
+++ b/pcbdraw/plot.py
@@ -0,0 +1,1186 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import decimal
+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
+
+import engineering_notation
+import numpy as np
+import svgpathtools
+from lxml import etree, objectify
+from pcbnewTransition import KICAD_VERSION, isV6, pcbnew
+
+# Give more priority to local modules than installed versions
+PKG_BASE = os.path.dirname(__file__)
+
+etree.register_namespace("xlink", "http://www.w3.org/1999/xlink")
+
+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%': '#805500',
+ '2%': '#ff0000',
+ '0.5%': '#00cc11',
+ '0.25%': '#0000cc',
+ '0.1%': '#cc00cc',
+ '0.05%': '#666666',
+ '5%': '#ffc800',
+ '10%': '#d9d9d9',
+ }
+}
+
+float_re = r'([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)'
+
+class SvgPathItem:
+ def __init__(self, path):
+ path = re.sub(r"([MLA])(-?\d+)", r"\1 \2", path)
+ path = re.split("[, ]", path)
+ path = list(filter(lambda x: x, path))
+ if path[0] != "M":
+ raise SyntaxError("Only paths with absolute position are supported")
+ self.start = tuple(map(float, path[1:3]))
+ path = path[3:]
+ if path[0] == "L":
+ x = float(path[1])
+ y = float(path[2])
+ self.end = (x, y)
+ self.type = path[0]
+ self.args = None
+ elif path[0] == "A":
+ args = list(map(float, path[1:8]))
+ self.end = (args[5], args[6])
+ self.args = args[0:5]
+ self.type = path[0]
+ else:
+ raise SyntaxError("Unsupported path element " + path[0])
+
+ @staticmethod
+ def is_same(p1, p2):
+ dx = p1[0] - p2[0]
+ dy = p1[1] - p2[1]
+ return math.sqrt(dx*dx+dy*dy) < 5
+
+ def format(self, first):
+ 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):
+ self.start, self.end = self.end, self.start
+ if self.type == "A":
+ self.args[4] = 1 if self.args[4] < 0.5 else 0
+
+def unique_prefix():
+ unique_prefix.counter += 1
+ return "pref_" + str(unique_prefix.counter)
+unique_prefix.counter = 0
+
+def matrix(data):
+ return np.array(data, dtype=np.float32)
+
+def extract_arg(args, index, default=None):
+ """
+ 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):
+ """
+ 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 = np.cos(np.radians(args[0]))
+ sina = np.sin(np.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]]))
+ if op == 'skewX':
+ tana = np.tan(np.radians(args[0]))
+ m = np.matmul(m, matrix([
+ [1, tana, 0],
+ [0, 1, 0],
+ [0, 0, 1]]))
+ if op == 'skewY':
+ tana = np.tan(np.radians(args[0]))
+ m = np.matmul(m, matrix([
+ [1, 0, 0],
+ [tana, 1, 0],
+ [0, 0, 1]]))
+ return m
+
+def collect_transformation(element, root=None):
+ """
+ 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"]
+ return np.matmul(m, to_trans_matrix(trans))
+
+def element_position(element, root=None):
+ 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 ki2dmil(val):
+ return val // 2540
+
+def dmil2ki(val):
+ return val * 2540
+
+def ki2mm(val):
+ return val / 1000000.0
+
+def mm2ki(val):
+ return int(val * 1000000)
+
+# KiCAD 5 and KiCAD 6 use different units of the SVG
+ki2svg = (lambda x: int(x)) if isV6(KICAD_VERSION) else ki2dmil
+svg2ki = (lambda x: int(x)) if isV6(KICAD_VERSION) else dmil2ki
+
+def to_kicad_basic_units(val):
+ """
+ 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)
+
+def to_user_units(val):
+ 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 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
+
+def make_XML_identifier(s):
+ """
+ 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 extract_resistor_settings(args):
+ tht_resistor_settings = {}
+ if args.resistor_values:
+ split_list = args.resistor_values.split(",")
+ for r in split_list:
+ r_s = r.split(":")
+ tht_resistor_settings[r_s[0]] = {'override_val': r_s[1]}
+ if args.resistor_flip:
+ for r in args.resistor_flip.split(","):
+ if r in tht_resistor_settings:
+ tht_resistor_settings[r]['flip'] = True
+ else:
+ tht_resistor_settings[r] = {'flip': True}
+ return tht_resistor_settings
+
+def read_svg_unique(filename: str) -> etree.Element:
+ root, _ = read_svg_unique2(filename)
+ return root
+
+def read_svg_unique2(filename: str) -> etree.Element:
+ prefix = unique_prefix() + "_"
+ 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):
+ # 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, keys, forbidden_colors):
+ 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):
+ document = etree.ElementTree(etree.fromstring(
+ """
+
+ """))
+ root = document.getroot()
+ for key, value in attrs.items():
+ root.attrib[key] = value
+ return document
+
+def get_board_polygon(svg_elements):
+ """
+ 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":
+ elements.append(SvgPathItem(svg_element.attrib["d"]))
+ 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):
+ size = len(outline)
+ for i, e in enumerate(elements):
+ if SvgPathItem.is_same(outline[0].start, e.end):
+ outline.insert(0, e)
+ elif SvgPathItem.is_same(outline[0].start, e.start):
+ e.flip()
+ outline.insert(0, e)
+ elif SvgPathItem.is_same(outline[-1].end, e.start):
+ outline.append(e)
+ elif SvgPathItem.is_same(outline[-1].end, e.end):
+ e.flip()
+ outline.append(e)
+ else:
+ continue
+ del elements[i]
+ break
+ # ...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 component_to_board_scale(svg):
+ width = ki2svg(to_kicad_basic_units(svg.attrib["width"]))
+ height = 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
+
+def load_style(style_file):
+ try:
+ with open(style_file, "r") as f:
+ style = json.load(f)
+ except IOError:
+ raise RuntimeError("Cannot open style " + style_file)
+ 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):
+ if remap_file is None:
+ return {}
+ try:
+ with open(remap_file, "r") as f:
+ f = json.load(f)
+ return {ref: tuple(val.split(":")) for ref, val in f.items()}
+ except IOError:
+ raise RuntimeError("Cannot open remapping file " + remap_file)
+
+def merge_bbox(left, right):
+ """
+ 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])
+ ])
+
+
+def shrink_svg(svg: etree.ElementTree, margin: float):
+ """
+ Shrink the SVG canvas to the size of the drawing. Add margin in
+ KiCAD units.
+ """
+ # 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
+ paths = svgpathtools.document.flattened_paths(xmlParse(serializeXml(svg)))
+
+ if len(paths) == 0:
+ return
+ bbox = paths[0].bbox()
+ for x in paths:
+ bbox = merge_bbox(bbox, x.bbox())
+ bbox = list(bbox)
+ bbox[0] -= ki2svg(margin)
+ bbox[1] += ki2svg(margin)
+ bbox[2] -= ki2svg(margin)
+ bbox[3] += ki2svg(margin)
+
+ root = svg.getroot()
+ root.attrib["viewBox"] = "{} {} {} {}".format(
+ bbox[0], bbox[2],
+ bbox[1] - bbox[0], bbox[3] - bbox[2]
+ )
+ root.attrib["width"] = str(ki2mm(svg2ki(bbox[1] - bbox[0]))) + "mm"
+ root.attrib["height"] = str(ki2mm(svg2ki(bbox[3] - bbox[2]))) + "mm"
+
+def remove_empty_elems(tree):
+ """
+ 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):
+ 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: int
+ drillsize: Tuple[int, int]
+
+ def get_svg_path_d(self) -> 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()
+ holes.append(Hole(
+ position=(pos[0], pos[1]),
+ orientation=pad.GetOrientation(),
+ drillsize=(pad.GetDrillSizeX(), pad.GetDrillSizeY())
+ ))
+ for track in board.GetTracks():
+ if not isinstance(track, pcbnew.PCB_VIA) or not isV6(KICAD_VERSION):
+ continue
+ pos = track.GetPosition()
+ holes.append(Hole(
+ position=(pos[0], pos[1]),
+ orientation=0,
+ drillsize=(track.GetDrillValue(), track.GetDrillValue())
+ ))
+ return holes
+
+
+@dataclass
+class PlotSubstrate:
+ drill_holes: bool = True
+ outline_width: int = mm2ki(0.1)
+
+ def render(self, plotter: PcbPlotter):
+ self._plotter = plotter # ...so we don't have to pass it explicitly
+
+ to_plot: List[PlotAction] = []
+ if plotter.render_back:
+ to_plot = [
+ PlotAction("board", [pcbnew.Edge_Cuts], self._process_baselayer),
+ PlotAction("clad", [pcbnew.B_Mask], self._process_layer),
+ PlotAction("copper", [pcbnew.B_Cu], self._process_layer),
+ PlotAction("pads", [pcbnew.B_Cu], self._process_layer),
+ PlotAction("pads-mask", [pcbnew.B_Mask], self._process_mask),
+ PlotAction("silk", [pcbnew.B_SilkS], self._process_layer),
+ PlotAction("outline", [pcbnew.Edge_Cuts], self._process_outline)
+ ]
+ else:
+ to_plot = [
+ PlotAction("board", [pcbnew.Edge_Cuts], self._process_baselayer),
+ PlotAction("clad", [pcbnew.F_Mask], self._process_layer),
+ PlotAction("copper", [pcbnew.F_Cu], self._process_layer),
+ PlotAction("pads", [pcbnew.F_Cu], self._process_layer),
+ PlotAction("pads-mask", [pcbnew.F_Mask], self._process_mask),
+ PlotAction("silk", [pcbnew.F_SilkS], self._process_layer),
+ PlotAction("outline", [pcbnew.Edge_Cuts], self._process_outline)
+ ]
+
+ self._container = etree.Element("g", id="substrate")
+ self._container.attrib["clip-path"] = "url(#cut-off)"
+ self._boardsize = self._plotter.board.ComputeBoundingBox()
+ 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)):
+ # 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),
+ 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)):
+ # 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 = [ki2svg(coord) for coord in hole.position]
+ size = [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()
+ el.attrib["transform"] = "translate({} {}) rotate({})".format(
+ position[0], position[1], -hole.orientation / 10)
+
+ 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))))
+
+ 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))))
+ for element in extract_svg_content(read_svg_unique(source_filename)):
+ # 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)):
+ 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(ki2svg(self._boardsize.GetX())),
+ "y": str(ki2svg(self._boardsize.GetY())),
+ "width": str(ki2svg(self._boardsize.GetWidth())),
+ "height": str(ki2svg(self._boardsize.GetHeight())),
+ "fill": "white"
+ })
+ for element in extract_svg_content(read_svg_unique(source_filename)):
+ # 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.board.ComputeBoundingBox()
+ bg = etree.SubElement(container, "rect", x="0", y="0", fill="white")
+ bg.attrib["x"] = str(ki2svg(bb.GetX()))
+ bg.attrib["y"] = str(ki2svg(bb.GetY()))
+ bg.attrib["width"] = str(ki2svg(bb.GetWidth()))
+ bg.attrib["height"] = str(ki2svg(bb.GetHeight()))
+
+ for hole in collect_holes(self._plotter.board):
+ position = list(map(ki2svg, hole.position))
+ size = list(map(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 / 10)
+
+@dataclass
+class PlacedComponentInfo:
+ id: str
+ origin: Tuple[float, float]
+ scale: Tuple[float, float]
+ size: Tuple[float, float]
+
+@dataclass
+class PlotComponents:
+ 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)
+
+ def render(self, plotter: PcbPlotter) -> None:
+ self._plotter = plotter
+ self._prefix = 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, name, value):
+ 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 and self.resistor_values[ref].value is not None:
+ value = self.resistor_values[ref].value
+
+ 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:
+ self._plotter.yield_warning("component", f"Component {lib}:{name} has not 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({ki2svg(position[0])} {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)
+ for x in extract_svg_content(svg_tree):
+ if x.tag in ["namedview", "metadata"]:
+ continue
+ component_element.append(x)
+ origin_x = 0
+ origin_y = 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(f"component: Component {lib}:{name} has not origin")
+ svg_scale_x, svg_scale_y = component_to_board_scale(svg_tree)
+ component_info = PlacedComponentInfo(
+ id=xml_id,
+ origin=(origin_x, origin_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 _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(ki2svg(-padding / info.scale[0])),
+ y=str(ki2svg(-padding / info.scale[1])),
+ width=str(ki2svg(info.size[0] + 2 * padding) / info.scale[0]),
+ height=str(ki2svg(info.size[1] + 2 * padding) / info.scale[1]),
+ style=self._plotter.get_style("highlight-style"))
+ h.attrib["transform"] = \
+ f"translate({ki2svg(position[0])} {ki2svg(position[1])}) " + \
+ f"scale({info.scale[0]} {info.scale[1]}) " + \
+ f"rotate({-math.degrees(position[2])}) " + \
+ f"translate({-info.origin[0]}, {-info.origin[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 = get_resistance_from_value(value)
+ power = math.floor(res.log10()) - 1
+ res = int(res / 10 ** power)
+ resistor_colors = [
+ self._plotter._get_style("tht-resistor-band-colors", int(str(res)[0])),
+ self._plotter._get_style("tht-resistor-band-colors", int(str(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, "5%"
+ try:
+ value = value.split(" ", maxsplit=1)
+ res = engineering_notation.EngNumber(value[0]).number
+ if len(value) > 1:
+ t_string = value[1].strip().replace(" ", "")
+ if "%" in t_string:
+ if t_string.strip() in self._plotter.get_style("tht-resistor-band-colors"):
+ raise UserWarning(f"Invalid resistor tolerance {value[1]}")
+ tolerance = t_string
+ except decimal.InvalidOperation:
+ raise UserWarning(f"Invalid value {value}") from None
+ return res, tolerance
+
+
+@dataclass
+class PlotPlaceholders:
+ 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(ki2svg(position[0] - mm2ki(0.5))),
+ y=str(ki2svg(position[1] - mm2ki(0.5))),
+ width=str(ki2svg(mm2ki(1))), height=str(ki2svg(mm2ki(1))), style="fill:red;")
+ self._plotter.append_component_element(p)
+
+@dataclass
+class PlotVCuts:
+ layer: int = pcbnew.Cmts_User
+
+ def render(self, plotter: PcbPlotter):
+ 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)):
+ # 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:
+ def render(self, plotter: PcbPlotter):
+ 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)):
+ 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: str):
+ try:
+ self.board = pcbnew.LoadBoard(boardFile)
+ except IOError:
+ raise IOError(f"Cannot open board '{boardFile}'") from None
+ self.render_back = False
+ self.mirror = False
+ self.plot_plan = [
+ 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.style: any = {} # Color scheme
+ self.margin: float = 0 # Margin of the resulting document
+
+ self.yield_warning: Callable[[str, str], None] = lambda tag, msg: None # Handle warnings
+
+ 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())
+ shrink_svg(self._document, self.margin)
+ 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()
+ ref = footprint.GetReference().strip()
+ center = footprint.GetPosition()
+ orient = math.radians(footprint.GetOrientation() / 10)
+ 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):
+ """
+ Add PcbDraw built-in libraries to the search path for libraries
+ """
+ self.data_path.append(os.path.join(PKG_BASE, "footprints"))
+ self.data_path.append(os.path.join(PKG_BASE, "styles"))
+
+ def setup_global_data_path(self):
+ """
+ Add global installation paths to the search path for libraries.
+ """
+ share = os.path.join('share', 'pcbdraw')
+ entries = len(self.data_path)
+ scheme_names = sysconfig.get_scheme_names()
+ if os.name == 'posix':
+ if 'posix_user' in scheme_names:
+ self.data_path.append(os.path.join(sysconfig.get_path('data', 'posix_user'), share))
+ if 'posix_prefix' in scheme_names:
+ self.data_path.append(os.path.join(sysconfig.get_path('data', 'posix_prefix'), share))
+ elif os.name == 'nt':
+ if 'nt_user' in scheme_names:
+ self.data_path.append(os.path.join(sysconfig.get_path('data', 'nt_user'), share))
+ if 'nt' in scheme_names:
+ self.data_path.append(os.path.join(sysconfig.get_path('data', 'nt'), share))
+ if len(self.data_path) == entries:
+ self.data_path.append(os.path.join(sysconfig.get_path('data'), share))
+
+ 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")
+ 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(name)
+
+ def _find_data_file(self, name: str, extension: str) -> Optional[str]:
+ if not name.endswith(extension):
+ name += extension
+ if os.path.isfile(name):
+ return name
+ for path in self.data_path:
+ fname = os.path.join(path, name)
+ if os.path.isfile(fname):
+ return fname
+ return None
+
+ 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]
+ self._libs_path = [x for x in self._libs_path if os.path.exists(x)]
+ print(self.libs)
+ print(self.data_path)
+ print(self._libs_path)
+
+ def _get_model_file(self, lib, name) -> 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
+
+ def get_style(self, *args: List[str]) -> 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)
+ try:
+ popt.SetPlotOutlineMode(False)
+ except:
+ # Method does not exist in older versions of KiCad
+ pass
+ popt.SetTextMode(pcbnew.PLOT_TEXT_MODE_STROKE)
+ for action in to_plot:
+ 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 _setup_document(self, render_back: bool, mirror: bool) -> None:
+ bb = self.board.ComputeBoundingBox()
+ 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"{ki2svg(-bb.GetWidth() - bb.GetX())} {ki2svg(bb.GetY())} {ki2svg(bb.GetWidth())} {ki2svg(bb.GetHeight())}")
+ else:
+ self._document = empty_svg(
+ width=f"{ki2mm(bb.GetWidth())}mm",
+ height=f"{ki2mm(bb.GetHeight())}mm",
+ viewBox=f"{ki2svg(bb.GetX())} {ki2svg(bb.GetY())} {ki2svg(bb.GetWidth())} {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"
+
diff --git a/pcbdraw/renderer.py b/pcbdraw/renderer.py
index 3b90da2..d473ba0 100644
--- a/pcbdraw/renderer.py
+++ b/pcbdraw/renderer.py
@@ -159,6 +159,9 @@ def _dismissConfigs(self) -> None:
if "Configure Global Footprint Library Table" in windows.keys():
id = windows["Configure Global Footprint Library Table"]
self._xdotool(["key", "--window", id, "Return"])
+ if "KiCad PCB Editor" in windows.keys():
+ id = windows["KiCad PCB Editor"]
+ self._xdotool(["key", "--window", id, "Return"])
if "File Open Error" in windows.keys():
raise RuntimeError("File Open Error")
diff --git a/pcbdraw/ui.py b/pcbdraw/ui.py
new file mode 100644
index 0000000..3230e11
--- /dev/null
+++ b/pcbdraw/ui.py
@@ -0,0 +1,267 @@
+import sys
+from dataclasses import dataclass
+from enum import IntEnum
+from typing import Tuple
+
+import click
+import wx
+
+from . import __version__
+from .convert import save
+from .plot import (PcbPlotter, PlotComponents, PlotPaste, PlotPlaceholders,
+ PlotSubstrate, PlotVCuts, ResistorValue, load_remapping,
+ mm2ki)
+from .renderer import (GuiPuppetError, RenderAction, Side, postProcessCrop,
+ renderBoard, validateExternalPrerequisites)
+
+
+class Layer(IntEnum):
+ F_Cu = 0
+ B_Cu = 31
+ B_Adhes = 32
+ F_Adhes = 33
+ B_Paste = 34
+ F_Paste = 35
+ B_SilkS = 36
+ F_SilkS = 37
+ B_Mask = 38
+ F_Mask = 39
+ Dwgs_User = 40
+ Cmts_User = 41
+ Eco1_User = 42
+ Eco2_User = 43
+ Edge_Cuts = 44
+ Margin = 45
+ B_CrtYd = 46
+ F_CrtYd = 47
+ B_Fab = 48
+ F_Fab = 49
+
+class KiCADLayer(click.ParamType):
+ name = "KiCAD layer"
+
+ def convert(self, value, param, ctx):
+ if isinstance(value, int):
+ if value in [item.value for item in Layer]:
+ return Layer(value)
+ return self.fail(f"{value!r} is not a valid layer number", param, ctx)
+ if isinstance(value, str):
+ try:
+ return Layer[value.replace(".", "_")]
+ except KeyError:
+ return self.fail(f"{value!r} is not a valid layer name", param, ctx)
+ return self.fail(f"{value!r} is not of expected type", param, ctx)
+
+class CommaList(click.ParamType):
+ name = "Comma separated list"
+
+ def convert(self, value, param, ctx):
+ if isinstance(value, list):
+ return value
+ if not isinstance(value, str):
+ self.fail(f"Incorrect type of '{value}': {type(value)}")
+ values = [x.strip() for x in value.split(",")]
+ return values
+
+@dataclass
+class WarningStderrReporter:
+ silent: bool
+
+ def __post_init__(self):
+ self.triggered = False
+
+ def __call__(self, tag: str, msg: str) -> None:
+ if self.silent:
+ return
+ sys.stderr.write(msg + "\n")
+ self.triggered = True
+
+
+@click.command()
+@click.argument("input", type=click.Path(file_okay=True, dir_okay=False, exists=True))
+@click.argument("output", type=click.Path(file_okay=True, dir_okay=False))
+@click.option("--style", "-s", type=str, default=None,
+ help="A name of built-in style or a path to style file")
+@click.option("--libs", "-l", type=CommaList(), default=["KiCAD-6"],
+ help="Comma separated list of libraries to use")
+@click.option("--placeholders", "-p", is_flag=True,
+ help="Render placeholders to show the components origins")
+@click.option("--remap", "-m", type=click.Path(file_okay=True, dir_okay=False, exists=True),
+ help="JSON file with map from part reference to : to remap packages")
+@click.option("--drill-holes/--no-drill-holes", default=True,
+ help="Make drill holes transparent")
+@click.option("--side", type=click.Choice(["front", "back"]), default="front",
+ help="Specify which side of the PCB to render")
+@click.option("--mirror", is_flag=True,
+ help="Mirror the board")
+@click.option("--highlight", type=CommaList(), default=[],
+ help="Comma separated list of components to highlight")
+@click.option("--filter", type=CommaList(), default=None,
+ help="Comma separated list of components to show, if not specified, show all")
+@click.option("--vcuts", "-v", type=KiCADLayer(), default=None,
+ help="If layer specified, renders V-cuts from it")
+@click.option("--dpi", type=int, default=300,
+ help="DPI for bitmap output")
+@click.option("--margin", type=int, default=1.5,
+ help="Specify margin of the final image in millimeters")
+@click.option("--silent", is_flag=True,
+ help="Do not output any warnings")
+@click.option("--werror", is_flag=True,
+ help="Treat warnings as errors")
+@click.option("--resistor-values", type=CommaList(), default=[],
+ help="Comma separated list of resistor value remapping. For example, \"R1:10k,R2:470\"")
+@click.option("--resistor-flip", type=CommaList(), default=[],
+ help="Comma separated list of resistor bands to flip")
+@click.option("--paste", is_flag=True,
+ help="Add paste layer")
+@click.option("--components/--no-components", default=True,
+ help="Render components")
+@click.option("--outline-width", type=int, default=0.15,
+ help="Outline width in mm")
+def plot(input, output, style, libs, placeholders, remap, drill_holes, side,
+ mirror, highlight, filter, vcuts, dpi, margin, silent, werror,
+ resistor_values, resistor_flip, components, paste, outline_width):
+ """
+ Create a stylized drawing of the PCB.
+ """
+
+ app = wx.App()
+ app.InitLocale()
+
+ plotter = PcbPlotter(input)
+ plotter.setup_arbitrary_data_path(".")
+ plotter.setup_env_data_path()
+ plotter.setup_builtin_data_path()
+ plotter.setup_global_data_path()
+
+ plotter.yield_warning = WarningStderrReporter(silent=silent)
+
+ if style is not None:
+ plotter.resolve_style(style)
+ plotter.libs = libs
+ plotter.render_back = side == "back"
+ plotter.mirror = mirror
+ plotter.margin = margin
+
+ plotter.plot_plan = [PlotSubstrate(
+ drill_holes=drill_holes,
+ outline_width=mm2ki(outline_width))]
+ if paste:
+ plotter.plot_plan.append(PlotPaste())
+ if vcuts is not None:
+ plotter.plot_plan.append(PlotVCuts(layer=vcuts))
+
+ if components:
+ plotter.plot_plan.append(
+ build_plot_components(remap, highlight, filter, resistor_flip, resistor_values))
+ if placeholders:
+ plotter.plot_plan.append(PlotPlaceholders())
+
+ image = plotter.plot()
+
+ if werror and plotter.yield_warning.triggered:
+ sys.exit("Warning treated as errors. See output above.")
+
+ save(image, output, dpi)
+
+def build_plot_components(remap, highlight, filter, resistor_flip, resistor_values_input):
+ remapping = load_remapping(remap)
+ def remapping_fun(ref: str, lib: str, name: str) -> Tuple[str, str]:
+ if ref in remapping:
+ return remapping[ref]
+ return lib, name
+
+ resistor_values = {}
+ for mapping in resistor_values_input:
+ key, value = tuple(mapping.split(":"))
+ resistor_values[key] = ResistorValue(value=value)
+ for ref in resistor_flip:
+ field = resistor_values.get(key, ResistorValue())
+ field.flip_bands = True
+ resistor_values[ref] = field
+
+ plot_components = PlotComponents(
+ remapping=remapping_fun,
+ resistor_values=resistor_values)
+
+ if filter is not None:
+ filter = set(filter)
+ def filter_fun(ref: str) -> bool:
+ return ref in filter
+ plot_components.filter = filter_fun
+ if highlight is not None:
+ highlight = set(highlight)
+ def highlight_fun(ref: str) -> bool:
+ return ref in highlight
+ plot_components.highlight = highlight_fun
+ return plot_components
+
+
+@click.command()
+@click.argument("input", type=click.Path(file_okay=True, dir_okay=False, exists=True))
+@click.argument("output", type=click.Path(file_okay=True, dir_okay=False))
+@click.option("--side", type=click.Choice(["front", "back"]), default="front",
+ help="Specify which side to render")
+@click.option("--padding", type=int, default=5,
+ help="Image padding in millimeters")
+@click.option("--renderer", type=click.Choice(["raytrace", "normal"]), default="raytrace",
+ help="Specify what renderer to use")
+@click.option("--projection", type=click.Choice(["orthographic", "perspective"]), default="orthographic",
+ help="Specify projection")
+@click.option("--no-components", is_flag=True, default=False,
+ help="Disable component rendering")
+@click.option("--transparent", is_flag=True,
+ help="Make transparent background of the image")
+@click.option("--baseresolution", type=int, default=3000,
+ help="Canvas size for the renderer; resulting boards is roughly 2/3 of the resolution")
+@click.option("--bgcolor1", type=(int, int, int), default=(None, None, None),
+ help="First background color")
+@click.option("--bgcolor2", type=(int, int, int), default=(None, None, None),
+ help="Second background color")
+def render(input, output, side, renderer, projection, no_components, transparent,
+ padding, baseresolution, bgcolor1, bgcolor2):
+ """
+ Create a rendered image of the PCB using KiCAD's 3D Viewer
+ """
+ try:
+ validateExternalPrerequisites()
+
+ app = wx.App()
+ app.InitLocale()
+
+ if bgcolor1[0] is None:
+ bgcolor1 = None
+ if bgcolor2[0] is None:
+ bgcolor2 = None
+
+ plan = [RenderAction(
+ side=Side.FRONT if side == "front" else Side.BACK,
+ components=not no_components,
+ raytraced=renderer == "raytrace",
+ orthographic=projection == "orthographic",
+ postprocess=postProcessCrop(input, mm2ki(padding), mm2ki(padding), transparent)
+ )]
+ if transparent:
+ if bgcolor1 is not None or bgcolor2 is not None:
+ print("Transparent background was specified, ignoring colors")
+ bgcolor2 = bgcolor1 = (200, 100, 100)
+ images = renderBoard(input, plan, baseResolution=(baseresolution, baseresolution),
+ bgColor1=bgcolor1, bgColor2=bgcolor2)
+ save(image=images[0][0], filename=output)
+ except GuiPuppetError as e:
+ e.img.save("error.png")
+ e.message = "The following GUI error ocurred; image saved in error.png:\n" + e.message
+
+@click.group()
+@click.version_option(__version__)
+def run():
+ """
+ PcbDraw generates images of KiCAD PCBs
+ """
+ pass
+
+run.add_command(render)
+run.add_command(plot)
+
+if __name__ == "__main__":
+ run()
diff --git a/setup.py b/setup.py
index 1dd5a7f..ccf9c62 100644
--- a/setup.py
+++ b/setup.py
@@ -10,6 +10,7 @@
name="PcbDraw",
version=versioneer.get_version(),
cmdclass=versioneer.get_cmdclass(),
+ python_requires=">=3.7",
author="Jan Mrázek",
author_email="email@honzamrazek.cz",
description="Utility to produce nice looking drawings of KiCAD boards",
@@ -43,8 +44,7 @@
include_package_data=True,
entry_points = {
"console_scripts": [
- "pcbdraw=pcbdraw.pcbdraw:main",
- "populate=pcbdraw.populate:main"
+ "pcbdraw=pcbdraw.ui:run"
],
}
)