0321 from macmini

This commit is contained in:
2025-03-21 13:28:36 +08:00
commit cd1fd4bdfa
11397 changed files with 4528845 additions and 0 deletions

View File

@ -0,0 +1,41 @@
import os
def get_parser_by_extension(file_name, config, logger):
ext = os.path.splitext(file_name)[1]
if ext == '.kicad_pcb':
return get_kicad_parser(file_name, config, logger)
elif ext == '.json':
""".json file may be from EasyEDA or a generic json format"""
import io
import json
with io.open(file_name, 'r', encoding='utf-8') as f:
obj = json.load(f)
if 'pcbdata' in obj:
return get_generic_json_parser(file_name, config, logger)
else:
return get_easyeda_parser(file_name, config, logger)
elif ext in ['.fbrd', '.brd']:
return get_fusion_eagle_parser(file_name, config, logger)
else:
return None
def get_kicad_parser(file_name, config, logger, board=None):
from .kicad import PcbnewParser
return PcbnewParser(file_name, config, logger, board)
def get_easyeda_parser(file_name, config, logger):
from .easyeda import EasyEdaParser
return EasyEdaParser(file_name, config, logger)
def get_generic_json_parser(file_name, config, logger):
from .genericjson import GenericJsonParser
return GenericJsonParser(file_name, config, logger)
def get_fusion_eagle_parser(file_name, config, logger):
from .fusion_eagle import FusionEagleParser
return FusionEagleParser(file_name, config, logger)

View File

@ -0,0 +1,251 @@
import math
from .svgpath import parse_path
class ExtraFieldData(object):
def __init__(self, fields, fields_by_ref, fields_by_index=None):
self.fields = fields
self.fields_by_ref = fields_by_ref
self.fields_by_index = fields_by_index
class EcadParser(object):
def __init__(self, file_name, config, logger):
"""
:param file_name: path to file that should be parsed.
:param config: Config instance
:param logger: logging object.
"""
self.file_name = file_name
self.config = config
self.logger = logger
def parse(self):
"""
Abstract method that should be overridden in implementations.
Performs all the parsing and returns a tuple of
(pcbdata, components)
pcbdata is described in DATAFORMAT.md
components is list of Component objects
:return:
"""
pass
@staticmethod
def normalize_field_names(data):
# type: (ExtraFieldData) -> ExtraFieldData
def remap(ref_fields):
return {f.lower(): v for (f, v) in
sorted(ref_fields.items(), reverse=True) if v}
by_ref = {r: remap(d) for (r, d) in data.fields_by_ref.items()}
if data.fields_by_index:
by_index = {i: remap(d) for (i, d) in data.fields_by_index.items()}
print([a.get("blah", "") for a in by_index.values()])
else:
by_index = None
field_map = {f.lower(): f for f in sorted(data.fields, reverse=True)}
return ExtraFieldData(field_map.values(), by_ref, by_index)
def get_extra_field_data(self, file_name):
"""
Abstract method that may be overridden in implementations that support
extra field data.
:return: ExtraFieldData
"""
return ExtraFieldData([], {})
def parse_extra_data(self, file_name, normalize_case):
"""
Parses the file and returns extra field data.
:param file_name: path to file containing extra data
:param normalize_case: if true, normalize case so that
"mpn", "Mpn", "MPN" fields are combined
:return:
"""
data = self.get_extra_field_data(file_name)
if normalize_case:
data = self.normalize_field_names(data)
return ExtraFieldData(
sorted(data.fields), data.fields_by_ref, data.fields_by_index)
def latest_extra_data(self, extra_dirs=None):
"""
Abstract method that may be overridden in implementations that support
extra field data.
:param extra_dirs: List of extra directories to search.
:return: File name of most recent file with extra field data.
"""
return None
def extra_data_file_filter(self):
"""
Abstract method that may be overridden in implementations that support
extra field data.
:return: File open dialog filter string, eg:
"Netlist and xml files (*.net; *.xml)|*.net;*.xml"
"""
return None
def add_drawing_bounding_box(self, drawing, bbox):
# type: (dict, BoundingBox) -> None
def add_segment():
bbox.add_segment(drawing['start'][0], drawing['start'][1],
drawing['end'][0], drawing['end'][1],
drawing['width'] / 2)
def add_circle():
bbox.add_circle(drawing['start'][0], drawing['start'][1],
drawing['radius'] + drawing['width'] / 2)
def add_svgpath():
width = drawing.get('width', 0)
bbox.add_svgpath(drawing['svgpath'], width, self.logger)
def add_polygon():
if 'polygons' not in drawing:
add_svgpath()
return
polygon = drawing['polygons'][0]
for point in polygon:
bbox.add_point(point[0], point[1])
def add_arc():
if 'svgpath' in drawing:
add_svgpath()
else:
width = drawing.get('width', 0)
xc, yc = drawing['start'][:2]
a1 = drawing['startangle']
a2 = drawing['endangle']
r = drawing['radius']
x1 = xc + math.cos(math.radians(a1))
y1 = xc + math.sin(math.radians(a1))
x2 = xc + math.cos(math.radians(a2))
y2 = xc + math.sin(math.radians(a2))
da = a2 - a1 if a2 > a1 else a2 + 360 - a1
la = 1 if da > 180 else 0
svgpath = 'M %s %s A %s %s 0 %s 1 %s %s' % \
(x1, y1, r, r, la, x2, y2)
bbox.add_svgpath(svgpath, width, self.logger)
{
'segment': add_segment,
'rect': add_segment, # bbox of a rect and segment are the same
'circle': add_circle,
'arc': add_arc,
'polygon': add_polygon,
'text': lambda: None, # text is not really needed for bounding box
}.get(drawing['type'])()
class Component(object):
"""Simple data object to store component data needed for bom table."""
def __init__(self, ref, val, footprint, layer, attr=None, extra_fields={}):
self.ref = ref
self.val = val
self.footprint = footprint
self.layer = layer
self.attr = attr
self.extra_fields = extra_fields
class BoundingBox(object):
"""Geometry util to calculate and combine bounding box of simple shapes."""
def __init__(self):
self._x0 = None
self._y0 = None
self._x1 = None
self._y1 = None
def to_dict(self):
# type: () -> dict
return {
"minx": self._x0,
"miny": self._y0,
"maxx": self._x1,
"maxy": self._y1,
}
def to_component_dict(self):
# type: () -> dict
return {
"pos": [self._x0, self._y0],
"relpos": [0, 0],
"size": [self._x1 - self._x0, self._y1 - self._y0],
"angle": 0,
}
def add(self, other):
"""Add another bounding box.
:type other: BoundingBox
"""
if other._x0 is not None:
self.add_point(other._x0, other._y0)
self.add_point(other._x1, other._y1)
return self
@staticmethod
def _rotate(x, y, rx, ry, angle):
sin = math.sin(math.radians(angle))
cos = math.cos(math.radians(angle))
new_x = rx + (x - rx) * cos - (y - ry) * sin
new_y = ry + (x - rx) * sin + (y - ry) * cos
return new_x, new_y
def add_point(self, x, y, rx=0, ry=0, angle=0):
x, y = self._rotate(x, y, rx, ry, angle)
if self._x0 is None:
self._x0 = x
self._y0 = y
self._x1 = x
self._y1 = y
else:
self._x0 = min(self._x0, x)
self._y0 = min(self._y0, y)
self._x1 = max(self._x1, x)
self._y1 = max(self._y1, y)
return self
def add_segment(self, x0, y0, x1, y1, r):
self.add_circle(x0, y0, r)
self.add_circle(x1, y1, r)
return self
def add_rectangle(self, x, y, w, h, angle=0):
self.add_point(x - w / 2, y - h / 2, x, y, angle)
self.add_point(x + w / 2, y - h / 2, x, y, angle)
self.add_point(x - w / 2, y + h / 2, x, y, angle)
self.add_point(x + w / 2, y + h / 2, x, y, angle)
return self
def add_circle(self, x, y, r):
self.add_point(x - r, y)
self.add_point(x, y - r)
self.add_point(x + r, y)
self.add_point(x, y + r)
return self
def add_svgpath(self, svgpath, width, logger):
w = width / 2
for segment in parse_path(svgpath, logger):
x0, x1, y0, y1 = segment.bbox()
self.add_point(x0 - w, y0 - w)
self.add_point(x1 + w, y1 + w)
def pad(self, amount):
"""Add small padding to the box."""
if self._x0 is not None:
self._x0 -= amount
self._y0 -= amount
self._x1 += amount
self._y1 += amount
def initialized(self):
return self._x0 is not None

View File

@ -0,0 +1,493 @@
import io
import os
import sys
from .common import EcadParser, Component, BoundingBox, ExtraFieldData
if sys.version_info >= (3, 0):
string_types = str
else:
string_types = basestring # noqa F821: ignore undefined
class EasyEdaParser(EcadParser):
TOP_COPPER_LAYER = 1
BOT_COPPER_LAYER = 2
TOP_SILK_LAYER = 3
BOT_SILK_LAYER = 4
BOARD_OUTLINE_LAYER = 10
TOP_ASSEMBLY_LAYER = 13
BOT_ASSEMBLY_LAYER = 14
ALL_LAYERS = 11
def extra_data_file_filter(self):
return "Json file ({f})|{f}".format(f=os.path.basename(self.file_name))
def latest_extra_data(self, extra_dirs=None):
return self.file_name
def get_extra_field_data(self, file_name):
if os.path.abspath(file_name) != os.path.abspath(self.file_name):
return None
_, components = self.parse()
field_set = set()
comp_dict = {}
for c in components:
ref_fields = comp_dict.setdefault(c.ref, {})
for k, v in c.extra_fields.items():
field_set.add(k)
ref_fields[k] = v
by_index = {
i: components[i].extra_fields for i in range(len(components))
}
return ExtraFieldData(list(field_set), comp_dict, by_index)
def get_easyeda_pcb(self):
import json
with io.open(self.file_name, 'r', encoding='utf-8') as f:
return json.load(f)
@staticmethod
def tilda_split(s):
# type: (str) -> list
return s.split('~')
@staticmethod
def sharp_split(s):
# type: (str) -> list
return s.split('#@$')
def _verify(self, pcb):
"""Spot check the pcb object."""
if 'head' not in pcb:
self.logger.error('No head attribute.')
return False
head = pcb['head']
if len(head) < 2:
self.logger.error('Incorrect head attribute ' + pcb['head'])
return False
if head['docType'] != '3':
self.logger.error('Incorrect document type: ' + head['docType'])
return False
if 'canvas' not in pcb:
self.logger.error('No canvas attribute.')
return False
canvas = self.tilda_split(pcb['canvas'])
if len(canvas) < 18:
self.logger.error('Incorrect canvas attribute ' + pcb['canvas'])
return False
self.logger.info('EasyEDA editor version ' + head['editorVersion'])
return True
@staticmethod
def normalize(v):
if isinstance(v, string_types):
v = float(v)
return v
def parse_track(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 5, 'Invalid track ' + str(shape)
width = self.normalize(shape[0])
layer = int(shape[1])
points = [self.normalize(v) for v in shape[3].split(' ')]
points_xy = [[points[i], points[i + 1]] for i in
range(0, len(points), 2)]
segments = [(points_xy[i], points_xy[i + 1]) for i in
range(len(points_xy) - 1)]
segments_json = []
for segment in segments:
segments_json.append({
"type": "segment",
"start": segment[0],
"end": segment[1],
"width": width,
})
return layer, segments_json
def parse_rect(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 9, 'Invalid rect ' + str(shape)
x = self.normalize(shape[0])
y = self.normalize(shape[1])
width = self.normalize(shape[2])
height = self.normalize(shape[3])
layer = int(shape[4])
fill = shape[8]
if fill == "none":
thickness = self.normalize(shape[7])
return layer, [{
"type": "rect",
"start": [x, y],
"end": [x + width, y + height],
"width": thickness,
}]
else:
return layer, [{
"type": "polygon",
"pos": [x, y],
"angle": 0,
"polygons": [
[[0, 0], [width, 0], [width, height], [0, height]]
]
}]
def parse_circle(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 6, 'Invalid circle ' + str(shape)
cx = self.normalize(shape[0])
cy = self.normalize(shape[1])
r = self.normalize(shape[2])
width = self.normalize(shape[3])
layer = int(shape[4])
return layer, [{
"type": "circle",
"start": [cx, cy],
"radius": r,
"width": width
}]
def parse_solid_region(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 5, 'Invalid solid region ' + str(shape)
layer = int(shape[0])
svgpath = shape[2]
return layer, [{
"type": "polygon",
"svgpath": svgpath,
}]
def parse_text(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 12, 'Invalid text ' + str(shape)
text_type = shape[0]
stroke_width = self.normalize(shape[3])
layer = int(shape[6])
text = shape[9]
svgpath = shape[10]
hide = shape[11]
return layer, [{
"type": "text",
"text": text,
"thickness": stroke_width,
"attr": [],
"svgpath": svgpath,
"hide": hide,
"text_type": text_type,
}]
def parse_arc(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 6, 'Invalid arc ' + str(shape)
width = self.normalize(shape[0])
layer = int(shape[1])
svgpath = shape[3]
return layer, [{
"type": "arc",
"svgpath": svgpath,
"width": width
}]
def parse_hole(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 4, 'Invalid hole ' + str(shape)
cx = self.normalize(shape[0])
cy = self.normalize(shape[1])
radius = self.normalize(shape[2])
return self.BOARD_OUTLINE_LAYER, [{
"type": "circle",
"start": [cx, cy],
"radius": radius,
"width": 0.1, # 1 mil
}]
def parse_pad(self, shape):
shape = self.tilda_split(shape)
assert len(shape) >= 15, 'Invalid pad ' + str(shape)
pad_shape = shape[0]
x = self.normalize(shape[1])
y = self.normalize(shape[2])
width = self.normalize(shape[3])
height = self.normalize(shape[4])
layer = int(shape[5])
number = shape[7]
hole_radius = self.normalize(shape[8])
if shape[9]:
points = [self.normalize(v) for v in shape[9].split(' ')]
else:
points = []
angle = int(shape[10])
hole_length = self.normalize(shape[12]) if shape[12] else 0
pad_layers = {
self.TOP_COPPER_LAYER: ['F'],
self.BOT_COPPER_LAYER: ['B'],
self.ALL_LAYERS: ['F', 'B']
}.get(layer)
pad_shape = {
"ELLIPSE": "circle",
"RECT": "rect",
"OVAL": "oval",
"POLYGON": "custom",
}.get(pad_shape)
pad_type = "smd" if len(pad_layers) == 1 else "th"
json = {
"layers": pad_layers,
"pos": [x, y],
"size": [width, height],
"angle": angle,
"shape": pad_shape,
"type": pad_type,
}
if number == '1':
json['pin1'] = 1
if pad_shape == "custom":
polygon = [(points[i], points[i + 1]) for i in
range(0, len(points), 2)]
# translate coordinates to be relative to footprint
polygon = [(p[0] - x, p[1] - y) for p in polygon]
json["polygons"] = [polygon]
json["angle"] = 0
if pad_type == "th":
if hole_length > 1e-6:
json["drillshape"] = "oblong"
json["drillsize"] = [hole_radius * 2, hole_length]
else:
json["drillshape"] = "circle"
json["drillsize"] = [hole_radius * 2, hole_radius * 2]
return layer, [{
"type": "pad",
"pad": json,
}]
@staticmethod
def add_pad_bounding_box(pad, bbox):
# type: (dict, BoundingBox) -> None
def add_circle():
bbox.add_circle(pad['pos'][0], pad['pos'][1], pad['size'][0] / 2)
def add_rect():
bbox.add_rectangle(pad['pos'][0], pad['pos'][1],
pad['size'][0], pad['size'][1],
pad['angle'])
def add_custom():
x = pad['pos'][0]
y = pad['pos'][1]
polygon = pad['polygons'][0]
for point in polygon:
bbox.add_point(x + point[0], y + point[1])
{
'circle': add_circle,
'rect': add_rect,
'oval': add_rect,
'custom': add_custom,
}.get(pad['shape'])()
def parse_lib(self, shape):
parts = self.sharp_split(shape)
head = self.tilda_split(parts[0])
inner_shapes, _, _ = self.parse_shapes(parts[1:])
x = self.normalize(head[0])
y = self.normalize(head[1])
attr = head[2]
fp_layer = int(head[6])
attr = attr.split('`')
if len(attr) % 2 != 0:
attr.pop()
attr = {attr[i]: attr[i + 1] for i in range(0, len(attr), 2)}
fp_layer = 'F' if fp_layer == self.TOP_COPPER_LAYER else 'B'
val = '??'
ref = '??'
footprint = '??'
if 'package' in attr:
footprint = attr['package']
del attr['package']
pads = []
copper_drawings = []
extra_drawings = []
bbox = BoundingBox()
for layer, shapes in inner_shapes.items():
for s in shapes:
if s["type"] == "pad":
pads.append(s["pad"])
continue
if s["type"] == "text":
if s["text_type"] == "N":
val = s["text"]
if s["text_type"] == "P":
ref = s["text"]
del s["text_type"]
if s["hide"]:
continue
if layer in [self.TOP_COPPER_LAYER, self.BOT_COPPER_LAYER]:
copper_drawings.append({
"layer": (
'F' if layer == self.TOP_COPPER_LAYER else 'B'),
"drawing": s,
})
elif layer in [self.TOP_SILK_LAYER,
self.BOT_SILK_LAYER,
self.TOP_ASSEMBLY_LAYER,
self.BOT_ASSEMBLY_LAYER,
self.BOARD_OUTLINE_LAYER]:
extra_drawings.append((layer, s))
for pad in pads:
self.add_pad_bounding_box(pad, bbox)
for drawing in copper_drawings:
self.add_drawing_bounding_box(drawing['drawing'], bbox)
for _, drawing in extra_drawings:
self.add_drawing_bounding_box(drawing, bbox)
bbox.pad(0.5) # pad by 5 mil
if not bbox.initialized():
# if bounding box is not calculated yet
# set it to 100x100 mil square
bbox.add_rectangle(x, y, 10, 10, 0)
footprint_json = {
"ref": ref,
"center": [x, y],
"bbox": bbox.to_component_dict(),
"pads": pads,
"drawings": copper_drawings,
"layer": fp_layer,
}
component = Component(ref, val, footprint, fp_layer, extra_fields=attr)
return fp_layer, component, footprint_json, extra_drawings
def parse_shapes(self, shapes):
drawings = {}
footprints = []
components = []
for shape_str in shapes:
shape = shape_str.split('~', 1)
parse_func = {
'TRACK': self.parse_track,
'RECT': self.parse_rect,
'CIRCLE': self.parse_circle,
'SOLIDREGION': self.parse_solid_region,
'TEXT': self.parse_text,
'ARC': self.parse_arc,
'PAD': self.parse_pad,
'HOLE': self.parse_hole,
}.get(shape[0], None)
if parse_func:
layer, json_list = parse_func(shape[1])
drawings.setdefault(layer, []).extend(json_list)
if shape[0] == 'LIB':
layer, component, json, extras = self.parse_lib(shape[1])
for drawing_layer, drawing in extras:
drawings.setdefault(drawing_layer, []).append(drawing)
footprints.append(json)
components.append(component)
return drawings, footprints, components
def get_metadata(self, pcb):
if hasattr(pcb, 'metadata'):
return pcb.metadata
else:
import os
from datetime import datetime
pcb_file_name = os.path.basename(self.file_name)
title = os.path.splitext(pcb_file_name)[0]
file_mtime = os.path.getmtime(self.file_name)
file_date = datetime.fromtimestamp(file_mtime).strftime(
'%Y-%m-%d %H:%M:%S')
return {
"title": title,
"revision": "",
"company": "",
"date": file_date,
}
def parse(self):
pcb = self.get_easyeda_pcb()
if not self._verify(pcb):
self.logger.error(
'File ' + self.file_name +
' does not appear to be valid EasyEDA json file.')
return None, None
drawings, footprints, components = self.parse_shapes(pcb['shape'])
board_outline_bbox = BoundingBox()
for drawing in drawings.get(self.BOARD_OUTLINE_LAYER, []):
self.add_drawing_bounding_box(drawing, board_outline_bbox)
if board_outline_bbox.initialized():
bbox = board_outline_bbox.to_dict()
else:
# if nothing is drawn on outline layer then rely on EasyEDA bbox
x = self.normalize(pcb['BBox']['x'])
y = self.normalize(pcb['BBox']['y'])
bbox = {
"minx": x,
"miny": y,
"maxx": x + self.normalize(pcb['BBox']['width']),
"maxy": y + self.normalize(pcb['BBox']['height'])
}
pcbdata = {
"edges_bbox": bbox,
"edges": drawings.get(self.BOARD_OUTLINE_LAYER, []),
"drawings": {
"silkscreen": {
'F': drawings.get(self.TOP_SILK_LAYER, []),
'B': drawings.get(self.BOT_SILK_LAYER, []),
},
"fabrication": {
'F': drawings.get(self.TOP_ASSEMBLY_LAYER, []),
'B': drawings.get(self.BOT_ASSEMBLY_LAYER, []),
},
},
"footprints": footprints,
"metadata": self.get_metadata(pcb),
"bom": {},
"font_data": {}
}
if self.config.include_tracks:
def filter_tracks(drawing_list, drawing_type, keys):
result = []
for d in drawing_list:
if d["type"] == drawing_type:
r = {}
for key in keys:
r[key] = d[key]
result.append(r)
return result
pcbdata["tracks"] = {
'F': filter_tracks(drawings.get(self.TOP_COPPER_LAYER, []),
"segment", ["start", "end", "width"]),
'B': filter_tracks(drawings.get(self.BOT_COPPER_LAYER, []),
"segment", ["start", "end", "width"]),
}
# zones are not supported
pcbdata["zones"] = {'F': [], 'B': []}
return pcbdata, components

View File

@ -0,0 +1,920 @@
import io
import math
import os
import string
import zipfile
from datetime import datetime
from xml.etree import ElementTree
from .common import EcadParser, Component, BoundingBox
from .svgpath import Arc
from ..core.fontparser import FontParser
class FusionEagleParser(EcadParser):
TOP_COPPER_LAYER = '1'
BOT_COPPER_LAYER = '16'
TOP_PLACE_LAYER = '21'
BOT_PLACE_LAYER = '22'
TOP_NAMES_LAYER = '25'
BOT_NAMES_LAYER = '26'
DIMENSION_LAYER = '20'
TOP_DOCU_LAYER = '51'
BOT_DOCU_LAYER = '52'
def __init__(self, file_name, config, logger):
super(FusionEagleParser, self).__init__(file_name, config, logger)
self.config = config
self.font_parser = FontParser()
self.min_via_w = 1e-3
self.pcbdata = {
'drawings': {
'silkscreen': {
'F': [],
'B': []
},
'fabrication': {
'F': [],
'B': []
}
},
'edges': [],
'footprints': [],
'font_data': {}
}
self.components = []
def _parse_pad_nets(self, signals):
elements = {}
for signal in signals.iter('signal'):
net = signal.attrib['name']
for c in signal.iter('contactref'):
e = c.attrib['element']
if e not in elements:
elements[e] = {}
elements[e][c.attrib['pad']] = net
self.elements_pad_nets = elements
@staticmethod
def _radian(ux, uy, vx, vy):
dot = ux * vx + uy * vy
mod = math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
rad = math.acos(dot / mod)
if ux * vy - uy * vx < 0.0:
rad = -rad
return rad
def _curve_to_svgparams(self, el, x=0, y=0, angle=0, mirrored=False):
_x1 = float(el.attrib['x1'])
_x2 = float(el.attrib['x2'])
_y1 = -float(el.attrib['y1'])
_y2 = -float(el.attrib['y2'])
dx1, dy1 = self._rotate(_x1, _y1, -angle, mirrored)
dx2, dy2 = self._rotate(_x2, _y2, -angle, mirrored)
x1, y1 = x + dx1, -y + dy1
x2, y2 = x + dx2, -y + dy2
chord = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
theta = float(el.attrib['curve'])
theta = -theta if mirrored else theta
r = abs(0.5 * chord / math.sin(math.radians(theta) / 2))
la = 0 if abs(theta) < 180 else 1
sw = 0 if theta > 0 else 1
return {
'x1': x1,
'y1': y1,
'r': r,
'la': la,
'sw': sw,
'x2': x2,
'y2': y2
}
def _curve_to_svgpath(self, el, x=0, y=0, angle=0, mirrored=False):
p = self._curve_to_svgparams(el, x, y, angle, mirrored)
return 'M {x1} {y1} A {r} {r} 0 {la} {sw} {x2} {y2}'.format(**p)
@staticmethod
class Rot:
def __init__(self, rot_string):
if not rot_string:
self.mirrored = False
self.spin = False
self.angle = 0
return
self.mirrored = 'M' in rot_string
self.spin = 'S' in rot_string
self.angle = float(''.join(d for d in rot_string
if d in string.digits + '.'))
def __repr__(self):
return self.__str__()
def __str__(self):
return "Mirrored: {0}, Spin: {1}, Angle: {2}".format(self.mirrored,
self.spin,
self.angle)
def _rectangle_vertices(self, el):
# Note: Eagle specifies a rectangle using opposing corners
# (x1, y1) = lower-left and (x2, y2) = upper-right) and *optionally*
# a rotation angle. The size of the rectangle is defined by the
# corners irrespective of rotation angle, and then it is rotated
# about its own center point.
_x1 = float(el.attrib['x1'])
_x2 = float(el.attrib['x2'])
_y1 = -float(el.attrib['y1'])
_y2 = -float(el.attrib['y2'])
# Center of rectangle
xc = (_x1 + _x2) / 2
yc = (_y1 + _y2) / 2
# Vertices of rectangle relative to its center, un-rotated
_dv_c = [
(_x1 - xc, _y1 - yc),
(_x2 - xc, _y1 - yc),
(_x2 - xc, _y2 - yc),
(_x1 - xc, _y2 - yc)
]
elr = self.Rot(el.get('rot'))
# Rotate the rectangle about its center
dv_c = [self._rotate(_x, _y, -elr.angle, elr.mirrored)
for (_x, _y) in _dv_c]
# Map vertices to position relative to component origin, un-rotated
return [(_x + xc, _y + yc) for (_x, _y) in dv_c]
def _add_drawing(self, el):
layer_dest = {
self.DIMENSION_LAYER: self.pcbdata['edges'],
self.TOP_PLACE_LAYER: self.pcbdata['drawings']['silkscreen']['F'],
self.BOT_PLACE_LAYER: self.pcbdata['drawings']['silkscreen']['B'],
self.TOP_NAMES_LAYER: self.pcbdata['drawings']['silkscreen']['F'],
self.BOT_NAMES_LAYER: self.pcbdata['drawings']['silkscreen']['B']
}
if ("layer" in el.attrib) and (el.attrib['layer'] in layer_dest):
dwg = None
if el.tag == 'wire':
dwg = {'width': float(el.attrib['width'])}
if 'curve' in el.attrib:
dwg['type'] = 'arc'
dwg['svgpath'] = self._curve_to_svgpath(el)
else:
dwg['type'] = 'segment'
dwg['start'] = [
float(el.attrib['x1']),
-float(el.attrib['y1'])
]
dwg['end'] = [
float(el.attrib['x2']),
-float(el.attrib['y2'])
]
elif el.tag == 'text':
# Text is not currently supported (except refdes)
# due to lack of Eagle font data
pass
elif el.tag == 'circle':
dwg = {
'type': 'circle',
'start': [float(el.attrib['x']), -float(el.attrib['y'])],
'radius': float(el.attrib['radius']),
'width': float(el.attrib['width'])
}
elif el.tag in ['polygonshape', 'polygon']:
dwg = {
'type': 'polygon',
'pos': [0, 0],
'angle': 0,
'polygons': []
}
segs = el if el.tag == 'polygon' \
else el.find('polygonoutlinesegments')
polygon = FusionEagleParser._segments_to_polygon(segs)
dwg['polygons'].append(polygon)
elif el.tag == 'rectangle':
vertices = self._rectangle_vertices(el)
dwg = {
'type': 'polygon',
'pos': [0, 0],
'angle': 0,
'polygons': [[list(v) for v in vertices]]
}
if dwg:
layer_dest[el.attrib['layer']].append(dwg)
def _add_track(self, el, net):
if el.tag == 'via' or (
el.tag == 'wire' and el.attrib['layer'] in
[self.TOP_COPPER_LAYER, self.BOT_COPPER_LAYER]):
trk = {}
if self.config.include_nets:
trk['net'] = net
if el.tag == 'wire':
dest = self.pcbdata['tracks']['F'] \
if el.attrib['layer'] == self.TOP_COPPER_LAYER\
else self.pcbdata['tracks']['B']
if 'curve' in el.attrib:
trk['width'] = float(el.attrib['width'])
# Get SVG parameters for the curve
p = self._curve_to_svgparams(el)
start = complex(p['x1'], p['y1'])
end = complex(p['x2'], p['y2'])
radius = complex(p['r'], p['r'])
large_arc = bool(p['la'])
sweep = bool(p['sw'])
# Pass SVG parameters to get center parameters
arc = Arc(start, radius, 0, large_arc, sweep, end)
# Create arc track from center parameters
trk['center'] = [arc.center.real, arc.center.imag]
trk['radius'] = radius.real
if arc.delta < 0:
trk['startangle'] = arc.theta + arc.delta
trk['endangle'] = arc.theta
else:
trk['startangle'] = arc.theta
trk['endangle'] = arc.theta + arc.delta
dest.append(trk)
else:
trk['start'] = [
float(el.attrib['x1']),
-float(el.attrib['y1'])
]
trk['end'] = [
float(el.attrib['x2']),
-float(el.attrib['y2'])
]
trk['width'] = float(el.attrib['width'])
dest.append(trk)
elif el.tag == 'via':
trk['start'] = [float(el.attrib['x']), -float(el.attrib['y'])]
trk['end'] = trk['start']
trk['width'] = float(el.attrib['drill']) + 2 * self.min_via_w \
if 'diameter' not in el.attrib else float(
el.attrib['diameter'])
if float(el.attrib['drill']) >= self.min_drill_via_untented:
trk['drillsize'] = float(el.attrib['drill'])
self.pcbdata['tracks']['F'].append(trk)
self.pcbdata['tracks']['B'].append(trk)
def _calculate_footprint_bbox(self, package, x, y, angle, mirrored):
_angle = angle if not mirrored else -angle
layers = [
self.TOP_PLACE_LAYER,
self.BOT_PLACE_LAYER,
self.TOP_DOCU_LAYER,
self.BOT_DOCU_LAYER
]
xmax, ymax = -float('inf'), -float('inf')
xmin, ymin = float('inf'), float('inf')
for el in package.iter('wire'):
if el.tag == 'wire' and el.attrib['layer'] in layers:
xmax = max(xmax,
max(float(el.attrib['x1']), float(el.attrib['x2'])))
ymax = max(ymax,
max(float(el.attrib['y1']), float(el.attrib['y2'])))
xmin = min(xmin,
min(float(el.attrib['x1']), float(el.attrib['x2'])))
ymin = min(ymin,
min(float(el.attrib['y1']), float(el.attrib['y2'])))
for el in package.iter():
if el.tag in ['smd', 'pad']:
elx, ely = float(el.attrib['x']), float(el.attrib['y'])
if el.tag == 'smd':
dx, dy = abs(float(el.attrib['dx'])) / 2, abs(
float(el.attrib['dy'])) / 2
else:
d = el.get('diameter')
if d is None:
diameter = float(el.get('drill')) + 2 * self.min_via_w
else:
diameter = float(d)
dx, dy = diameter / 2, diameter / 2
xmax, ymax = max(xmax, elx + dx), max(ymax, ely + dy)
xmin, ymin = min(xmin, elx - dx), min(ymin, ely - dy)
if not math.isinf(xmin):
if mirrored:
xmin, xmax = -xmax, -xmin
dx, dy = self._rotate(xmin, ymax, _angle)
sx = abs(xmax - xmin)
sy = abs(ymax - ymin)
else:
dx, dy = 0, 0
sx, sy = 0, 0
return {
'pos': [x + dx, -y - dy],
'angle': _angle,
'relpos': [0, 0],
'size': [sx, sy]
}
def _footprint_pads(self, package, x, y, angle, mirrored, refdes):
pads = []
element_pad_nets = self.elements_pad_nets.get(refdes)
pin1_allocated = False
for el in package.iter():
if el.tag == 'pad':
elx = float(el.attrib['x'])
ely = -float(el.attrib['y'])
drill = float(el.attrib['drill'])
dx, dy = self._rotate(elx, ely, -angle, mirrored)
diameter = drill + 2 * self.min_via_w \
if 'diameter' not in el.attrib \
else float(el.attrib['diameter'])
pr = self.Rot(el.get('rot'))
if mirrored ^ pr.mirrored:
pad_angle = -angle - pr.angle
else:
pad_angle = angle + pr.angle
pad = {
'layers': ['F', 'B'],
'pos': [x + dx, -y + dy],
'angle': pad_angle,
'type': 'th',
'drillshape': 'circle',
'drillsize': [
drill,
drill
]
}
if el.get('name') in ['1', 'A', 'A1', 'P1', 'PAD1'] and \
not pin1_allocated:
pad['pin1'] = 1
pin1_allocated = True
if 'shape' not in el.attrib or el.attrib['shape'] == 'round':
pad['shape'] = 'circle'
pad['size'] = [diameter, diameter]
elif el.attrib['shape'] == 'square':
pad['shape'] = 'rect'
pad['size'] = [diameter, diameter]
elif el.attrib['shape'] == 'octagon':
pad['shape'] = 'chamfrect'
pad['size'] = [diameter, diameter]
pad['radius'] = 0
pad['chamfpos'] = 0b1111 # all corners
pad['chamfratio'] = 0.333
elif el.attrib['shape'] == 'long':
pad['shape'] = 'roundrect'
pad['radius'] = diameter / 2
pad['size'] = [2 * diameter, diameter]
elif el.attrib['shape'] == 'offset':
pad['shape'] = 'roundrect'
pad['radius'] = diameter / 2
pad['size'] = [2 * diameter, diameter]
pad['offset'] = [diameter / 2, 0]
elif el.attrib['shape'] == 'slot':
pad['shape'] = 'roundrect'
pad['radius'] = diameter / 2
slot_length = float(el.attrib['slotLength'])
pad['size'] = [slot_length + diameter / 2, diameter]
pad['drillshape'] = 'oblong'
pad['drillsize'] = [slot_length, drill]
else:
self.logger.info(
"Unsupported footprint pad shape %s, skipping",
el.attrib['shape'])
if self.config.include_nets and element_pad_nets is not None:
net = element_pad_nets.get(el.attrib['name'])
if net is not None:
pad['net'] = net
pads.append(pad)
elif el.tag == 'smd':
layer = el.attrib['layer']
if layer == self.TOP_COPPER_LAYER and not mirrored or \
layer == self.BOT_COPPER_LAYER and mirrored:
layers = ['F']
elif layer == self.TOP_COPPER_LAYER and mirrored or \
layer == self.BOT_COPPER_LAYER and not mirrored:
layers = ['B']
else:
self.logger.error('Unable to determine layer for '
'{0} pad {1}'.format(refdes,
el.attrib['name']))
layers = None
if layers is not None:
elx = float(el.attrib['x'])
ely = -float(el.attrib['y'])
dx, dy = self._rotate(elx, ely, -angle, mirrored)
pr = self.Rot(el.get('rot'))
if mirrored ^ pr.mirrored:
pad_angle = -angle - pr.angle
else:
pad_angle = angle + pr.angle
pad = {'layers': layers,
'pos': [x + dx, -y + dy],
'size': [
float(el.attrib['dx']),
float(el.attrib['dy'])
],
'angle': pad_angle,
'type': 'smd',
}
if el.get('name') in ['1', 'A', 'A1', 'P1', 'PAD1'] and \
not pin1_allocated:
pad['pin1'] = 1
pin1_allocated = True
if 'roundness' not in el.attrib:
pad['shape'] = 'rect'
else:
pad['shape'] = 'roundrect'
pad['radius'] = (float(el.attrib['roundness']) / 100) \
* float(el.attrib['dy']) / 2
if self.config.include_nets and \
element_pad_nets is not None:
net = element_pad_nets.get(el.attrib['name'])
if net is not None:
pad['net'] = net
pads.append(pad)
return pads
@staticmethod
def _rotate(x, y, angle, mirrored=False):
sin = math.sin(math.radians(angle))
cos = math.cos(math.radians(angle))
xr = x * cos - y * sin
yr = y * cos + x * sin
if mirrored:
return -xr, yr
else:
return xr, yr
def _process_footprint(self, package, x, y, angle, mirrored, populate):
for el in package.iter():
if el.tag in ['wire', 'rectangle', 'circle', 'hole',
'polygonshape', 'polygon', 'hole']:
if el.tag == 'hole':
dwg_layer = self.pcbdata['edges']
elif el.attrib['layer'] in [self.TOP_PLACE_LAYER,
self.BOT_PLACE_LAYER]:
dwg_layer = self.pcbdata['drawings']['silkscreen']
top = el.attrib['layer'] == self.TOP_PLACE_LAYER
elif el.attrib['layer'] in [self.TOP_DOCU_LAYER,
self.BOT_DOCU_LAYER]:
if not populate:
return
dwg_layer = self.pcbdata['drawings']['fabrication']
top = el.attrib['layer'] == self.TOP_DOCU_LAYER
elif el.tag == 'wire' and \
el.attrib['layer'] == self.DIMENSION_LAYER:
dwg_layer = self.pcbdata['edges']
top = True
else:
continue
dwg = None
if el.tag == 'wire':
_dx1 = float(el.attrib['x1'])
_dx2 = float(el.attrib['x2'])
_dy1 = -float(el.attrib['y1'])
_dy2 = -float(el.attrib['y2'])
dx1, dy1 = self._rotate(_dx1, _dy1, -angle, mirrored)
dx2, dy2 = self._rotate(_dx2, _dy2, -angle, mirrored)
x1, y1 = x + dx1, -y + dy1
x2, y2 = x + dx2, -y + dy2
if el.get('curve'):
dwg = {
'type': 'arc',
'width': float(el.attrib['width']),
'svgpath': self._curve_to_svgpath(el, x, y, angle,
mirrored)
}
else:
dwg = {
'type': 'segment',
'start': [x1, y1],
'end': [x2, y2],
'width': float(el.attrib['width'])
}
elif el.tag == 'rectangle':
_dv = self._rectangle_vertices(el)
# Rotate rectangle about component origin based on
# component angle
dv = [self._rotate(_x, _y, -angle, mirrored)
for (_x, _y) in _dv]
# Map vertices back to absolute coordinates
v = [(x + _x, -y + _y) for (_x, _y) in dv]
dwg = {
'type': 'polygon',
'filled': 1,
'pos': [0, 0],
'polygons': [v]
}
elif el.tag in ['circle', 'hole']:
_x = float(el.attrib['x'])
_y = -float(el.attrib['y'])
dxc, dyc = self._rotate(_x, _y, -angle, mirrored)
xc, yc = x + dxc, -y + dyc
if el.tag == 'circle':
radius = float(el.attrib['radius'])
width = float(el.attrib['width'])
else:
radius = float(el.attrib['drill']) / 2
width = 0
dwg = {
'type': 'circle',
'start': [xc, yc],
'radius': radius,
'width': width
}
elif el.tag in ['polygonshape', 'polygon']:
segs = el if el.tag == 'polygon' \
else el.find('polygonoutlinesegments')
dv = self._segments_to_polygon(segs, angle, mirrored)
polygon = [[x + v[0], -y + v[1]] for v in dv]
dwg = {
'type': 'polygon',
'filled': 1,
'pos': [0, 0],
'polygons': [polygon]
}
if dwg is not None:
if el.tag == 'hole' or \
el.attrib['layer'] == self.DIMENSION_LAYER:
dwg_layer.append(dwg)
else:
bot = not top
# Note that in Eagle terminology, 'mirrored'
# essentially means 'flipped' (i.e. to the opposite
# side of the board)
if (mirrored and bot) or (not mirrored and top):
dwg_layer['F'].append(dwg)
elif (mirrored and top) or (not mirrored and bot):
dwg_layer['B'].append(dwg)
def _name_to_silk(self, name, x, y, elr, tr, align, size, ratio):
angle = tr.angle
mirrored = tr.mirrored
spin = elr.spin ^ tr.spin
if mirrored:
angle = -angle
if align is None:
justify = [-1, 1]
elif align == 'center':
justify = [0, 0]
else:
j = align.split('-')
alignments = {
'bottom': 1,
'center': 0,
'top': -1,
'left': -1,
'right': 1
}
justify = [alignments[ss] for ss in j[::-1]]
if (90 < angle <= 270 and not spin) or \
(-90 > angle >= -270 and not spin):
angle += 180
justify = [-j for j in justify]
dwg = {
'type': 'text',
'text': name,
'pos': [x, y],
'height': size,
'width': size,
'justify': justify,
'thickness': size * ratio,
'attr': [] if not mirrored else ['mirrored'],
'angle': angle
}
self.font_parser.parse_font_for_string(name)
if mirrored:
self.pcbdata['drawings']['silkscreen']['B'].append(dwg)
else:
self.pcbdata['drawings']['silkscreen']['F'].append(dwg)
def _element_refdes_to_silk(self, el, package):
if 'smashed' not in el.attrib:
elx = float(el.attrib['x'])
ely = -float(el.attrib['y'])
for p_el in package.iter('text'):
if p_el.text == '>NAME':
dx = float(p_el.attrib['x'])
dy = float(p_el.attrib['y'])
elr = self.Rot(el.get('rot'))
dx, dy = self._rotate(dx, dy, elr.angle, elr.mirrored)
tr = self.Rot(p_el.get('rot'))
tr.angle += elr.angle
tr.mirrored ^= elr.mirrored
self._name_to_silk(
name=el.attrib['name'],
x=elx + dx,
y=ely - dy,
elr=elr,
tr=tr,
align=p_el.get('align'),
size=float(p_el.attrib['size']),
ratio=float(p_el.get('ratio', '8')) / 100)
for attr in el.iter('attribute'):
if attr.attrib['name'] == 'NAME':
self._name_to_silk(
name=el.attrib['name'],
x=float(attr.attrib['x']),
y=-float(attr.attrib['y']),
elr=self.Rot(el.get('rot')),
tr=self.Rot(attr.get('rot')),
align=attr.attrib.get('align'),
size=float(attr.attrib['size']),
ratio=float(attr.get('ratio', '8')) / 100)
@staticmethod
def _segments_to_polygon(segs, angle=0, mirrored=False):
polygon = []
for vertex in segs.iter('vertex'):
_x, _y = float(vertex.attrib['x']), -float(vertex.attrib['y'])
x, y = FusionEagleParser._rotate(_x, _y, -angle, mirrored)
polygon.append([x, y])
return polygon
def _add_zone(self, poly, net):
layer = poly.attrib['layer']
if layer == self.TOP_COPPER_LAYER:
dest = self.pcbdata['zones']['F']
elif layer == self.BOT_COPPER_LAYER:
dest = self.pcbdata['zones']['B']
else:
return
if poly.tag == 'polygonpour':
shapes = poly.find('polygonfilldetails').findall('polygonshape')
if shapes:
zone = {'polygons': [],
'fillrule': 'evenodd'}
for shape in shapes:
segs = shape.find('polygonoutlinesegments')
zone['polygons'].append(self._segments_to_polygon(segs))
holelist = shape.find('polygonholelist')
if holelist:
holes = holelist.findall('polygonholesegments')
for hole in holes:
zone['polygons'].append(self._segments_to_polygon(hole))
if self.config.include_nets:
zone['net'] = net
dest.append(zone)
else:
zone = {'polygons': []}
zone['polygons'].append(self._segments_to_polygon(poly))
if self.config.include_nets:
zone['net'] = net
dest.append(zone)
def _add_parsed_font_data(self):
for (c, wl) in self.font_parser.get_parsed_font().items():
if c not in self.pcbdata['font_data']:
self.pcbdata['font_data'][c] = wl
def _parse_param_length(self, name, root, default):
# parse named parameter (typically a design rule) assuming it is in
# length units (mil or mm)
p = [el.attrib['value'] for el in root.iter('param') if
el.attrib['name'] == name]
if len(p) == 0:
self.logger.warning("{0} not found, defaulting to {1}"
.format(name, default))
return default
else:
if len(p) > 1:
self.logger.warning(
"Multiple {0} found, using first occurrence".format(name))
p = p[0]
p_val = float(''.join(d for d in p if d in string.digits + '.'))
p_units = (''.join(d for d in p if d in string.ascii_lowercase))
if p_units == 'mm':
return p_val
elif p_units == 'mil':
return p_val * 0.0254
else:
self.logger.error("Unsupported units {0} on {1}"
.format(p_units, name))
def parse(self):
ext = os.path.splitext(self.file_name)[1]
if ext.lower() == '.fbrd':
with zipfile.ZipFile(self.file_name) as myzip:
brdfilename = [fname for fname in myzip.namelist() if
os.path.splitext(fname)[1] == '.brd']
with myzip.open(brdfilename[0]) as brdfile:
return self._parse(brdfile)
elif ext.lower() == '.brd':
with io.open(self.file_name, 'r', encoding='utf-8') as brdfile:
return self._parse(brdfile)
def _parse(self, brdfile):
try:
brdxml = ElementTree.parse(brdfile)
except ElementTree.ParseError as err:
self.logger.error(
"Exception occurred trying to parse {0}, message: {1}"
.format(brdfile.name, err.msg))
return None, None
if brdxml is None:
self.logger.error(
"No data was able to be parsed from {0}".format(brdfile.name))
return None, None
# Pick out key sections
root = brdxml.getroot()
board = root.find('drawing').find('board')
plain = board.find('plain')
elements = board.find('elements')
signals = board.find('signals')
# Build library mapping elements' pads to nets
self._parse_pad_nets(signals)
# Parse needed design rules
# Minimum via annular ring
# (Needed in order to calculate through-hole pad diameters correctly)
self.min_via_w = (
self._parse_param_length('rlMinViaOuter', root, default=0))
# Minimum drill diameter above which vias will be un-tented
self.min_drill_via_untented = (
self._parse_param_length('mlViaStopLimit', root, default=0))
# Signals --> nets
if self.config.include_nets:
self.pcbdata['nets'] = []
for signal in signals.iter('signal'):
self.pcbdata['nets'].append(signal.attrib['name'])
# Signals --> tracks, zones
if self.config.include_tracks:
self.pcbdata['tracks'] = {'F': [], 'B': []}
self.pcbdata['zones'] = {'F': [], 'B': []}
for signal in signals.iter('signal'):
for wire in signal.iter('wire'):
self._add_track(wire, signal.attrib['name'])
for via in signal.iter('via'):
self._add_track(via, signal.attrib['name'])
for poly in signal.iter('polygonpour'):
self._add_zone(poly, signal.attrib['name'])
# Elements --> components, footprints, silkscreen, edges
for el in elements.iter('element'):
populate = el.get('populate') != 'no'
elr = self.Rot(el.get('rot'))
layer = 'B' if elr.mirrored else 'F'
extra_fields = {}
for a in el.iter('attribute'):
if 'value' in a.attrib:
extra_fields[a.attrib['name']] = a.attrib['value']
comp = Component(ref=el.attrib['name'],
val='' if 'value' not in el.attrib else el.attrib[
'value'],
footprint=el.attrib['package'],
layer=layer,
attr=None if populate else 'Virtual',
extra_fields=extra_fields)
# For component, get footprint data
libs = [lib for lib in board.find('libraries').findall('library')
if lib.attrib['name'] == el.attrib['library']]
packages = []
for lib in libs:
p = [pac for pac in lib.find('packages').findall('package')
if pac.attrib['name'] == el.attrib['package']]
packages.extend(p)
if not packages:
self.logger.error("Package {0} in library {1} not found in "
"source file {2} for element {3}"
.format(el.attrib['package'],
el.attrib['library'],
brdfile.name,
el.attrib['name']))
return None, None
else:
package = packages[0]
if len(packages) > 1:
self.logger.warn("Multiple packages found for package {0}"
" in library {1}, using first instance "
"found".format(el.attrib['package'],
el.attrib['library']))
elx = float(el.attrib['x'])
ely = float(el.attrib['y'])
refdes = el.attrib['name']
footprint = {
'ref': refdes,
'center': [elx, ely],
'pads': [],
'drawings': [],
'layer': layer
}
elr = self.Rot(el.get('rot'))
footprint['pads'] = self._footprint_pads(package, elx, ely,
elr.angle, elr.mirrored,
refdes)
footprint['bbox'] = self._calculate_footprint_bbox(package, elx,
ely, elr.angle,
elr.mirrored)
self.pcbdata['footprints'].append(footprint)
# Add silkscreen, edges for component footprint & refdes
self._process_footprint(package, elx, ely, elr.angle, elr.mirrored,
populate)
self._element_refdes_to_silk(el, package)
self.components.append(comp)
# Edges & silkscreen (independent of elements)
for el in plain.iter():
self._add_drawing(el)
# identify board bounding box based on edges
board_outline_bbox = BoundingBox()
for drawing in self.pcbdata['edges']:
self.add_drawing_bounding_box(drawing, board_outline_bbox)
if board_outline_bbox.initialized():
self.pcbdata['edges_bbox'] = board_outline_bbox.to_dict()
self._add_parsed_font_data()
# Fabrication & metadata
company = [a.attrib['value'] for a in root.iter('attribute') if
a.attrib['name'] == 'COMPANY']
company = '' if not company else company[0]
rev = [a.attrib['value'] for a in root.iter('attribute') if
a.attrib['name'] == 'REVISION']
rev = '' if not rev else rev[0]
if not rev:
rev = ''
title = os.path.basename(self.file_name)
variant = [a.attrib['name'] for a in root.iter('variantdef') if
a.get('current') == 'yes']
variant = None if not variant else variant[0]
if variant:
title = "{0}, Variant: {1}".format(title, variant)
date = datetime.fromtimestamp(
os.path.getmtime(self.file_name)).strftime('%Y-%m-%d %H:%M:%S')
self.pcbdata['metadata'] = {'title': title, 'revision': rev,
'company': company, 'date': date}
return self.pcbdata, self.components

View File

@ -0,0 +1,167 @@
import io
import json
import os.path
from jsonschema import validate, ValidationError
from .common import EcadParser, Component, BoundingBox, ExtraFieldData
from ..core.fontparser import FontParser
from ..errors import ParsingException
class GenericJsonParser(EcadParser):
COMPATIBLE_SPEC_VERSIONS = [1]
def extra_data_file_filter(self):
return "Json file ({f})|{f}".format(f=os.path.basename(self.file_name))
def latest_extra_data(self, extra_dirs=None):
return self.file_name
def get_extra_field_data(self, file_name):
if os.path.abspath(file_name) != os.path.abspath(self.file_name):
return None
_, components = self._parse()
field_set = set()
comp_dict = {}
for c in components:
ref_fields = comp_dict.setdefault(c.ref, {})
for k, v in c.extra_fields.items():
field_set.add(k)
ref_fields[k] = v
by_index = {
i: components[i].extra_fields for i in range(len(components))
}
return ExtraFieldData(list(field_set), comp_dict, by_index)
def get_generic_json_pcb(self):
with io.open(self.file_name, 'r', encoding='utf-8') as f:
pcb = json.load(f)
if 'spec_version' not in pcb:
raise ValidationError("'spec_version' is a required property")
if pcb['spec_version'] not in self.COMPATIBLE_SPEC_VERSIONS:
raise ValidationError("Unsupported spec_version ({})"
.format(pcb['spec_version']))
schema_dir = os.path.join(os.path.dirname(__file__), 'schema')
schema_file_name = os.path.join(schema_dir,
'genericjsonpcbdata_v{}.schema'.format(
pcb['spec_version']))
with io.open(schema_file_name, 'r', encoding='utf-8') as f:
schema = json.load(f)
validate(instance=pcb, schema=schema)
return pcb
def _verify(self, pcb):
"""Spot check the pcb object."""
if len(pcb['pcbdata']['footprints']) != len(pcb['components']):
self.logger.error("Length of components list doesn't match"
" length of footprints list.")
return False
return True
@staticmethod
def _texts(pcbdata):
for layer in pcbdata['drawings'].values():
for side in layer.values():
for dwg in side:
if 'text' in dwg:
yield dwg
@staticmethod
def _remove_control_codes(s):
import unicodedata
return ''.join(c for c in s if unicodedata.category(c)[0] != "C")
def _parse_font_data(self, pcbdata):
font_parser = FontParser()
for dwg in self._texts(pcbdata):
if 'svgpath' not in dwg:
dwg['text'] = self._remove_control_codes(dwg['text'])
font_parser.parse_font_for_string(dwg['text'])
if font_parser.get_parsed_font():
pcbdata['font_data'] = font_parser.get_parsed_font()
def _check_font_data(self, pcbdata):
mc = set()
for dwg in self._texts(pcbdata):
dwg['text'] = self._remove_control_codes(dwg['text'])
mc.update({c for c in dwg['text'] if 'svgpath' not in dwg and
c not in pcbdata['font_data']})
if mc:
s = ''.join(mc)
self.logger.error('Provided font_data is missing character(s)'
f' "{s}" that are present in text drawing'
' objects')
return False
else:
return True
def _parse(self):
try:
pcb = self.get_generic_json_pcb()
except ValidationError as e:
self.logger.error('File {f} does not comply with json schema. {m}'
.format(f=self.file_name, m=e.message))
return None, None
if not self._verify(pcb):
self.logger.error('File {} does not appear to be valid generic'
' InteractiveHtmlBom json file.'
.format(self.file_name))
return None, None
pcbdata = pcb['pcbdata']
components = [Component(**c) for c in pcb['components']]
if 'font_data' in pcbdata:
if not self._check_font_data(pcbdata):
raise ParsingException(f'Failed parsing {self.file_name}')
else:
self._parse_font_data(pcbdata)
if 'font_data' in pcbdata:
self.logger.info('No font_data provided in JSON, using '
'newstroke font')
self.logger.info('Successfully parsed {}'.format(self.file_name))
return pcbdata, components
def parse(self):
pcbdata, components = self._parse()
# override board bounding box based on edges
board_outline_bbox = BoundingBox()
for drawing in pcbdata['edges']:
self.add_drawing_bounding_box(drawing, board_outline_bbox)
if board_outline_bbox.initialized():
pcbdata['edges_bbox'] = board_outline_bbox.to_dict()
extra_fields = set(self.config.show_fields)
extra_fields.discard("Footprint")
extra_fields.discard("Value")
if self.config.dnp_field:
extra_fields.add(self.config.dnp_field)
if self.config.board_variant_field:
extra_fields.add(self.config.board_variant_field)
if extra_fields:
for c in components:
c.extra_fields = {
f: c.extra_fields.get(f, "") for f in extra_fields}
self.config.kicad_text_formatting = False
return pcbdata, components

View File

@ -0,0 +1,843 @@
import os
from datetime import datetime
import pcbnew
from .common import EcadParser, Component, ExtraFieldData
from .kicad_extra import find_latest_schematic_data, parse_schematic_data
from .svgpath import create_path
from ..core import ibom
from ..core.config import Config
from ..core.fontparser import FontParser
class PcbnewParser(EcadParser):
def __init__(self, file_name, config, logger, board=None):
super(PcbnewParser, self).__init__(file_name, config, logger)
self.board = board
if self.board is None:
self.board = pcbnew.LoadBoard(self.file_name) # type: pcbnew.BOARD
if hasattr(self.board, 'GetModules'):
self.footprints = list(self.board.GetModules())
else:
self.footprints = list(self.board.GetFootprints())
self.font_parser = FontParser()
def get_extra_field_data(self, file_name):
if os.path.abspath(file_name) == os.path.abspath(self.file_name):
return self.parse_extra_data_from_pcb()
if os.path.splitext(file_name)[1] == '.kicad_pcb':
return None
data = parse_schematic_data(file_name)
return ExtraFieldData(data[0], data[1])
@staticmethod
def get_footprint_fields(f):
# type: (pcbnew.FOOTPRINT) -> dict
props = {}
if hasattr(f, "GetProperties"):
props = f.GetProperties()
if hasattr(f, "GetFields"):
props = f.GetFieldsShownText()
if "dnp" in props and props["dnp"] == "":
del props["dnp"]
props["kicad_dnp"] = "DNP"
if hasattr(f, "IsDNP"):
if f.IsDNP():
props["kicad_dnp"] = "DNP"
return props
def parse_extra_data_from_pcb(self):
field_set = set()
by_ref = {}
by_index = {}
for (i, f) in enumerate(self.footprints):
props = self.get_footprint_fields(f)
by_index[i] = props
ref = f.GetReference()
ref_fields = by_ref.setdefault(ref, {})
for k, v in props.items():
field_set.add(k)
ref_fields[k] = v
return ExtraFieldData(list(field_set), by_ref, by_index)
def latest_extra_data(self, extra_dirs=None):
base_name = os.path.splitext(os.path.basename(self.file_name))[0]
extra_dirs.append(self.board.GetPlotOptions().GetOutputDirectory())
file_dir_name = os.path.dirname(self.file_name)
directories = [file_dir_name]
for dir in extra_dirs:
if not os.path.isabs(dir):
dir = os.path.join(file_dir_name, dir)
if os.path.exists(dir):
directories.append(dir)
return find_latest_schematic_data(base_name, directories)
def extra_data_file_filter(self):
if hasattr(self.board, 'GetModules'):
return "Netlist and xml files (*.net; *.xml)|*.net;*.xml"
else:
return ("Netlist, xml and pcb files (*.net; *.xml; *.kicad_pcb)|"
"*.net;*.xml;*.kicad_pcb")
@staticmethod
def normalize(point):
return [point.x * 1e-6, point.y * 1e-6]
@staticmethod
def normalize_angle(angle):
if isinstance(angle, int) or isinstance(angle, float):
return angle * 0.1
else:
return angle.AsDegrees()
def get_arc_angles(self, d):
# type: (pcbnew.PCB_SHAPE) -> tuple
a1 = self.normalize_angle(d.GetArcAngleStart())
if hasattr(d, "GetAngle"):
a2 = a1 + self.normalize_angle(d.GetAngle())
else:
a2 = a1 + self.normalize_angle(d.GetArcAngle())
if a2 < a1:
a1, a2 = a2, a1
return round(a1, 2), round(a2, 2)
def parse_shape(self, d):
# type: (pcbnew.PCB_SHAPE) -> dict or None
shape = {
pcbnew.S_SEGMENT: "segment",
pcbnew.S_CIRCLE: "circle",
pcbnew.S_ARC: "arc",
pcbnew.S_POLYGON: "polygon",
pcbnew.S_CURVE: "curve",
pcbnew.S_RECT: "rect",
}.get(d.GetShape(), "")
if shape == "":
self.logger.info("Unsupported shape %s, skipping", d.GetShape())
return None
start = self.normalize(d.GetStart())
end = self.normalize(d.GetEnd())
if shape == "segment":
return {
"type": shape,
"start": start,
"end": end,
"width": d.GetWidth() * 1e-6
}
if shape == "rect":
if hasattr(d, "GetRectCorners"):
points = list(map(self.normalize, d.GetRectCorners()))
else:
points = [
start,
[end[0], start[1]],
end,
[start[0], end[1]]
]
shape_dict = {
"type": "polygon",
"pos": [0, 0],
"angle": 0,
"polygons": [points],
"width": d.GetWidth() * 1e-6,
"filled": 0
}
if hasattr(d, "IsFilled") and d.IsFilled():
shape_dict["filled"] = 1
return shape_dict
if shape == "circle":
shape_dict = {
"type": shape,
"start": start,
"radius": d.GetRadius() * 1e-6,
"width": d.GetWidth() * 1e-6
}
if hasattr(d, "IsFilled") and d.IsFilled():
shape_dict["filled"] = 1
return shape_dict
if shape == "arc":
a1, a2 = self.get_arc_angles(d)
if hasattr(d, "GetCenter"):
start = self.normalize(d.GetCenter())
return {
"type": shape,
"start": start,
"radius": d.GetRadius() * 1e-6,
"startangle": a1,
"endangle": a2,
"width": d.GetWidth() * 1e-6
}
if shape == "polygon":
if hasattr(d, "GetPolyShape"):
polygons = self.parse_poly_set(d.GetPolyShape())
else:
self.logger.info(
"Polygons not supported for KiCad 4, skipping")
return None
angle = 0
if hasattr(d, 'GetParentModule'):
parent_footprint = d.GetParentModule()
else:
parent_footprint = d.GetParentFootprint()
if parent_footprint is not None:
angle = self.normalize_angle(parent_footprint.GetOrientation())
shape_dict = {
"type": shape,
"pos": start,
"angle": angle,
"polygons": polygons
}
if hasattr(d, "IsFilled") and not d.IsFilled():
shape_dict["filled"] = 0
shape_dict["width"] = d.GetWidth() * 1e-6
return shape_dict
if shape == "curve":
if hasattr(d, "GetBezierC1"):
c1 = self.normalize(d.GetBezierC1())
c2 = self.normalize(d.GetBezierC2())
else:
c1 = self.normalize(d.GetBezControl1())
c2 = self.normalize(d.GetBezControl2())
return {
"type": shape,
"start": start,
"cpa": c1,
"cpb": c2,
"end": end,
"width": d.GetWidth() * 1e-6
}
def parse_line_chain(self, shape):
# type: (pcbnew.SHAPE_LINE_CHAIN) -> list
result = []
if not hasattr(shape, "PointCount"):
self.logger.warn("No PointCount method on outline object. "
"Unpatched kicad version?")
return result
for point_index in range(shape.PointCount()):
result.append(
self.normalize(shape.CPoint(point_index)))
return result
def parse_poly_set(self, poly):
# type: (pcbnew.SHAPE_POLY_SET) -> list
result = []
for i in range(poly.OutlineCount()):
result.append(self.parse_line_chain(poly.Outline(i)))
return result
def parse_text(self, d):
# type: (pcbnew.PCB_TEXT) -> dict
if not d.IsVisible() and d.GetClass() not in ["PTEXT", "PCB_TEXT"]:
return None
pos = self.normalize(d.GetPosition())
if hasattr(d, "GetTextThickness"):
thickness = d.GetTextThickness() * 1e-6
else:
thickness = d.GetThickness() * 1e-6
if hasattr(d, 'TransformToSegmentList'):
segments = [self.normalize(p) for p in d.TransformToSegmentList()]
lines = []
for i in range(0, len(segments), 2):
if i == 0 or segments[i - 1] != segments[i]:
lines.append([segments[i]])
lines[-1].append(segments[i + 1])
return {
"thickness": thickness,
"svgpath": create_path(lines)
}
elif hasattr(d, 'GetEffectiveTextShape'):
shape = d.GetEffectiveTextShape(False) # type: pcbnew.SHAPE_COMPOUND
segments = []
polygons = []
for s in shape.GetSubshapes():
if s.Type() == pcbnew.SH_LINE_CHAIN:
polygons.append(self.parse_line_chain(s))
elif s.Type() == pcbnew.SH_SEGMENT:
seg = s.GetSeg()
segments.append(
[self.normalize(seg.A), self.normalize(seg.B)])
else:
self.logger.warn(
"Unsupported subshape in text: %s" % s.Type())
if segments:
return {
"thickness": thickness,
"svgpath": create_path(segments)
}
else:
return {
"polygons": polygons
}
if d.GetClass() == "MTEXT":
angle = self.normalize_angle(d.GetDrawRotation())
else:
if hasattr(d, "GetTextAngle"):
angle = self.normalize_angle(d.GetTextAngle())
else:
angle = self.normalize_angle(d.GetOrientation())
if hasattr(d, "GetTextHeight"):
height = d.GetTextHeight() * 1e-6
width = d.GetTextWidth() * 1e-6
else:
height = d.GetHeight() * 1e-6
width = d.GetWidth() * 1e-6
if hasattr(d, "GetShownText"):
text = d.GetShownText()
else:
text = d.GetText()
self.font_parser.parse_font_for_string(text)
attributes = []
if d.IsMirrored():
attributes.append("mirrored")
if d.IsItalic():
attributes.append("italic")
if d.IsBold():
attributes.append("bold")
return {
"pos": pos,
"text": text,
"height": height,
"width": width,
"justify": [d.GetHorizJustify(), d.GetVertJustify()],
"thickness": thickness,
"attr": attributes,
"angle": angle
}
def parse_dimension(self, d):
# type: (pcbnew.PCB_DIMENSION_BASE) -> dict
segments = []
circles = []
for s in d.GetShapes():
s = s.Cast()
if s.Type() == pcbnew.SH_SEGMENT:
seg = s.GetSeg()
segments.append(
[self.normalize(seg.A), self.normalize(seg.B)])
elif s.Type() == pcbnew.SH_CIRCLE:
circles.append(
[self.normalize(s.GetCenter()), s.GetRadius() * 1e-6])
else:
self.logger.info(
"Unsupported shape type in dimension object: %s", s.Type())
svgpath = create_path(segments, circles=circles)
return {
"thickness": d.GetLineThickness() * 1e-6,
"svgpath": svgpath
}
def parse_drawing(self, d):
# type: (pcbnew.BOARD_ITEM) -> list
result = []
s = None
if d.GetClass() in ["DRAWSEGMENT", "MGRAPHIC", "PCB_SHAPE"]:
s = self.parse_shape(d)
elif d.GetClass() in ["PTEXT", "MTEXT", "FP_TEXT", "PCB_TEXT", "PCB_FIELD"]:
s = self.parse_text(d)
elif (d.GetClass().startswith("PCB_DIM")
and hasattr(pcbnew, "VECTOR_SHAPEPTR")):
result.append(self.parse_dimension(d))
if hasattr(d, "Text"):
s = self.parse_text(d.Text())
else:
s = self.parse_text(d)
else:
self.logger.info("Unsupported drawing class %s, skipping",
d.GetClass())
if s:
result.append(s)
return result
def parse_edges(self, pcb):
edges = []
drawings = list(pcb.GetDrawings())
bbox = None
for f in self.footprints:
for g in f.GraphicalItems():
drawings.append(g)
for d in drawings:
if d.GetLayer() == pcbnew.Edge_Cuts:
for parsed_drawing in self.parse_drawing(d):
edges.append(parsed_drawing)
if bbox is None:
bbox = d.GetBoundingBox()
else:
bbox.Merge(d.GetBoundingBox())
if bbox:
bbox.Normalize()
return edges, bbox
def parse_drawings_on_layers(self, drawings, f_layer, b_layer):
front = []
back = []
for d in drawings:
if d[1].GetLayer() not in [f_layer, b_layer]:
continue
for drawing in self.parse_drawing(d[1]):
if d[0] in ["ref", "val"]:
drawing[d[0]] = 1
if d[1].GetLayer() == f_layer:
front.append(drawing)
else:
back.append(drawing)
return {
"F": front,
"B": back
}
def get_all_drawings(self):
drawings = [(d.GetClass(), d) for d in list(self.board.GetDrawings())]
for f in self.footprints:
drawings.append(("ref", f.Reference()))
drawings.append(("val", f.Value()))
for d in f.GraphicalItems():
drawings.append((d.GetClass(), d))
return drawings
def parse_pad(self, pad):
# type: (pcbnew.PAD) -> dict or None
layers_set = list(pad.GetLayerSet().Seq())
layers = []
if pcbnew.F_Cu in layers_set:
layers.append("F")
if pcbnew.B_Cu in layers_set:
layers.append("B")
pos = self.normalize(pad.GetPosition())
size = self.normalize(pad.GetSize())
angle = self.normalize_angle(pad.GetOrientation())
shape_lookup = {
pcbnew.PAD_SHAPE_RECT: "rect",
pcbnew.PAD_SHAPE_OVAL: "oval",
pcbnew.PAD_SHAPE_CIRCLE: "circle",
}
if hasattr(pcbnew, "PAD_SHAPE_TRAPEZOID"):
shape_lookup[pcbnew.PAD_SHAPE_TRAPEZOID] = "trapezoid"
if hasattr(pcbnew, "PAD_SHAPE_ROUNDRECT"):
shape_lookup[pcbnew.PAD_SHAPE_ROUNDRECT] = "roundrect"
if hasattr(pcbnew, "PAD_SHAPE_CUSTOM"):
shape_lookup[pcbnew.PAD_SHAPE_CUSTOM] = "custom"
if hasattr(pcbnew, "PAD_SHAPE_CHAMFERED_RECT"):
shape_lookup[pcbnew.PAD_SHAPE_CHAMFERED_RECT] = "chamfrect"
shape = shape_lookup.get(pad.GetShape(), "")
if shape == "":
self.logger.info("Unsupported pad shape %s, skipping.",
pad.GetShape())
return None
pad_dict = {
"layers": layers,
"pos": pos,
"size": size,
"angle": angle,
"shape": shape
}
if shape == "custom":
polygon_set = pad.GetCustomShapeAsPolygon()
if polygon_set.HasHoles():
self.logger.warn('Detected holes in custom pad polygons')
pad_dict["polygons"] = self.parse_poly_set(polygon_set)
if shape == "trapezoid":
# treat trapezoid as custom shape
pad_dict["shape"] = "custom"
delta = self.normalize(pad.GetDelta())
pad_dict["polygons"] = [[
[size[0] / 2 + delta[1] / 2, size[1] / 2 - delta[0] / 2],
[-size[0] / 2 - delta[1] / 2, size[1] / 2 + delta[0] / 2],
[-size[0] / 2 + delta[1] / 2, -size[1] / 2 - delta[0] / 2],
[size[0] / 2 - delta[1] / 2, -size[1] / 2 + delta[0] / 2],
]]
if shape in ["roundrect", "chamfrect"]:
pad_dict["radius"] = pad.GetRoundRectCornerRadius() * 1e-6
if shape == "chamfrect":
pad_dict["chamfpos"] = pad.GetChamferPositions()
pad_dict["chamfratio"] = pad.GetChamferRectRatio()
if hasattr(pcbnew, 'PAD_ATTRIB_PTH'):
through_hole_attributes = [pcbnew.PAD_ATTRIB_PTH,
pcbnew.PAD_ATTRIB_NPTH]
else:
through_hole_attributes = [pcbnew.PAD_ATTRIB_STANDARD,
pcbnew.PAD_ATTRIB_HOLE_NOT_PLATED]
if pad.GetAttribute() in through_hole_attributes:
pad_dict["type"] = "th"
pad_dict["drillshape"] = {
pcbnew.PAD_DRILL_SHAPE_CIRCLE: "circle",
pcbnew.PAD_DRILL_SHAPE_OBLONG: "oblong"
}.get(pad.GetDrillShape())
pad_dict["drillsize"] = self.normalize(pad.GetDrillSize())
else:
pad_dict["type"] = "smd"
if hasattr(pad, "GetOffset"):
pad_dict["offset"] = self.normalize(pad.GetOffset())
if self.config.include_nets:
pad_dict["net"] = pad.GetNetname()
return pad_dict
def parse_footprints(self):
# type: () -> list
footprints = []
for f in self.footprints: # type: pcbnew.FOOTPRINT
ref = f.GetReference()
# bounding box
if hasattr(pcbnew, 'MODULE'):
f_copy = pcbnew.MODULE(f)
else:
f_copy = pcbnew.FOOTPRINT(f)
try:
f_copy.SetOrientation(0)
except TypeError:
f_copy.SetOrientation(
pcbnew.EDA_ANGLE(0, pcbnew.TENTHS_OF_A_DEGREE_T))
pos = f_copy.GetPosition()
pos.x = pos.y = 0
f_copy.SetPosition(pos)
if hasattr(f_copy, 'GetFootprintRect'):
footprint_rect = f_copy.GetFootprintRect()
else:
footprint_rect = f_copy.GetBoundingBox(False, False)
bbox = {
"pos": self.normalize(f.GetPosition()),
"relpos": self.normalize(footprint_rect.GetPosition()),
"size": self.normalize(footprint_rect.GetSize()),
"angle": self.normalize_angle(f.GetOrientation()),
}
# graphical drawings
drawings = []
for d in f.GraphicalItems():
# we only care about copper ones, silkscreen is taken care of
if d.GetLayer() not in [pcbnew.F_Cu, pcbnew.B_Cu]:
continue
for drawing in self.parse_drawing(d):
drawings.append({
"layer": "F" if d.GetLayer() == pcbnew.F_Cu else "B",
"drawing": drawing,
})
# footprint pads
pads = []
for p in f.Pads():
pad_dict = self.parse_pad(p)
if pad_dict is not None:
pads.append((p.GetPadName(), pad_dict))
if pads:
# Try to guess first pin name.
pads = sorted(pads, key=lambda el: el[0])
pin1_pads = [p for p in pads if p[0] in
['1', 'A', 'A1', 'P1', 'PAD1']]
if pin1_pads:
pin1_pad_name = pin1_pads[0][0]
else:
# No pads have common first pin name,
# pick lexicographically smallest.
pin1_pad_name = pads[0][0]
for pad_name, pad_dict in pads:
if pad_name == pin1_pad_name:
pad_dict['pin1'] = 1
pads = [p[1] for p in pads]
# add footprint
footprints.append({
"ref": ref,
"bbox": bbox,
"pads": pads,
"drawings": drawings,
"layer": {
pcbnew.F_Cu: "F",
pcbnew.B_Cu: "B"
}.get(f.GetLayer())
})
return footprints
def parse_tracks(self, tracks):
tent_vias = True
if hasattr(self.board, "GetTentVias"):
tent_vias = self.board.GetTentVias()
result = {pcbnew.F_Cu: [], pcbnew.B_Cu: []}
for track in tracks:
if track.GetClass() in ["VIA", "PCB_VIA"]:
track_dict = {
"start": self.normalize(track.GetStart()),
"end": self.normalize(track.GetEnd()),
"width": track.GetWidth() * 1e-6,
"net": track.GetNetname(),
}
if not tent_vias:
track_dict["drillsize"] = track.GetDrillValue() * 1e-6
for layer in [pcbnew.F_Cu, pcbnew.B_Cu]:
if track.IsOnLayer(layer):
result[layer].append(track_dict)
else:
if track.GetLayer() in [pcbnew.F_Cu, pcbnew.B_Cu]:
if track.GetClass() in ["ARC", "PCB_ARC"]:
a1, a2 = self.get_arc_angles(track)
track_dict = {
"center": self.normalize(track.GetCenter()),
"startangle": a1,
"endangle": a2,
"radius": track.GetRadius() * 1e-6,
"width": track.GetWidth() * 1e-6,
}
else:
track_dict = {
"start": self.normalize(track.GetStart()),
"end": self.normalize(track.GetEnd()),
"width": track.GetWidth() * 1e-6,
}
if self.config.include_nets:
track_dict["net"] = track.GetNetname()
result[track.GetLayer()].append(track_dict)
return {
'F': result.get(pcbnew.F_Cu),
'B': result.get(pcbnew.B_Cu)
}
def parse_zones(self, zones):
result = {pcbnew.F_Cu: [], pcbnew.B_Cu: []}
for zone in zones: # type: pcbnew.ZONE
if (not zone.IsFilled() or
hasattr(zone, 'GetIsKeepout') and zone.GetIsKeepout() or
hasattr(zone, 'GetIsRuleArea') and zone.GetIsRuleArea()):
continue
layers = [layer for layer in list(zone.GetLayerSet().Seq())
if layer in [pcbnew.F_Cu, pcbnew.B_Cu]]
for layer in layers:
try:
# kicad 5.1 and earlier
poly_set = zone.GetFilledPolysList()
except TypeError:
poly_set = zone.GetFilledPolysList(layer)
width = zone.GetMinThickness() * 1e-6
if (hasattr(zone, 'GetFilledPolysUseThickness') and
not zone.GetFilledPolysUseThickness()):
width = 0
zone_dict = {
"polygons": self.parse_poly_set(poly_set),
"width": width,
}
if self.config.include_nets:
zone_dict["net"] = zone.GetNetname()
result[layer].append(zone_dict)
return {
'F': result.get(pcbnew.F_Cu),
'B': result.get(pcbnew.B_Cu)
}
@staticmethod
def parse_netlist(net_info):
# type: (pcbnew.NETINFO_LIST) -> list
nets = net_info.NetsByName().asdict().keys()
nets = sorted([str(s) for s in nets])
return nets
@staticmethod
def footprint_to_component(footprint, extra_fields):
try:
footprint_name = str(footprint.GetFPID().GetFootprintName())
except AttributeError:
footprint_name = str(footprint.GetFPID().GetLibItemName())
attr = 'Normal'
if hasattr(pcbnew, 'FP_EXCLUDE_FROM_BOM'):
if footprint.GetAttributes() & pcbnew.FP_EXCLUDE_FROM_BOM:
attr = 'Virtual'
elif hasattr(pcbnew, 'MOD_VIRTUAL'):
if footprint.GetAttributes() == pcbnew.MOD_VIRTUAL:
attr = 'Virtual'
layer = {
pcbnew.F_Cu: 'F',
pcbnew.B_Cu: 'B',
}.get(footprint.GetLayer())
return Component(footprint.GetReference(),
footprint.GetValue(),
footprint_name,
layer,
attr,
extra_fields)
def parse(self):
from ..errors import ParsingException
# Get extra field data from netlist
field_set = set(self.config.show_fields)
field_set.discard("Value")
field_set.discard("Footprint")
need_extra_fields = (field_set or
self.config.board_variant_whitelist or
self.config.board_variant_blacklist or
self.config.dnp_field)
if not self.config.extra_data_file and need_extra_fields:
self.logger.warn('Ignoring extra fields related config parameters '
'since no netlist/xml file was specified.')
need_extra_fields = False
extra_field_data = None
if (self.config.extra_data_file and
os.path.isfile(self.config.extra_data_file)):
extra_field_data = self.parse_extra_data(
self.config.extra_data_file, self.config.normalize_field_case)
if extra_field_data is None and need_extra_fields:
raise ParsingException(
'Failed parsing %s' % self.config.extra_data_file)
title_block = self.board.GetTitleBlock()
title = title_block.GetTitle()
revision = title_block.GetRevision()
company = title_block.GetCompany()
file_date = title_block.GetDate()
if (hasattr(self.board, "GetProject") and
hasattr(pcbnew, "ExpandTextVars")):
project = self.board.GetProject()
title = pcbnew.ExpandTextVars(title, project)
revision = pcbnew.ExpandTextVars(revision, project)
company = pcbnew.ExpandTextVars(company, project)
file_date = pcbnew.ExpandTextVars(file_date, project)
if not file_date:
file_mtime = os.path.getmtime(self.file_name)
file_date = datetime.fromtimestamp(file_mtime).strftime(
'%Y-%m-%d %H:%M:%S')
pcb_file_name = os.path.basename(self.file_name)
if not title:
# remove .kicad_pcb extension
title = os.path.splitext(pcb_file_name)[0]
edges, bbox = self.parse_edges(self.board)
if bbox is None:
self.logger.error('Please draw pcb outline on the edges '
'layer on sheet or any footprint before '
'generating BOM.')
return None, None
bbox = {
"minx": bbox.GetPosition().x * 1e-6,
"miny": bbox.GetPosition().y * 1e-6,
"maxx": bbox.GetRight() * 1e-6,
"maxy": bbox.GetBottom() * 1e-6,
}
drawings = self.get_all_drawings()
pcbdata = {
"edges_bbox": bbox,
"edges": edges,
"drawings": {
"silkscreen": self.parse_drawings_on_layers(
drawings, pcbnew.F_SilkS, pcbnew.B_SilkS),
"fabrication": self.parse_drawings_on_layers(
drawings, pcbnew.F_Fab, pcbnew.B_Fab),
},
"footprints": self.parse_footprints(),
"metadata": {
"title": title,
"revision": revision,
"company": company,
"date": file_date,
},
"bom": {},
"font_data": self.font_parser.get_parsed_font()
}
if self.config.include_tracks:
pcbdata["tracks"] = self.parse_tracks(self.board.GetTracks())
if hasattr(self.board, "Zones"):
pcbdata["zones"] = self.parse_zones(self.board.Zones())
else:
self.logger.info("Zones not supported for KiCad 4, skipping")
pcbdata["zones"] = {'F': [], 'B': []}
if self.config.include_nets and hasattr(self.board, "GetNetInfo"):
pcbdata["nets"] = self.parse_netlist(self.board.GetNetInfo())
if extra_field_data and need_extra_fields:
extra_fields = extra_field_data.fields_by_index
if extra_fields:
extra_fields = extra_fields.values()
if extra_fields is None:
extra_fields = []
field_map = extra_field_data.fields_by_ref
warning_shown = False
for f in self.footprints:
extra_fields.append(field_map.get(f.GetReference(), {}))
if f.GetReference() not in field_map:
# Some components are on pcb but not in schematic data.
# Show a warning about outdated extra data file.
self.logger.warn(
'Component %s is missing from schematic data.'
% f.GetReference())
warning_shown = True
if warning_shown:
self.logger.warn('Netlist/xml file is likely out of date.')
else:
extra_fields = [{}] * len(self.footprints)
components = [self.footprint_to_component(f, e)
for (f, e) in zip(self.footprints, extra_fields)]
return pcbdata, components
class InteractiveHtmlBomPlugin(pcbnew.ActionPlugin, object):
def __init__(self):
super(InteractiveHtmlBomPlugin, self).__init__()
self.name = "Generate Interactive HTML BOM"
self.category = "Read PCB"
self.pcbnew_icon_support = hasattr(self, "show_toolbar_button")
self.show_toolbar_button = True
icon_dir = os.path.dirname(os.path.dirname(__file__))
self.icon_file_name = os.path.join(icon_dir, 'icon.png')
self.description = "Generate interactive HTML page with BOM " \
"table and pcb drawing."
def defaults(self):
pass
def Run(self):
from ..version import version
from ..errors import ParsingException
logger = ibom.Logger()
board = pcbnew.GetBoard()
pcb_file_name = board.GetFileName()
if not pcb_file_name:
logger.error('Please save the board file before generating BOM.')
return
config = Config(version, os.path.dirname(pcb_file_name))
parser = PcbnewParser(pcb_file_name, config, logger, board)
try:
ibom.run_with_dialog(parser, config, logger)
except ParsingException as e:
logger.error(str(e))

View File

@ -0,0 +1,59 @@
import os
import pcbnew
from .xmlparser import XmlParser
from .netlistparser import NetlistParser
PARSERS = {
'.xml': XmlParser,
'.net': NetlistParser,
}
if hasattr(pcbnew, 'FOOTPRINT'):
PARSERS['.kicad_pcb'] = None
def parse_schematic_data(file_name):
if not os.path.isfile(file_name):
return None
extension = os.path.splitext(file_name)[1]
if extension not in PARSERS:
return None
else:
parser_cls = PARSERS[extension]
if parser_cls is None:
return None
parser = parser_cls(file_name)
return parser.get_extra_field_data()
def find_latest_schematic_data(base_name, directories):
"""
:param base_name: base name of pcb file
:param directories: list of directories to search
:return: last modified parsable file path or None if not found
"""
files = []
for d in directories:
files.extend(_find_in_dir(d))
# sort by decreasing modification time
files = sorted(files, reverse=True)
if files:
# try to find first (last modified) file that has name matching pcb file
for _, f in files:
if os.path.splitext(os.path.basename(f))[0] == base_name:
return f
# if no such file is found just return last modified
return files[0][1]
else:
return None
def _find_in_dir(dir):
_, _, files = next(os.walk(dir), (None, None, []))
# filter out files that we can not parse
files = [f for f in files if os.path.splitext(f)[1] in PARSERS.keys()]
files = [os.path.join(dir, f) for f in files]
# get their modification time and sort in descending order
return [(os.path.getmtime(f), f) for f in files]

View File

@ -0,0 +1,59 @@
import io
from .parser_base import ParserBase
from .sexpressions import parse_sexpression
class NetlistParser(ParserBase):
def get_extra_field_data(self):
with io.open(self.file_name, 'r', encoding='utf-8') as f:
sexpression = parse_sexpression(f.read())
components = None
for s in sexpression:
if s[0] == 'components':
components = s[1:]
if components is None:
return None
field_set = set()
comp_dict = {}
for c in components:
ref = None
fields = None
datasheet = None
libsource = None
dnp = False
for f in c[1:]:
if f[0] == 'ref':
ref = f[1]
if f[0] == 'fields':
fields = f[1:]
if f[0] == 'datasheet':
datasheet = f[1]
if f[0] == 'libsource':
libsource = f[1:]
if f[0] == 'property' and isinstance(f[1], list) and \
f[1][0] == 'name' and f[1][1] == 'dnp':
dnp = True
if ref is None:
return None
ref_fields = comp_dict.setdefault(ref, {})
if datasheet and datasheet != '~':
field_set.add('Datasheet')
ref_fields['Datasheet'] = datasheet
if libsource is not None:
for lib_field in libsource:
if lib_field[0] == 'description':
field_set.add('Description')
ref_fields['Description'] = lib_field[1]
if dnp:
field_set.add('kicad_dnp')
ref_fields['kicad_dnp'] = "DNP"
if fields is None:
continue
for f in fields:
if len(f) > 1:
field_set.add(f[1][1])
if len(f) > 2:
ref_fields[f[1][1]] = f[2]
return list(field_set), comp_dict

View File

@ -0,0 +1,26 @@
class ParserBase:
def __init__(self, file_name):
"""
:param file_name: path to file that should be parsed.
"""
self.file_name = file_name
def get_extra_field_data(self):
# type: () -> tuple
"""
Parses the file and returns extra field data.
:return: tuple of the format
(
[field_name1, field_name2,... ],
{
ref1: {
field_name1: field_value1,
field_name2: field_value2,
...
],
ref2: ...
}
)
"""
pass

View File

@ -0,0 +1,32 @@
import re
term_regex = r'''(?mx)
\s*(?:
(?P<open>\()|
(?P<close>\))|
(?P<sq>"(?:\\\\|\\"|[^"])*")|
(?P<s>[^(^)\s]+)
)'''
pattern = re.compile(term_regex)
def parse_sexpression(sexpression):
stack = []
out = []
for terms in pattern.finditer(sexpression):
term, value = [(t, v) for t, v in terms.groupdict().items() if v][0]
if term == 'open':
stack.append(out)
out = []
elif term == 'close':
assert stack, "Trouble with nesting of brackets"
tmp, out = out, stack.pop(-1)
out.append(tmp)
elif term == 'sq':
out.append(value[1:-1].replace('\\\\', '\\').replace('\\"', '"'))
elif term == 's':
out.append(value)
else:
raise NotImplementedError("Error: %s, %s" % (term, value))
assert not stack, "Trouble with nesting of brackets"
return out[0]

View File

@ -0,0 +1,42 @@
from xml.dom import minidom
from .parser_base import ParserBase
class XmlParser(ParserBase):
@staticmethod
def get_text(nodelist):
rc = []
for node in nodelist:
if node.nodeType == node.TEXT_NODE:
rc.append(node.data)
return ''.join(rc)
def get_extra_field_data(self):
xml = minidom.parse(self.file_name)
components = xml.getElementsByTagName('comp')
field_set = set()
comp_dict = {}
for c in components:
ref_fields = comp_dict.setdefault(c.attributes['ref'].value, {})
datasheet = c.getElementsByTagName('datasheet')
if datasheet:
datasheet = self.get_text(datasheet[0].childNodes)
if datasheet != '~':
field_set.add('Datasheet')
ref_fields['Datasheet'] = datasheet
libsource = c.getElementsByTagName('libsource')
if libsource and libsource[0].hasAttribute('description'):
field_set.add('Description')
attr = libsource[0].attributes['description']
ref_fields['Description'] = attr.value
for f in c.getElementsByTagName('field'):
name = f.attributes['name'].value
field_set.add(name)
ref_fields[name] = self.get_text(f.childNodes)
for f in c.getElementsByTagName('property'):
if f.attributes['name'].value == 'dnp':
field_set.add('kicad_dnp')
ref_fields['kicad_dnp'] = "DNP"
return list(field_set), comp_dict

View File

@ -0,0 +1,638 @@
{
"$schema": "http://json-schema.org/draft-06/schema#",
"$ref": "#/definitions/GenericJSONPCBData",
"definitions": {
"GenericJSONPCBData": {
"type": "object",
"additionalProperties": false,
"properties": {
"spec_version": {
"type": "integer"
},
"pcbdata": {
"$ref": "#/definitions/Pcbdata"
},
"components": {
"type": "array",
"items": {
"$ref": "#/definitions/Component"
}
}
},
"required": [
"spec_version",
"pcbdata",
"components"
],
"title": "GenericJSONPCBData"
},
"Component": {
"type": "object",
"additionalProperties": false,
"properties": {
"attr": {
"type": "string"
},
"footprint": {
"type": "string"
},
"layer": {
"$ref": "#/definitions/Layer"
},
"ref": {
"type": "string"
},
"val": {
"type": "string"
},
"extra_fields": {
"$ref": "#/definitions/ExtraData"
}
},
"required": [
"footprint",
"layer",
"ref",
"val"
],
"title": "Component"
},
"Pcbdata": {
"type": "object",
"additionalProperties": false,
"properties": {
"edges_bbox": {
"$ref": "#/definitions/EdgesBbox"
},
"edges": {
"$ref": "#/definitions/DrawingArray"
},
"drawings": {
"$ref": "#/definitions/LayerDrawings"
},
"footprints": {
"type": "array",
"items": {
"$ref": "#/definitions/Footprint"
}
},
"metadata": {
"$ref": "#/definitions/Metadata"
},
"tracks": {
"$ref": "#/definitions/Tracks"
},
"zones": {
"$ref": "#/definitions/Zones"
},
"nets": {
"type": "array",
"items": { "type": "string" }
},
"font_data": {
"$ref": "#/definitions/FontData"
}
},
"required": [
"edges_bbox",
"edges",
"drawings",
"footprints",
"metadata"
],
"dependencies": {
"tracks": { "required": ["zones"] },
"zones": { "required": ["tracks"] }
},
"title": "Pcbdata"
},
"EdgesBbox": {
"type": "object",
"additionalProperties": false,
"properties": {
"minx": {
"type": "number"
},
"miny": {
"type": "number"
},
"maxx": {
"type": "number"
},
"maxy": {
"type": "number"
}
},
"required": ["minx", "miny", "maxx", "maxy"],
"title": "EdgesBbox"
},
"DrawingSet": {
"type": "object",
"additionalProperties": false,
"properties": {
"F": {
"$ref": "#/definitions/DrawingArray"
},
"B": {
"$ref": "#/definitions/DrawingArray"
}
},
"required": ["F", "B"],
"title": "DrawingSet"
},
"Footprint": {
"type": "object",
"additionalProperties": false,
"properties": {
"ref": {
"type": "string"
},
"center": {
"$ref": "#/definitions/Coordinates"
},
"bbox": {
"$ref": "#/definitions/Bbox"
},
"pads": {
"type": "array",
"items": {
"$ref": "#/definitions/Pad"
}
},
"drawings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"layer": { "$ref": "#/definitions/Layer" },
"drawing": { "$ref": "#/definitions/Drawing" }
},
"required": [ "layer", "drawing" ]
}
},
"layer": {
"$ref": "#/definitions/Layer"
}
},
"required": ["ref", "center", "bbox", "pads", "drawings", "layer"],
"title": "Footprint"
},
"Bbox": {
"type": "object",
"additionalProperties": false,
"properties": {
"pos": {
"$ref": "#/definitions/Coordinates"
},
"relpos": {
"$ref": "#/definitions/Coordinates"
},
"size": {
"$ref": "#/definitions/Coordinates"
},
"angle": {
"type": "number"
}
},
"required": ["pos", "relpos", "size", "angle"],
"title": "Bbox"
},
"Pad": {
"type": "object",
"additionalProperties": false,
"properties": {
"layers": {
"type": "array",
"items": {
"$ref": "#/definitions/Layer"
},
"minItems": 1,
"maxItems": 2
},
"pos": {
"$ref": "#/definitions/Coordinates"
},
"size": {
"$ref": "#/definitions/Coordinates"
},
"angle": {
"type": "number"
},
"shape": {
"$ref": "#/definitions/Shape"
},
"svgpath": { "type": "string" },
"polygons": { "$ref": "#/definitions/Polygons" },
"radius": { "type": "number" },
"chamfpos": { "type": "integer" },
"chamfratio": { "type": "number" },
"type": {
"$ref": "#/definitions/PadType"
},
"pin1": {
"type": "integer", "const": 1
},
"drillshape": {
"$ref": "#/definitions/Drillshape"
},
"drillsize": {
"$ref": "#/definitions/Coordinates"
},
"offset": {
"$ref": "#/definitions/Coordinates"
},
"net": { "type": "string" }
},
"required": [
"layers",
"pos",
"size",
"shape",
"type"
],
"allOf": [
{
"if": {
"properties": { "shape": { "const": "custom" } }
},
"then": {
"anyOf": [
{ "required": [ "svgpath" ] },
{ "required": [ "pos", "angle", "polygons" ] }
]
}
},
{
"if": {
"properties": { "shape": { "const": "roundrect" } }
},
"then": {
"required": [ "radius" ]
}
},
{
"if": {
"properties": { "shape": { "const": "chamfrect" } }
},
"then": {
"required": [ "radius", "chamfpos", "chamfratio" ]
}
},
{
"if": {
"properties": { "type": { "const": "th" } }
},
"then": {
"required": [ "drillshape", "drillsize" ]
}
}
],
"title": "Pad"
},
"Metadata": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "string"
},
"revision": {
"type": "string"
},
"company": {
"type": "string"
},
"date": {
"type": "string"
}
},
"required": ["title", "revision", "company", "date"],
"title": "Metadata"
},
"LayerDrawings": {
"type": "object",
"items": {
"silkscreen": {
"$ref": "#/definitions/DrawingSet"
},
"fabrication": {
"$ref": "#/definitions/DrawingSet"
}
}
},
"DrawingArray": {
"type": "array",
"items": {
"$ref": "#/definitions/Drawing"
}
},
"Drawing": {
"type": "object",
"oneOf": [
{ "$ref": "#/definitions/DrawingSegment" },
{ "$ref": "#/definitions/DrawingRect" },
{ "$ref": "#/definitions/DrawingCircle" },
{ "$ref": "#/definitions/DrawingArc" },
{ "$ref": "#/definitions/DrawingCurve" },
{ "$ref": "#/definitions/DrawingPolygon" },
{ "$ref": "#/definitions/DrawingText" }
]
},
"DrawingSegment": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "type": "string", "const": "segment" },
"start": { "$ref": "#/definitions/Coordinates" },
"end": { "$ref": "#/definitions/Coordinates" },
"width": { "type": "number" }
},
"required": ["type", "start", "end", "width"],
"title": "DrawingSegment"
},
"DrawingRect": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "const": "rect" },
"start": { "$ref": "#/definitions/Coordinates" },
"end": { "$ref": "#/definitions/Coordinates" },
"width": { "type": "number" }
},
"required": ["type", "start", "end", "width"],
"title": "DrawingRect"
},
"DrawingCircle": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "const": "circle" },
"start": { "$ref": "#/definitions/Coordinates" },
"radius": { "type": "number" },
"filled": { "type": "integer" },
"width": { "type": "number" }
},
"required": ["type", "start", "radius", "width"],
"title": "DrawingCircle"
},
"DrawingArc": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "const": "arc" },
"width": { "type": "number" },
"svgpath": { "type": "string" },
"start": { "$ref": "#/definitions/Coordinates" },
"radius": { "type": "number" },
"startangle": { "type": "number" },
"endangle": { "type": "number" }
},
"required": [
"type",
"width"
],
"anyOf": [
{ "required": ["svgpath"] },
{ "required": ["start", "radius", "startangle", "endangle"] }
],
"title": "DrawingArc"
},
"DrawingCurve": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "const": "curve" },
"start": { "$ref": "#/definitions/Coordinates" },
"end": { "$ref": "#/definitions/Coordinates" },
"cpa": { "$ref": "#/definitions/Coordinates" },
"cpb": { "$ref": "#/definitions/Coordinates" },
"width": { "type": "number" }
},
"required": ["type", "start", "end", "cpa", "cpb", "width"],
"title": "DrawingCurve"
},
"DrawingPolygon": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "const": "polygon" },
"filled": { "type": "integer" },
"width": { "type": "number" },
"svgpath": { "type": "string" },
"pos": { "$ref": "#/definitions/Coordinates" },
"angle": { "type": "number" },
"polygons": {
"type": "array",
"items": {
"type": "array",
"items": { "$ref": "#/definitions/Coordinates" }
}
}
},
"required": ["type"],
"anyOf": [
{ "required": ["svgpath"] },
{ "required": ["pos", "angle", "polygons"] }
],
"title": "DrawingPolygon"
},
"DrawingText": {
"type": "object",
"additionalProperties": false,
"properties": {
"svgpath": { "type": "string" },
"thickness": { "type": "number" },
"ref": { "type": "integer" , "const": 1 },
"val": { "type": "integer" , "const": 1 }
},
"required": [
"svgpath",
"thickness"
],
"title": "DrawingText"
},
"Coordinates": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"Drillshape": {
"type": "string",
"enum": [
"circle",
"oblong"
],
"title": "Drillshape"
},
"Layer": {
"type": "string",
"enum": [
"B",
"F"
],
"title": "Layer"
},
"Shape": {
"type": "string",
"enum": [
"rect",
"circle",
"oval",
"roundrect",
"chamfrect",
"custom"
],
"title": "Shape"
},
"PadType": {
"type": "string",
"enum": [
"smd",
"th"
],
"title": "PadType"
},
"Tracks": {
"type": "object",
"additionalProperties": false,
"properties": {
"F": {
"type": "array",
"items": { "$ref": "#/definitions/Track" }
},
"B": {
"type": "array",
"items": { "$ref": "#/definitions/Track" }
}
},
"required": [ "F", "B" ],
"title": "Tracks"
},
"Track": {
"type": "object",
"oneOf":[
{
"additionalProperties": false,
"properties": {
"start": { "$ref": "#/definitions/Coordinates" },
"end": { "$ref": "#/definitions/Coordinates" },
"width": { "type": "number" },
"drillsize": { "type": "number" },
"net": { "type": "string" }
},
"required": [
"start",
"end",
"width"
]
},
{
"additionalProperties": false,
"properties": {
"center": { "$ref": "#/definitions/Coordinates" },
"startangle": { "type": "number" },
"endangle": { "type": "number" },
"radius": { "type": "number" },
"width": { "type": "number" },
"net": { "type": "string" }
},
"required": [
"center",
"startangle",
"endangle",
"radius",
"width"
]
}
]
},
"Zones": {
"type": "object",
"additionalProperties": false,
"properties": {
"F": {
"type": "array",
"items": { "$ref": "#/definitions/Zone" }
},
"B": {
"type": "array",
"items": { "$ref": "#/definitions/Zone" }
}
},
"required": [ "F", "B" ],
"title": "Zones"
},
"Zone": {
"type": "object",
"additionalProperties": false,
"properties": {
"svgpath": { "type": "string" },
"polygons": {
"$ref": "#/definitions/Polygons"
},
"net": { "type": "string" },
"fillrule": {
"type": "string",
"enum": [
"nonzero",
"evenodd"
]
}
},
"anyOf": [
{ "required": [ "svgpath" ] },
{ "required": [ "polygons" ] }
],
"title": "Zone"
},
"Polygons": {
"type": "array",
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/Coordinates"
}
}
},
"PolyLineArray": {
"$ref": "#/definitions/Polygons"
},
"ReferenceSet": {
"type": "array",
"items": {
"type": "array",
"items": [
{ "type": "string" },
{ "type": "integer" }
],
"additionalItems": false
}
},
"ExtraData": {
"type": "object",
"additionalProperties": true,
"properties": {
},
"title": "ExtraData"
},
"FontData": {
"type": "object",
"patternProperties": {
"^.$" : {
"type": "object",
"properties": {
"w": { "type": "number" },
"l": { "$ref": "#/definitions/PolyLineArray" }
},
"additionalProperties" : false,
"required": [
"w",
"l"
]
}
}
}
}
}

View File

@ -0,0 +1,538 @@
"""This submodule contains very stripped down bare bones version of
svgpathtools module:
https://github.com/mathandy/svgpathtools
All external dependencies are removed. This code can parse path strings with
segments and arcs, calculate bounding box and that's about it. This is all
that is needed in ibom parsers at the moment.
"""
# External dependencies
from __future__ import division, absolute_import, print_function
import re
from cmath import exp
from math import sqrt, cos, sin, acos, degrees, radians, pi
def clip(a, a_min, a_max):
return min(a_max, max(a_min, a))
class Line(object):
def __init__(self, start, end):
self.start = start
self.end = end
def __repr__(self):
return 'Line(start=%s, end=%s)' % (self.start, self.end)
def __eq__(self, other):
if not isinstance(other, Line):
return NotImplemented
return self.start == other.start and self.end == other.end
def __ne__(self, other):
if not isinstance(other, Line):
return NotImplemented
return not self == other
def __len__(self):
return 2
def bbox(self):
"""returns the bounding box for the segment in the form
(xmin, xmax, ymin, ymax)."""
xmin = min(self.start.real, self.end.real)
xmax = max(self.start.real, self.end.real)
ymin = min(self.start.imag, self.end.imag)
ymax = max(self.start.imag, self.end.imag)
return xmin, xmax, ymin, ymax
class Arc(object):
def __init__(self, start, radius, rotation, large_arc, sweep, end,
autoscale_radius=True):
"""
This should be thought of as a part of an ellipse connecting two
points on that ellipse, start and end.
Parameters
----------
start : complex
The start point of the curve. Note: `start` and `end` cannot be the
same. To make a full ellipse or circle, use two `Arc` objects.
radius : complex
rx + 1j*ry, where rx and ry are the radii of the ellipse (also
known as its semi-major and semi-minor axes, or vice-versa or if
rx < ry).
Note: If rx = 0 or ry = 0 then this arc is treated as a
straight line segment joining the endpoints.
Note: If rx or ry has a negative sign, the sign is dropped; the
absolute value is used instead.
Note: If no such ellipse exists, the radius will be scaled so
that one does (unless autoscale_radius is set to False).
rotation : float
This is the CCW angle (in degrees) from the positive x-axis of the
current coordinate system to the x-axis of the ellipse.
large_arc : bool
Given two points on an ellipse, there are two elliptical arcs
connecting those points, the first going the short way around the
ellipse, and the second going the long way around the ellipse. If
`large_arc == False`, the shorter elliptical arc will be used. If
`large_arc == True`, then longer elliptical will be used.
In other words, `large_arc` should be 0 for arcs spanning less than
or equal to 180 degrees and 1 for arcs spanning greater than 180
degrees.
sweep : bool
For any acceptable parameters `start`, `end`, `rotation`, and
`radius`, there are two ellipses with the given major and minor
axes (radii) which connect `start` and `end`. One which connects
them in a CCW fashion and one which connected them in a CW
fashion. If `sweep == True`, the CCW ellipse will be used. If
`sweep == False`, the CW ellipse will be used. See note on curve
orientation below.
end : complex
The end point of the curve. Note: `start` and `end` cannot be the
same. To make a full ellipse or circle, use two `Arc` objects.
autoscale_radius : bool
If `autoscale_radius == True`, then will also scale `self.radius`
in the case that no ellipse exists with the input parameters
(see inline comments for further explanation).
Derived Parameters/Attributes
-----------------------------
self.theta : float
This is the phase (in degrees) of self.u1transform(self.start).
It is $\\theta_1$ in the official documentation and ranges from
-180 to 180.
self.delta : float
This is the angular distance (in degrees) between the start and
end of the arc after the arc has been sent to the unit circle
through self.u1transform().
It is $\\Delta\\theta$ in the official documentation and ranges
from -360 to 360; being positive when the arc travels CCW and
negative otherwise (i.e. is positive/negative when
sweep == True/False).
self.center : complex
This is the center of the arc's ellipse.
self.phi : float
The arc's rotation in radians, i.e. `radians(self.rotation)`.
self.rot_matrix : complex
Equal to `exp(1j * self.phi)` which is also equal to
`cos(self.phi) + 1j*sin(self.phi)`.
Note on curve orientation (CW vs CCW)
-------------------------------------
The notions of clockwise (CW) and counter-clockwise (CCW) are reversed
in some sense when viewing SVGs (as the y coordinate starts at the top
of the image and increases towards the bottom).
"""
assert start != end
assert radius.real != 0 and radius.imag != 0
self.start = start
self.radius = abs(radius.real) + 1j * abs(radius.imag)
self.rotation = rotation
self.large_arc = bool(large_arc)
self.sweep = bool(sweep)
self.end = end
self.autoscale_radius = autoscale_radius
# Convenience parameters
self.phi = radians(self.rotation)
self.rot_matrix = exp(1j * self.phi)
# Derive derived parameters
self._parameterize()
def __repr__(self):
params = (self.start, self.radius, self.rotation,
self.large_arc, self.sweep, self.end)
return ("Arc(start={}, radius={}, rotation={}, "
"large_arc={}, sweep={}, end={})".format(*params))
def __eq__(self, other):
if not isinstance(other, Arc):
return NotImplemented
return self.start == other.start and self.end == other.end \
and self.radius == other.radius \
and self.rotation == other.rotation \
and self.large_arc == other.large_arc and self.sweep == other.sweep
def __ne__(self, other):
if not isinstance(other, Arc):
return NotImplemented
return not self == other
def _parameterize(self):
# See http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
# my notation roughly follows theirs
rx = self.radius.real
ry = self.radius.imag
rx_sqd = rx * rx
ry_sqd = ry * ry
# Transform z-> z' = x' + 1j*y'
# = self.rot_matrix**(-1)*(z - (end+start)/2)
# coordinates. This translates the ellipse so that the midpoint
# between self.end and self.start lies on the origin and rotates
# the ellipse so that the its axes align with the xy-coordinate axes.
# Note: This sends self.end to -self.start
zp1 = (1 / self.rot_matrix) * (self.start - self.end) / 2
x1p, y1p = zp1.real, zp1.imag
x1p_sqd = x1p * x1p
y1p_sqd = y1p * y1p
# Correct out of range radii
# Note: an ellipse going through start and end with radius and phi
# exists if and only if radius_check is true
radius_check = (x1p_sqd / rx_sqd) + (y1p_sqd / ry_sqd)
if radius_check > 1:
if self.autoscale_radius:
rx *= sqrt(radius_check)
ry *= sqrt(radius_check)
self.radius = rx + 1j * ry
rx_sqd = rx * rx
ry_sqd = ry * ry
else:
raise ValueError("No such elliptic arc exists.")
# Compute c'=(c_x', c_y'), the center of the ellipse in (x', y') coords
# Noting that, in our new coord system, (x_2', y_2') = (-x_1', -x_2')
# and our ellipse is cut out by of the plane by the algebraic equation
# (x'-c_x')**2 / r_x**2 + (y'-c_y')**2 / r_y**2 = 1,
# we can find c' by solving the system of two quadratics given by
# plugging our transformed endpoints (x_1', y_1') and (x_2', y_2')
tmp = rx_sqd * y1p_sqd + ry_sqd * x1p_sqd
radicand = (rx_sqd * ry_sqd - tmp) / tmp
try:
radical = sqrt(radicand)
except ValueError:
radical = 0
if self.large_arc == self.sweep:
cp = -radical * (rx * y1p / ry - 1j * ry * x1p / rx)
else:
cp = radical * (rx * y1p / ry - 1j * ry * x1p / rx)
# The center in (x,y) coordinates is easy to find knowing c'
self.center = exp(1j * self.phi) * cp + (self.start + self.end) / 2
# Now we do a second transformation, from (x', y') to (u_x, u_y)
# coordinates, which is a translation moving the center of the
# ellipse to the origin and a dilation stretching the ellipse to be
# the unit circle
u1 = (x1p - cp.real) / rx + 1j * (y1p - cp.imag) / ry
u2 = (-x1p - cp.real) / rx + 1j * (-y1p - cp.imag) / ry
# clip in case of floating point error
u1 = clip(u1.real, -1, 1) + 1j * clip(u1.imag, -1, 1)
u2 = clip(u2.real, -1, 1) + 1j * clip(u2.imag, -1, 1)
# Now compute theta and delta (we'll define them as we go)
# delta is the angular distance of the arc (w.r.t the circle)
# theta is the angle between the positive x'-axis and the start point
# on the circle
if u1.imag > 0:
self.theta = degrees(acos(u1.real))
elif u1.imag < 0:
self.theta = -degrees(acos(u1.real))
else:
if u1.real > 0: # start is on pos u_x axis
self.theta = 0
else: # start is on neg u_x axis
# Note: This behavior disagrees with behavior documented in
# http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
# where theta is set to 0 in this case.
self.theta = 180
det_uv = u1.real * u2.imag - u1.imag * u2.real
acosand = u1.real * u2.real + u1.imag * u2.imag
acosand = clip(acosand.real, -1, 1) + clip(acosand.imag, -1, 1)
if det_uv > 0:
self.delta = degrees(acos(acosand))
elif det_uv < 0:
self.delta = -degrees(acos(acosand))
else:
if u1.real * u2.real + u1.imag * u2.imag > 0:
# u1 == u2
self.delta = 0
else:
# u1 == -u2
# Note: This behavior disagrees with behavior documented in
# http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
# where delta is set to 0 in this case.
self.delta = 180
if not self.sweep and self.delta >= 0:
self.delta -= 360
elif self.large_arc and self.delta <= 0:
self.delta += 360
def point(self, t):
if t == 0:
return self.start
if t == 1:
return self.end
angle = radians(self.theta + t * self.delta)
cosphi = self.rot_matrix.real
sinphi = self.rot_matrix.imag
rx = self.radius.real
ry = self.radius.imag
# z = self.rot_matrix*(rx*cos(angle) + 1j*ry*sin(angle)) + self.center
x = rx * cosphi * cos(angle) - ry * sinphi * sin(
angle) + self.center.real
y = rx * sinphi * cos(angle) + ry * cosphi * sin(
angle) + self.center.imag
return complex(x, y)
def bbox(self):
"""returns a bounding box for the segment in the form
(xmin, xmax, ymin, ymax)."""
# a(t) = radians(self.theta + self.delta*t)
# = (2*pi/360)*(self.theta + self.delta*t)
# x'=0: ~~~~~~~~~
# -rx*cos(phi)*sin(a(t)) = ry*sin(phi)*cos(a(t))
# -(rx/ry)*cot(phi)*tan(a(t)) = 1
# a(t) = arctan(-(ry/rx)tan(phi)) + pi*k === atan_x
# y'=0: ~~~~~~~~~~
# rx*sin(phi)*sin(a(t)) = ry*cos(phi)*cos(a(t))
# (rx/ry)*tan(phi)*tan(a(t)) = 1
# a(t) = arctan((ry/rx)*cot(phi))
# atanres = arctan((ry/rx)*cot(phi)) === atan_y
# ~~~~~~~~
# (2*pi/360)*(self.theta + self.delta*t) = atanres + pi*k
# Therefore, for both x' and y', we have...
# t = ((atan_{x/y} + pi*k)*(360/(2*pi)) - self.theta)/self.delta
# for all k s.t. 0 < t < 1
from math import atan, tan
if cos(self.phi) == 0:
atan_x = pi / 2
atan_y = 0
elif sin(self.phi) == 0:
atan_x = 0
atan_y = pi / 2
else:
rx, ry = self.radius.real, self.radius.imag
atan_x = atan(-(ry / rx) * tan(self.phi))
atan_y = atan((ry / rx) / tan(self.phi))
def angle_inv(ang, q): # inverse of angle from Arc.derivative()
return ((ang + pi * q) * (360 / (2 * pi)) -
self.theta) / self.delta
xtrema = [self.start.real, self.end.real]
ytrema = [self.start.imag, self.end.imag]
for k in range(-4, 5):
tx = angle_inv(atan_x, k)
ty = angle_inv(atan_y, k)
if 0 <= tx <= 1:
xtrema.append(self.point(tx).real)
if 0 <= ty <= 1:
ytrema.append(self.point(ty).imag)
return min(xtrema), max(xtrema), min(ytrema), max(ytrema)
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
UPPERCASE = set('MZLHVCSQTA')
COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])")
FLOAT_RE = re.compile(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?")
def _tokenize_path(path_def):
for x in COMMAND_RE.split(path_def):
if x in COMMANDS:
yield x
for token in FLOAT_RE.findall(x):
yield token
def parse_path(pathdef, logger, current_pos=0j):
# In the SVG specs, initial movetos are absolute, even if
# specified as 'm'. This is the default behavior here as well.
# But if you pass in a current_pos variable, the initial moveto
# will be relative to that current_pos. This is useful.
elements = list(_tokenize_path(pathdef))
# Reverse for easy use of .pop()
elements.reverse()
absolute = False
segments = []
start_pos = None
command = None
while elements:
if elements[-1] in COMMANDS:
# New command.
command = elements.pop()
absolute = command in UPPERCASE
command = command.upper()
else:
# If this element starts with numbers, it is an implicit command
# and we don't change the command. Check that it's allowed:
if command is None:
raise ValueError(
"Unallowed implicit command in %s, position %s" % (
pathdef, len(pathdef.split()) - len(elements)))
if command == 'M':
# Moveto command.
x = elements.pop()
y = elements.pop()
pos = float(x) + float(y) * 1j
if absolute:
current_pos = pos
else:
current_pos += pos
# when M is called, reset start_pos
# This behavior of Z is defined in svg spec:
# http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand
start_pos = current_pos
# Implicit moveto commands are treated as lineto commands.
# So we set command to lineto here, in case there are
# further implicit commands after this moveto.
command = 'L'
elif command == 'Z':
# Close path
if not (current_pos == start_pos):
segments.append(Line(current_pos, start_pos))
current_pos = start_pos
command = None
elif command == 'L':
x = elements.pop()
y = elements.pop()
pos = float(x) + float(y) * 1j
if not absolute:
pos += current_pos
segments.append(Line(current_pos, pos))
current_pos = pos
elif command == 'H':
x = elements.pop()
pos = float(x) + current_pos.imag * 1j
if not absolute:
pos += current_pos.real
segments.append(Line(current_pos, pos))
current_pos = pos
elif command == 'V':
y = elements.pop()
pos = current_pos.real + float(y) * 1j
if not absolute:
pos += current_pos.imag * 1j
segments.append(Line(current_pos, pos))
current_pos = pos
elif command == 'C':
logger.warn('Encountered Cubic Bezier segment. '
'It is currently not supported and will be replaced '
'by a line segment.')
for i in range(4):
# ignore control points
elements.pop()
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
segments.append(Line(current_pos, end))
current_pos = end
elif command == 'S':
logger.warn('Encountered Quadratic Bezier segment. '
'It is currently not supported and will be replaced '
'by a line segment.')
for i in range(2):
# ignore control points
elements.pop()
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
segments.append(Line(current_pos, end))
current_pos = end
elif command == 'Q':
logger.warn('Encountered Quadratic Bezier segment. '
'It is currently not supported and will be replaced '
'by a line segment.')
for i in range(2):
# ignore control points
elements.pop()
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
segments.append(Line(current_pos, end))
current_pos = end
elif command == 'T':
logger.warn('Encountered Quadratic Bezier segment. '
'It is currently not supported and will be replaced '
'by a line segment.')
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
segments.append(Line(current_pos, end))
current_pos = end
elif command == 'A':
radius = float(elements.pop()) + float(elements.pop()) * 1j
rotation = float(elements.pop())
arc = float(elements.pop())
sweep = float(elements.pop())
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
segments.append(
Arc(current_pos, radius, rotation, arc, sweep, end))
current_pos = end
return segments
def create_path(lines, circles=[]):
"""Returns a path d-string."""
def limit_digits(val):
return format(val, '.6f').rstrip('0').replace(',', '.').rstrip('.')
def different_points(a, b):
return abs(a[0] - b[0]) > 1e-6 or abs(a[1] - b[1]) > 1e-6
parts = []
for i, line in enumerate(lines):
if i == 0 or different_points(lines[i - 1][-1], line[0]):
parts.append('M{},{}'.format(*map(limit_digits, line[0])))
for point in line[1:]:
parts.append('L{},{}'.format(*map(limit_digits, point)))
for circle in circles:
cx, cy, r = circle[0][0], circle[0][1], circle[1]
parts.append('M{},{}'.format(limit_digits(cx - r), limit_digits(cy)))
parts.append('a {},{} 0 1,0 {},0'.format(
*map(limit_digits, [r, r, r + r])))
parts.append('a {},{} 0 1,0 -{},0'.format(
*map(limit_digits, [r, r, r + r])))
return ''.join(parts)