KiBot/kibot/PcbDraw/plot.py

1375 lines
54 KiB
Python

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