494 lines
16 KiB
Python
494 lines
16 KiB
Python
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
|