Merge pull request #53 from curtacircuitos/pcb_interface

Add PCB interface
This commit is contained in:
Paulo Henrique Silva 2016-01-05 22:05:19 -02:00
commit 7a53251463
20 changed files with 2435 additions and 500 deletions

View file

@ -31,4 +31,5 @@ doc-clean:
.PHONY: examples
examples:
PYTHONPATH=. $(PYTHON) examples/cairo_example.py
PYTHONPATH=. $(PYTHON) examples/pcb_example.py

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Before After
Before After

View file

@ -36,7 +36,6 @@ mask = read(os.path.join(GERBER_FOLDER, 'soldermask.GTS'))
silk = read(os.path.join(GERBER_FOLDER, 'silkscreen.GTO'))
drill = read(os.path.join(GERBER_FOLDER, 'ncdrill.DRD'))
# Create a new drawing context
ctx = GerberCairoContext()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
G75*
%MOIN*%
%OFA0B0*%
%FSLAX24Y24*%
%IPPOS*%
%LPD*%
%AMOC8*
5,1,8,0,0,1.08239X$1,22.5*
%
%ADD10C,0.0634*%
%ADD11C,0.1360*%
%ADD12C,0.0680*%
%ADD13C,0.1340*%
%ADD14C,0.0476*%
D10*
X017200Y009464D03*
X018200Y009964D03*
X018200Y010964D03*
X017200Y010464D03*
X017200Y011464D03*
X018200Y011964D03*
D11*
X020700Y012714D03*
X020700Y008714D03*
D12*
X018350Y016514D02*
X018350Y017114D01*
X017350Y017114D02*
X017350Y016514D01*
X007350Y016664D02*
X007350Y017264D01*
X006350Y017264D02*
X006350Y016664D01*
X005350Y016664D02*
X005350Y017264D01*
X001800Y012564D02*
X001200Y012564D01*
X001200Y011564D02*
X001800Y011564D01*
X001800Y010564D02*
X001200Y010564D01*
X001200Y009564D02*
X001800Y009564D01*
X001800Y008564D02*
X001200Y008564D01*
D13*
X002350Y005114D03*
X002300Y016064D03*
X020800Y016064D03*
X020800Y005064D03*
D14*
X015650Y006264D03*
X013500Y006864D03*
X012100Y005314D03*
X009250Y004064D03*
X015200Y004514D03*
X013550Y008764D03*
X013350Y010114D03*
X013300Y011464D03*
X011650Y013164D03*
X010000Y015114D03*
X006500Y013714D03*
X004150Y011564D03*
X014250Y014964D03*
X015850Y009914D03*
M02*

View file

@ -1,354 +0,0 @@
%
M48
M72
T01C0.03200
T02C0.03543
T03C0.04000
%
T01
X11212Y16343
X80212Y16343
X21212Y16343
X99212Y22143
X99212Y12143
X40212Y16343
T02
X10812Y191043
X70812Y111043
X130812Y111043
X80812Y141043
X110812Y71043
X160812Y51043
X20812Y171043
X30812Y91043
X50812Y111043
X50812Y121043
X20812Y161043
X90812Y111043
X70812Y61043
X40812Y171043
X50812Y81043
X160812Y61043
X40812Y191043
X30812Y31043
X90812Y131043
X10812Y31043
X150812Y111043
X170812Y51043
X110812Y151043
X10812Y51043
X150812Y51043
X140812Y121043
X170812Y61043
X30812Y61043
X70812Y91043
X70812Y101043
X160812Y161043
X40812Y81043
X220812Y151043
X180812Y71043
X30812Y151043
X50812Y161043
X150812Y131043
X40812Y61043
X130812Y91043
X90812Y61043
X80812Y101043
X30812Y191043
X130812Y151043
X60812Y31043
X50812Y91043
X40812Y111043
X220812Y141043
X30812Y81043
X140812Y81043
X60812Y61043
X210812Y131043
X160812Y71043
X90812Y41043
X120812Y151043
X10812Y161043
X80812Y151043
X50812Y71043
X160812Y151043
X110812Y111043
X30812Y121043
X10812Y41043
X20812Y41043
X40812Y51043
X10812Y151043
X200812Y101043
X70812Y41043
X120812Y51043
X40812Y41043
X80812Y91043
X170812Y161043
X100812Y71043
X40812Y31043
X30812Y141043
X180812Y131043
X10812Y61043
X120812Y141043
X200812Y151043
X90812Y121043
X50812Y31043
X170812Y121043
X170812Y111043
X60812Y121043
X40812Y101043
X120812Y121043
X100812Y161043
X10812Y81043
X130812Y131043
X60812Y81043
X200812Y111043
X140812Y51043
X150812Y71043
X160812Y111043
X120812Y111043
X130812Y101043
X20812Y51043
X20812Y201043
X90812Y71043
X190812Y61043
X170812Y81043
X70812Y71043
X50812Y101043
X150812Y81043
X60812Y131043
X190812Y121043
X170812Y131043
X130812Y121043
X20812Y91043
X70812Y151043
X70812Y141043
X180812Y111043
X10812Y181043
X40812Y131043
X80812Y121043
X120812Y61043
X160812Y101043
X90812Y31043
X10812Y91043
X80812Y71043
X100812Y121043
X100812Y51043
X160812Y121043
X40812Y71043
X50812Y51043
X180812Y81043
X90812Y51043
X60812Y71043
X40812Y161043
X190812Y141043
X20812Y31043
X100812Y151043
X200812Y141043
X180812Y151043
X60812Y51043
X120812Y131043
X150812Y141043
X180812Y51043
X150812Y101043
X170812Y101043
X150812Y151043
X30812Y111043
X90812Y151043
X80812Y131043
X170812Y151043
X80812Y51043
X10812Y201043
X60812Y151043
X140812Y111043
X100812Y91043
X90812Y161043
X130812Y81043
X190812Y111043
X140812Y101043
X20812Y71043
X150812Y121043
X90812Y141043
X60812Y111043
X110812Y121043
X30812Y71043
X30812Y51043
X210812Y141043
X50812Y61043
X140812Y131043
X30812Y201043
X190812Y101043
X70812Y81043
X20812Y121043
X20812Y191043
X80812Y161043
X80812Y81043
X20812Y151043
X40812Y121043
X80812Y31043
X80812Y111043
X190812Y151043
X30812Y181043
X60812Y91043
X110812Y61043
X180812Y61043
X10812Y141043
X50812Y131043
X130812Y51043
X50812Y151043
X110812Y51043
X70812Y131043
X60812Y41043
X200812Y161043
X80812Y61043
X140812Y161043
X190812Y81043
X20812Y141043
X70812Y161043
X140812Y151043
X20812Y61043
X20812Y81043
X100812Y131043
X200812Y131043
X140812Y141043
X40812Y151043
X40812Y91043
X60812Y101043
X160812Y81043
X130812Y71043
X30812Y41043
X10812Y71043
X180812Y141043
X170812Y141043
X180812Y91043
X180812Y101043
X150812Y61043
X120812Y161043
X90812Y101043
X200812Y121043
X190812Y91043
X160812Y141043
X130812Y161043
X20812Y101043
X90812Y81043
X190812Y161043
X30812Y171043
X40812Y181043
X70812Y51043
X110812Y101043
X60812Y141043
X120812Y101043
X30812Y161043
X100812Y141043
X220812Y131043
X50812Y141043
X30812Y101043
X60812Y161043
X150812Y161043
X20812Y131043
X150812Y91043
X100812Y61043
X10812Y131043
X30812Y131043
X100812Y41043
X140812Y61043
X210812Y151043
X70812Y121043
X100812Y101043
X180812Y121043
X40812Y201043
X190812Y71043
X10812Y171043
X110812Y141043
X130812Y61043
X110812Y81043
X80812Y41043
X50812Y41043
X110812Y131043
X190812Y131043
X130812Y141043
X140812Y91043
X20812Y111043
X140812Y71043
X170812Y91043
X120812Y91043
X190812Y51043
X120812Y81043
X160812Y91043
X100812Y81043
X120812Y71043
X10812Y121043
X170812Y71043
X110812Y91043
X100812Y111043
X110812Y161043
X70812Y31043
X90812Y91043
X40812Y141043
X20812Y181043
X210812Y161043
X180812Y161043
X160812Y131043
T03
X86712Y189043
X213012Y23043
X126732Y201114
X96712Y189043
X86732Y201114
X56732Y201114
X142812Y23443
X106712Y189043
X112754Y11450
X182720Y200950
X106732Y201114
X207259Y55639
X207259Y81239
X203131Y11150
X76732Y201114
X192720Y200950
X66712Y189043
X96732Y201114
X193131Y11150
X66732Y201114
X203012Y23043
X122754Y11450
X76712Y189043
X173131Y11150
X192712Y188843
X116712Y189043
X116732Y201114
X213131Y11150
X162720Y200950
X225059Y55639
X183131Y11150
X126712Y189043
X183012Y23043
X212712Y188843
X163131Y11150
X213563Y110846
X122812Y23443
X132812Y23443
X182712Y188843
X212720Y200950
X202720Y200950
X193012Y23043
X213563Y120846
X172720Y200950
X225059Y81239
X223563Y120846
X56712Y189043
X172712Y188843
X213563Y100846
X142720Y200950
X163012Y23043
X142754Y11450
X223563Y110846
X132754Y11450
X142712Y188843
X162712Y188843
X152712Y188843
X223563Y100846
X202712Y188843
X112812Y23443
X173012Y23043
X152720Y200950
M30

BIN
examples/pcb_bottom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

39
examples/pcb_example.py Normal file
View file

@ -0,0 +1,39 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""
This example demonstrates the use of pcb-tools with cairo to render composite
images using the PCB interface
"""
import os
from gerber import PCB
from gerber.render import GerberCairoContext, theme
GERBER_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), 'gerbers'))
# Create a new drawing context
ctx = GerberCairoContext()
# Create a new PCB
pcb = PCB.from_directory(GERBER_FOLDER)
pcb.theme = theme.THEMES['OSH Park']
ctx.render_layers(pcb.top_layers, os.path.join(os.path.dirname(__file__), 'pcb_top.png'))
ctx.render_layers(pcb.bottom_layers, os.path.join(os.path.dirname(__file__), 'pcb_bottom.png'))

BIN
examples/pcb_top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -23,4 +23,5 @@ gerber-tools provides utilities for working with Gerber (RS-274X) and Excellon
files in python.
"""
from .common import read, loads
from .common import read, loads
from .pcb import PCB

View file

@ -17,6 +17,7 @@
from . import rs274x
from . import excellon
from . import ipc356
from .exceptions import ParseError
from .utils import detect_file_format
@ -43,6 +44,8 @@ def read(filename):
return rs274x.read(filename)
elif fmt == 'excellon':
return excellon.read(filename)
elif fmt == 'ipc_d_356':
return ipc356.read(filename)
else:
raise ParseError('Unable to detect file format')

View file

@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
class ParseError(Exception):
pass

View file

@ -27,8 +27,11 @@ _NNAME = re.compile(r'^NNAME\d+$')
# Board Edge Coordinates
_COORD = re.compile(r'X?(?P<x>[\d\s]*)?Y?(?P<y>[\d\s]*)?')
_SM_FIELD = {'0': 'none', '1': 'primary side', '2': 'secondary side', '3': 'both'}
_SM_FIELD = {
'0': 'none',
'1': 'primary side',
'2': 'secondary side',
'3': 'both'}
def read(filename):
@ -51,17 +54,17 @@ def read(filename):
class IPC_D_356(CamFile):
@classmethod
def from_file(self, filename):
p = IPC_D_356_Parser()
return p.parse(filename)
def from_file(cls, filename):
parser = IPC_D_356_Parser()
return parser.parse(filename)
def __init__(self, statements, settings, primitives=None):
def __init__(self, statements, settings, primitives=None, filename=None):
self.statements = statements
self.units = settings.units
self.angle_units = settings.angle_units
self.primitives = [TestRecord((rec.x_coord, rec.y_coord), rec.net_name,
rec.access) for rec in self.test_records]
self.filename = filename
@property
def settings(self):
@ -95,8 +98,6 @@ class IPC_D_356(CamFile):
adjacent_nets.add(record.net)
nets.append(IPC356_Net(net, adjacent_nets))
return nets
@property
def components(self):
@ -109,14 +110,12 @@ class IPC_D_356(CamFile):
@property
def outlines(self):
return [stmt for stmt in self.statements
return [stmt for stmt in self.statements
if isinstance(stmt, IPC356_Outline)]
@property
def adjacency_records(self):
return [record for record in self.statements
return [record for record in self.statements
if isinstance(record, IPC356_Adjacency)]
def render(self, ctx, layer='both', filename=None):
@ -133,6 +132,7 @@ class IPC_D_356(CamFile):
class IPC_D_356_Parser(object):
# TODO: Allow multi-line statements (e.g. Altium board edge)
def __init__(self):
self.units = 'inch'
self.angle_units = 'degrees'
@ -158,8 +158,7 @@ class IPC_D_356_Parser(object):
oldline = line
self._parse_line(oldline)
return IPC_D_356(self.statements, self.settings)
return IPC_D_356(self.statements, self.settings, filename=filename)
def _parse_line(self, line):
if not len(line):
@ -201,18 +200,23 @@ class IPC_D_356_Parser(object):
elif line[0:3] == '378':
# Conductor
self.statements.append(IPC356_Conductor.from_line(line, self.settings))
self.statements.append(
IPC356_Conductor.from_line(
line, self.settings))
elif line[0:3] == '379':
# Net Adjacency
self.statements.append(IPC356_Adjacency.from_line(line))
elif line[0:3] == '389':
# Outline
self.statements.append(IPC356_Outline.from_line(line, self.settings))
self.statements.append(
IPC356_Outline.from_line(
line, self.settings))
class IPC356_Comment(object):
@classmethod
def from_line(cls, line):
if line[0] != 'C':
@ -228,6 +232,7 @@ class IPC356_Comment(object):
class IPC356_Parameter(object):
@classmethod
def from_line(cls, line):
if line[0] != 'P':
@ -246,13 +251,14 @@ class IPC356_Parameter(object):
class IPC356_TestRecord(object):
@classmethod
def from_line(cls, line, settings):
offset = 0
units = settings.units
angle = settings.angle_units
feature_types = {'1':'through-hole', '2': 'smt',
'3':'tooling-feature', '4':'tooling-hole'}
feature_types = {'1': 'through-hole', '2': 'smt',
'3': 'tooling-feature', '4': 'tooling-hole'}
access = ['both', 'top', 'layer2', 'layer3', 'layer4', 'layer5',
'layer6', 'layer7', 'bottom']
record = {}
@ -290,21 +296,21 @@ class IPC356_TestRecord(object):
if len(line) >= (43 + offset):
end = len(line) - 1 if len(line) < (50 + offset) else (49 + offset)
coord = int(line[42 + offset:end].strip())
record['x_coord'] = (coord * 0.0001 if units == 'inch'
record['x_coord'] = (coord * 0.0001 if units == 'inch'
else coord * 0.001)
if len(line) >= (51 + offset):
end = len(line) - 1 if len(line) < (58 + offset) else (57 + offset)
coord = int(line[50 + offset:end].strip())
record['y_coord'] = (coord * 0.0001 if units == 'inch'
else coord * 0.001)
record['y_coord'] = (coord * 0.0001 if units == 'inch'
else coord * 0.001)
if len(line) >= (59 + offset):
end = len(line) - 1 if len(line) < (63 + offset) else (62 + offset)
dim = line[58 + offset:end].strip()
if dim != '':
record['rect_x'] = (int(dim) * 0.0001 if units == 'inch'
else int(dim) * 0.001)
else int(dim) * 0.001)
if len(line) >= (64 + offset):
end = len(line) - 1 if len(line) < (68 + offset) else (67 + offset)
@ -321,7 +327,7 @@ class IPC356_TestRecord(object):
else math.degrees(rot))
if len(line) >= (74 + offset):
end = 74 + offset
end = 74 + offset
sm_info = line[73 + offset:end].strip()
record['soldermask_info'] = _SM_FIELD.get(sm_info)
@ -337,7 +343,8 @@ class IPC356_TestRecord(object):
def __repr__(self):
return '<IPC-D-356 %s Test Record: %s>' % (self.net_name,
self.feature_type)
self.feature_type)
class IPC356_Outline(object):
@ -365,24 +372,27 @@ class IPC356_Outline(object):
class IPC356_Conductor(object):
@classmethod
def from_line(cls, line, settings):
if line[0:3] != '378':
raise ValueError('Not a valid IPC-D-356 Conductor statement')
scale = 0.0001 if settings.units == 'inch' else 0.001
net_name = line[3:17].strip()
layer = int(line[19:21])
# Parse out aperture definiting
raw_aperture = line[22:].split()[0]
aperture_dict = _COORD.match(raw_aperture).groupdict()
x = 0
y = 0
x = int(aperture_dict['x']) * scale if aperture_dict['x'] is not '' else None
y = int(aperture_dict['y']) * scale if aperture_dict['y'] is not '' else None
x = int(aperture_dict['x']) * \
scale if aperture_dict['x'] is not '' else None
y = int(aperture_dict['y']) * \
scale if aperture_dict['y'] is not '' else None
aperture = (x, y)
# Parse out conductor shapes
shapes = []
coord_list = ' '.join(line[22:].split()[1:])
@ -399,7 +409,7 @@ class IPC356_Conductor(object):
shape.append((x * scale, y * scale))
shapes.append(tuple(shape))
return cls(net_name, layer, aperture, tuple(shapes))
def __init__(self, net_name, layer, aperture, shapes):
self.net_name = net_name
self.layer = layer
@ -417,18 +427,19 @@ class IPC356_Adjacency(object):
if line[0:3] != '379':
raise ValueError('Not a valid IPC-D-356 Conductor statement')
nets = line[3:].strip().split()
return cls(nets[0], nets[1:])
def __init__(self, net, adjacent_nets):
self.net = net
self.adjacent_nets = adjacent_nets
def __repr__(self):
return '<IPC-D-356 %s Adjacency Record>' % self.net
class IPC356_EndOfFile(object):
def __init__(self):
pass
@ -437,12 +448,14 @@ class IPC356_EndOfFile(object):
def __repr__(self):
return '<IPC-D-356 EOF>'
class IPC356_Net(object):
def __init__(self, name, adjacent_nets):
self.name = name
self.adjacent_nets = set(adjacent_nets) if adjacent_nets is not None else set()
self.adjacent_nets = set(
adjacent_nets) if adjacent_nets is not None else set()
def __repr__(self):
return '<IPC-D-356 Net %s>' % self.name

View file

@ -15,40 +15,212 @@
# See the License for the specific language governing permissions and
# limitations under the License.
top_copper_ext = ['gtl', 'cmp', 'top', ]
top_copper_name = ['art01', 'top', 'GTL', 'layer1', 'soldcom', 'comp', ]
import os
import re
from collections import namedtuple
bottom_copper_ext = ['gbl', 'sld', 'bot', 'sol', ]
bottom_coppper_name = ['art02', 'bottom', 'bot', 'GBL', 'layer2', 'soldsold', ]
internal_layer_ext = ['in', 'gt1', 'gt2', 'gt3', 'gt4', 'gt5', 'gt6', 'g1',
'g2', 'g3', 'g4', 'g5', 'g6', ]
internal_layer_name = ['art', 'internal']
power_plane_name = ['pgp', 'pwr', ]
ground_plane_name = ['gp1', 'gp2', 'gp3', 'gp4', 'gt5', 'gp6', 'gnd',
'ground', ]
top_silk_ext = ['gto', 'sst', 'plc', 'ts', 'skt', ]
top_silk_name = ['sst01', 'topsilk', 'silk', 'slk', 'sst', ]
bottom_silk_ext = ['gbo', 'ssb', 'pls', 'bs', 'skb', ]
bottom_silk_name = ['sst', 'bsilk', 'ssb', 'botsilk', ]
top_mask_ext = ['gts', 'stc', 'tmk', 'smt', 'tr', ]
top_mask_name = ['sm01', 'cmask', 'tmask', 'mask1', 'maskcom', 'topmask',
'mst', ]
bottom_mask_ext = ['gbs', 'sts', 'bmk', 'smb', 'br', ]
bottom_mask_name = ['sm', 'bmask', 'mask2', 'masksold', 'botmask', 'msb', ]
top_paste_ext = ['gtp', 'tm']
top_paste_name = ['sp01', 'toppaste', 'pst']
bottom_paste_ext = ['gbp', 'bm']
bottom_paste_name = ['sp02', 'botpaste', 'psb']
board_outline_ext = ['gko']
board_outline_name = ['BDR', 'border', 'out', ]
from .excellon import ExcellonFile
from .ipc356 import IPC_D_356
Hint = namedtuple('Hint', 'layer ext name')
hints = [
Hint(layer='top',
ext=['gtl', 'cmp', 'top', ],
name=['art01', 'top', 'GTL', 'layer1', 'soldcom', 'comp', ]
),
Hint(layer='bottom',
ext=['gbl', 'sld', 'bot', 'sol', 'bottom', ],
name=['art02', 'bottom', 'bot', 'GBL', 'layer2', 'soldsold', ]
),
Hint(layer='internal',
ext=['in', 'gt1', 'gt2', 'gt3', 'gt4', 'gt5', 'gt6', 'g1',
'g2', 'g3', 'g4', 'g5', 'g6', ],
name=['art', 'internal', 'pgp', 'pwr', 'gp1', 'gp2', 'gp3', 'gp4',
'gt5', 'gp6', 'gnd', 'ground', ]
),
Hint(layer='topsilk',
ext=['gto', 'sst', 'plc', 'ts', 'skt', 'topsilk', ],
name=['sst01', 'topsilk', 'silk', 'slk', 'sst', ]
),
Hint(layer='bottomsilk',
ext=['gbo', 'ssb', 'pls', 'bs', 'skb', 'bottomsilk', ],
name=['bsilk', 'ssb', 'botsilk', ]
),
Hint(layer='topmask',
ext=['gts', 'stc', 'tmk', 'smt', 'tr', 'topmask', ],
name=['sm01', 'cmask', 'tmask', 'mask1', 'maskcom', 'topmask',
'mst', ]
),
Hint(layer='bottommask',
ext=['gbs', 'sts', 'bmk', 'smb', 'br', 'bottommask', ],
name=['sm', 'bmask', 'mask2', 'masksold', 'botmask', 'msb', ]
),
Hint(layer='toppaste',
ext=['gtp', 'tm', 'toppaste', ],
name=['sp01', 'toppaste', 'pst']
),
Hint(layer='bottompaste',
ext=['gbp', 'bm', 'bottompaste', ],
name=['sp02', 'botpaste', 'psb']
),
Hint(layer='outline',
ext=['gko', 'outline', ],
name=['BDR', 'border', 'out', ]
),
Hint(layer='ipc_netlist',
ext=['ipc'],
name=[],
),
]
def guess_layer_class(filename):
try:
directory, name = os.path.split(filename)
name, ext = os.path.splitext(name.lower())
for hint in hints:
patterns = [r'^(\w*[.-])*{}([.-]\w*)?$'.format(x) for x in hint.name]
if ext[1:] in hint.ext or any(re.findall(p, name, re.IGNORECASE) for p in patterns):
return hint.layer
except:
pass
return 'unknown'
def sort_layers(layers):
layer_order = ['outline', 'toppaste', 'topsilk', 'topmask', 'top',
'internal', 'bottom', 'bottommask', 'bottomsilk',
'bottompaste', 'drill', ]
output = []
drill_layers = [layer for layer in layers if layer.layer_class == 'drill']
internal_layers = list(sorted([layer for layer in layers if layer.layer_class == 'internal']))
for layer_class in layer_order:
if layer_class == 'internal':
output += internal_layers
elif layer_class == 'drill':
output += drill_layers
else:
for layer in layers:
if layer.layer_class == layer_class:
output.append(layer)
return output
class PCBLayer(object):
""" Base class for PCB Layers
Parameters
----------
source : CAMFile
CAMFile representing the layer
Attributes
----------
filename : string
Source Filename
"""
@classmethod
def from_gerber(cls, camfile):
filename = camfile.filename
layer_class = guess_layer_class(filename)
if isinstance(camfile, ExcellonFile) or (layer_class == 'drill'):
return DrillLayer.from_gerber(camfile)
elif layer_class == 'internal':
return InternalLayer.from_gerber(camfile)
if isinstance(camfile, IPC_D_356):
layer_class = 'ipc_netlist'
return cls(filename, layer_class, camfile)
def __init__(self, filename=None, layer_class=None, cam_source=None, **kwargs):
super(PCBLayer, self).__init__(**kwargs)
self.filename = filename
self.layer_class = layer_class
self.cam_source = cam_source
self.surface = None
self.primitives = cam_source.primitives if cam_source is not None else []
@property
def bounds(self):
if self.cam_source is not None:
return self.cam_source.bounds
else:
return None
class DrillLayer(PCBLayer):
@classmethod
def from_gerber(cls, camfile):
return cls(camfile.filename, camfile)
def __init__(self, filename=None, cam_source=None, layers=None, **kwargs):
super(DrillLayer, self).__init__(filename, 'drill', cam_source, **kwargs)
self.layers = layers if layers is not None else ['top', 'bottom']
class InternalLayer(PCBLayer):
@classmethod
def from_gerber(cls, camfile):
filename = camfile.filename
try:
order = int(re.search(r'\d+', filename).group())
except:
order = 0
return cls(filename, camfile, order)
def __init__(self, filename=None, cam_source=None, order=0, **kwargs):
super(InternalLayer, self).__init__(filename, 'internal', cam_source, **kwargs)
self.order = order
def __eq__(self, other):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order == other.order)
def __ne__(self, other):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order != other.order)
def __gt__(self, other):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order > other.order)
def __lt__(self, other):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order < other.order)
def __ge__(self, other):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order >= other.order)
def __le__(self, other):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order <= other.order)
class LayerSet(object):
def __init__(self, name, layers, **kwargs):
super(LayerSet, self).__init__(**kwargs)
self.name = name
self.layers = list(layers)
def __len__(self):
return len(self.layers)
def __getitem__(self, item):
return self.layers[item]
def to_render(self):
return self.layers
def apply_theme(self, theme):
pass

94
gerber/pcb.py Normal file
View file

@ -0,0 +1,94 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from .exceptions import ParseError
from .layers import PCBLayer, LayerSet, sort_layers
from .common import read as gerber_read
from .utils import listdir
class PCB(object):
@classmethod
def from_directory(cls, directory, board_name=None, verbose=False):
layers = []
names = set()
# Validate
directory = os.path.abspath(directory)
if not os.path.isdir(directory):
raise TypeError('{} is not a directory.'.format(directory))
# Load gerber files
for filename in listdir(directory, True, True):
try:
camfile = gerber_read(os.path.join(directory, filename))
layer = PCBLayer.from_gerber(camfile)
layers.append(layer)
names.add(os.path.splitext(filename)[0])
if verbose:
print('Added {} layer <{}>'.format(layer.layer_class, filename))
except ParseError:
if verbose:
print('Skipping file {}'.format(filename))
# Try to guess board name
if board_name is None:
if len(names) == 1:
board_name = names.pop()
else:
board_name = os.path.basename(directory)
# Return PCB
return cls(layers, board_name)
def __init__(self, layers, name=None):
self.layers = sort_layers(layers)
self.name = name
def __len__(self):
return len(self.layers)
@property
def top_layers(self):
board_layers = [l for l in reversed(self.layers) if l.layer_class in ('topsilk', 'topmask', 'top')]
drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
return board_layers + drill_layers
@property
def bottom_layers(self):
board_layers = [l for l in self.layers if l.layer_class in ('bottomsilk', 'bottommask', 'bottom')]
drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
return board_layers + drill_layers
@property
def drill_layers(self):
return [l for l in self.layers if l.layer_class == 'drill']
@property
def layer_count(self):
""" Number of *COPPER* layers
"""
return len([l for l in self.layers if l.layer_class in ('top', 'bottom', 'internal')])
@property
def board_bounds(self):
for layer in self.layers:
if layer.layer_class == 'outline':
return layer.bounds
for layer in self.layers:
if layer.layer_class == 'top':
return layer.bounds

View file

@ -21,7 +21,8 @@ from operator import mul
import math
import tempfile
from .render import GerberContext
from .render import GerberContext, RenderSettings
from .theme import THEMES
from ..primitives import *
try:
@ -39,16 +40,25 @@ class GerberCairoContext(GerberContext):
self.bg = False
self.mask = None
self.mask_ctx = None
self.origin_in_pixels = None
self.size_in_pixels = None
self.origin_in_inch = None
self.size_in_inch = None
self._xform_matrix = None
def set_bounds(self, bounds):
@property
def origin_in_pixels(self):
return tuple(map(mul, self.origin_in_inch, self.scale)) if self.origin_in_inch is not None else (0.0, 0.0)
@property
def size_in_pixels(self):
return tuple(map(mul, self.size_in_inch, self.scale)) if self.size_in_inch is not None else (0.0, 0.0)
def set_bounds(self, bounds, new_surface=False):
origin_in_inch = (bounds[0][0], bounds[1][0])
size_in_inch = (abs(bounds[0][1] - bounds[0][0]), abs(bounds[1][1] - bounds[1][0]))
size_in_pixels = map(mul, size_in_inch, self.scale)
self.origin_in_pixels = tuple(map(mul, origin_in_inch, self.scale)) if self.origin_in_pixels is None else self.origin_in_pixels
self.size_in_pixels = size_in_pixels if self.size_in_pixels is None else self.size_in_pixels
if self.surface is None:
size_in_pixels = tuple(map(mul, size_in_inch, self.scale))
self.origin_in_inch = origin_in_inch if self.origin_in_inch is None else self.origin_in_inch
self.size_in_inch = size_in_inch if self.size_in_inch is None else self.size_in_inch
if (self.surface is None) or new_surface:
self.surface_buffer = tempfile.NamedTemporaryFile()
self.surface = cairo.SVGSurface(self.surface_buffer, size_in_pixels[0], size_in_pixels[1])
self.ctx = cairo.Context(self.surface)
@ -60,6 +70,58 @@ class GerberCairoContext(GerberContext):
self.mask_ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
self.mask_ctx.scale(1, -1)
self.mask_ctx.translate(-(origin_in_inch[0] * self.scale[0]), (-origin_in_inch[1]*self.scale[0]) - size_in_pixels[1])
self._xform_matrix = cairo.Matrix(xx=1.0, yy=-1.0, x0=-self.origin_in_pixels[0], y0=self.size_in_pixels[1] + self.origin_in_pixels[1])
def render_layers(self, layers, filename, theme=THEMES['default']):
""" Render a set of layers
"""
self.set_bounds(layers[0].bounds, True)
self._paint_background(True)
for layer in layers:
self._render_layer(layer, theme)
self.dump(filename)
def dump(self, filename):
""" Save image as `filename`
"""
is_svg = filename.lower().endswith(".svg")
if is_svg:
self.surface.finish()
self.surface_buffer.flush()
with open(filename, "w") as f:
self.surface_buffer.seek(0)
f.write(self.surface_buffer.read())
f.flush()
else:
self.surface.write_to_png(filename)
def dump_str(self):
""" Return a string containing the rendered image.
"""
fobj = StringIO()
self.surface.write_to_png(fobj)
return fobj.getvalue()
def dump_svg_str(self):
""" Return a string containg the rendered SVG.
"""
self.surface.finish()
self.surface_buffer.flush()
return self.surface_buffer.read()
def _render_layer(self, layer, theme=THEMES['default']):
settings = theme.get(layer.layer_class, RenderSettings())
self.color = settings.color
self.alpha = settings.alpha
self.invert = settings.invert
if settings.mirror:
raise Warning('mirrored layers aren\'t supported yet...')
if self.invert:
self._clear_mask()
for prim in layer.primitives:
self.render(prim)
if self.invert:
self._render_mask()
def _render_line(self, line, color):
start = map(mul, line.start, self.scale)
@ -178,12 +240,14 @@ class GerberCairoContext(GerberContext):
self._render_circle(circle, color)
def _render_test_record(self, primitive, color):
self.ctx.select_font_face('monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
self.ctx.set_font_size(200)
self._render_circle(Circle(primitive.position, 0.01), color)
self.ctx.set_source_rgb(*color)
position = tuple(map(add, primitive.position, self.origin_in_inch))
self.ctx.set_operator(cairo.OPERATOR_OVER)
self.ctx.select_font_face('monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
self.ctx.set_font_size(13)
self._render_circle(Circle(position, 0.015), color)
self.ctx.set_source_rgba(*color, alpha=self.alpha)
self.ctx.set_operator(cairo.OPERATOR_OVER if primitive.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
self.ctx.move_to(*[self.scale[0] * (coord + 0.01) for coord in primitive.position])
self.ctx.move_to(*[self.scale[0] * (coord + 0.015) for coord in position])
self.ctx.scale(1, -1)
self.ctx.show_text(primitive.net_name)
self.ctx.scale(1, -1)
@ -196,38 +260,12 @@ class GerberCairoContext(GerberContext):
def _render_mask(self):
self.ctx.set_operator(cairo.OPERATOR_OVER)
ptn = cairo.SurfacePattern(self.mask)
ptn.set_matrix(cairo.Matrix(xx=1.0, yy=-1.0, x0=-self.origin_in_pixels[0], y0=self.size_in_pixels[1] + self.origin_in_pixels[1]))
ptn.set_matrix(self._xform_matrix)
self.ctx.set_source(ptn)
self.ctx.paint()
def _paint_background(self):
if not self.bg:
def _paint_background(self, force=False):
if (not self.bg) or force:
self.bg = True
self.ctx.set_source_rgba(*self.background_color)
self.ctx.set_source_rgba(*self.background_color, alpha=1.0)
self.ctx.paint()
def dump(self, filename):
is_svg = filename.lower().endswith(".svg")
if is_svg:
self.surface.finish()
self.surface_buffer.flush()
with open(filename, "w") as f:
self.surface_buffer.seek(0)
f.write(self.surface_buffer.read())
f.flush()
else:
self.surface.write_to_png(filename)
def dump_str(self):
""" Return a string containing the rendered image.
"""
fobj = StringIO()
self.surface.write_to_png(fobj)
return fobj.getvalue()
def dump_svg_str(self):
""" Return a string containg the rendered SVG.
"""
self.surface.finish()
self.surface_buffer.flush()
return self.surface_buffer.read()

View file

@ -60,7 +60,6 @@ class GerberContext(object):
def __init__(self, units='inch'):
self._units = units
self._color = (0.7215, 0.451, 0.200)
self._drill_color = (0.25, 0.25, 0.25)
self._background_color = (0.0, 0.0, 0.0)
self._alpha = 1.0
self._invert = False
@ -150,7 +149,7 @@ class GerberContext(object):
elif isinstance(primitive, Polygon):
self._render_polygon(primitive, color)
elif isinstance(primitive, Drill):
self._render_drill(primitive, self.drill_color)
self._render_drill(primitive, color)
elif isinstance(primitive, TestRecord):
self._render_test_record(primitive, color)
else:
@ -184,16 +183,10 @@ class GerberContext(object):
pass
class Renderable(object):
def __init__(self, color=None, alpha=None, invert=False):
class RenderSettings(object):
def __init__(self, color=(0.0, 0.0, 0.0), alpha=1.0, invert=False, mirror=False):
self.color = color
self.alpha = alpha
self.invert = invert
self.mirror = mirror
def to_render(self):
""" Override this in subclass. Should return a list of Primitives or Renderables
"""
raise NotImplementedError('to_render() must be implemented in subclass')
def apply_theme(self, theme):
raise NotImplementedError('apply_theme() must be implemented in subclass')

View file

@ -16,9 +16,14 @@
# limitations under the License.
from .render import RenderSettings
COLORS = {
'black': (0.0, 0.0, 0.0),
'white': (1.0, 1.0, 1.0),
'red': (1.0, 0.0, 0.0),
'green': (0.0, 1.0, 0.0),
'blue' : (0.0, 0.0, 1.0),
'fr-4': (0.290, 0.345, 0.0),
'green soldermask': (0.0, 0.612, 0.396),
'blue soldermask': (0.059, 0.478, 0.651),
@ -30,30 +35,36 @@ COLORS = {
}
class RenderSettings(object):
def __init__(self, color, alpha=1.0, invert=False):
self.color = color
self.alpha = alpha
self.invert = False
class Theme(object):
def __init__(self, **kwargs):
self.background = kwargs.get('background', RenderSettings(COLORS['black'], 0.0))
def __init__(self, name=None, **kwargs):
self.name = 'Default' if name is None else name
self.background = kwargs.get('background', RenderSettings(COLORS['black'], alpha=0.0))
self.topsilk = kwargs.get('topsilk', RenderSettings(COLORS['white']))
self.bottomsilk = kwargs.get('bottomsilk', RenderSettings(COLORS['white']))
self.topmask = kwargs.get('topmask', RenderSettings(COLORS['green soldermask'], 0.8, True))
self.bottommask = kwargs.get('bottommask', RenderSettings(COLORS['green soldermask'], 0.8, True))
self.topmask = kwargs.get('topmask', RenderSettings(COLORS['green soldermask'], alpha=0.8, invert=True))
self.bottommask = kwargs.get('bottommask', RenderSettings(COLORS['green soldermask'], alpha=0.8, invert=True))
self.top = kwargs.get('top', RenderSettings(COLORS['hasl copper']))
self.bottom = kwargs.get('top', RenderSettings(COLORS['hasl copper']))
self.drill = kwargs.get('drill', self.background)
self.drill = kwargs.get('drill', RenderSettings(COLORS['black']))
self.ipc_netlist = kwargs.get('ipc_netlist', RenderSettings(COLORS['red']))
def __getitem__(self, key):
return getattr(self, key)
def get(self, key, noneval=None):
val = getattr(self, key)
return val if val is not None else noneval
THEMES = {
'Default': Theme(),
'Osh Park': Theme(top=COLORS['enig copper'],
bottom=COLORS['enig copper'],
topmask=COLORS['purple soldermask'],
bottommask=COLORS['purple soldermask']),
'default': Theme(),
'OSH Park': Theme(name='OSH Park',
top=RenderSettings(COLORS['enig copper']),
bottom=RenderSettings(COLORS['enig copper']),
topmask=RenderSettings(COLORS['purple soldermask'], alpha=0.8, invert=True),
bottommask=RenderSettings(COLORS['purple soldermask'], alpha=0.8, invert=True)),
'Blue': Theme(name='Blue',
topmask=RenderSettings(COLORS['blue soldermask'], alpha=0.8, invert=True),
bottommask=RenderSettings(COLORS['blue soldermask'], alpha=0.8, invert=True)),
}

View file

@ -0,0 +1,33 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
from .tests import *
from ..layers import guess_layer_class, hints
def test_guess_layer_class():
""" Test layer type inferred correctly from filename
"""
# Add any specific test cases here (filename, layer_class)
test_vectors = [(None, 'unknown'), ('NCDRILL.TXT', 'unknown'),
('example_board.gtl', 'top'),
('exampmle_board.sst', 'topsilk'),
('ipc-d-356.ipc', 'ipc_netlist'),]
for hint in hints:
for ext in hint.ext:
assert_equal(hint.layer, guess_layer_class('board.{}'.format(ext)))
for name in hint.name:
assert_equal(hint.layer, guess_layer_class('{}.pho'.format(name)))
for filename, layer_class in test_vectors:
assert_equal(layer_class, guess_layer_class(filename))
def test_sort_layers():
""" Test layer ordering
"""
pass

View file

@ -26,6 +26,7 @@ files.
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
# License:
import os
from math import radians, sin, cos
from operator import sub
@ -219,7 +220,10 @@ def detect_file_format(data):
if 'M48' in line:
return 'excellon'
elif '%FS' in line:
return'rs274x'
return 'rs274x'
elif ((len(line.split()) >= 2) and
(line.split()[0] == 'P') and (line.split()[1] == 'JOB')):
return 'ipc_d_356'
return 'unknown'
@ -288,3 +292,13 @@ def rotate_point(point, angle, center=(0.0, 0.0)):
x = center[0] + (cos(angle) * xdelta) - (sin(angle) * ydelta)
y = center[1] + (sin(angle) * xdelta) - (cos(angle) * ydelta)
return (x, y)
def listdir(directory, ignore_hidden=True, ignore_os=True):
os_files = ('.DS_Store', 'Thumbs.db', 'ethumbs.db')
files = os.listdir(directory)
if ignore_hidden:
files = [f for f in files if not f.startswith('.')]
if ignore_os:
files = [f for f in files if not f in os_files]
return files