From 6f876edd09d9b81649691e529f85653f14b8fd1c Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Tue, 22 Dec 2015 02:45:48 -0500 Subject: Add PCB interface this incorporates some of @chintal's layers.py changes PCB.from_directory() simplifies loading of multiple gerbers the PCB() class should be pretty helpful going forward... the context classes could use some cleaning up, although I'd like to wait until the freecad stuff gets merged, that way we can try to refactor the context base to support more use cases --- gerber/__init__.py | 3 +- gerber/common.py | 3 + gerber/exceptions.py | 1 + gerber/ipc356.py | 89 ++++++++------- gerber/layers.py | 246 ++++++++++++++++++++++++++++++++++------- gerber/pcb.py | 107 ++++++++++++++++++ gerber/render/cairo_backend.py | 62 ++++++++--- gerber/render/render.py | 19 +--- gerber/render/theme.py | 34 ++++-- gerber/tests/test_layers.py | 33 ++++++ gerber/utils.py | 16 ++- 11 files changed, 496 insertions(+), 117 deletions(-) create mode 100644 gerber/pcb.py create mode 100644 gerber/tests/test_layers.py (limited to 'gerber') diff --git a/gerber/__init__.py b/gerber/__init__.py index b5a9014..5cfdad7 100644 --- a/gerber/__init__.py +++ b/gerber/__init__.py @@ -23,4 +23,5 @@ gerber-tools provides utilities for working with Gerber (RS-274X) and Excellon files in python. """ -from .common import read, loads \ No newline at end of file +from .common import read, loads +from .pcb import PCB diff --git a/gerber/common.py b/gerber/common.py index f8979dc..04b6423 100644 --- a/gerber/common.py +++ b/gerber/common.py @@ -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') diff --git a/gerber/exceptions.py b/gerber/exceptions.py index fdd548c..65ae905 100644 --- a/gerber/exceptions.py +++ b/gerber/exceptions.py @@ -15,6 +15,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + class ParseError(Exception): pass diff --git a/gerber/ipc356.py b/gerber/ipc356.py index b8a7ba3..7dadd22 100644 --- a/gerber/ipc356.py +++ b/gerber/ipc356.py @@ -27,8 +27,11 @@ _NNAME = re.compile(r'^NNAME\d+$') # Board Edge Coordinates _COORD = re.compile(r'X?(?P[\d\s]*)?Y?(?P[\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 '' % (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 '' % self.net class IPC356_EndOfFile(object): + def __init__(self): pass @@ -437,12 +448,14 @@ class IPC356_EndOfFile(object): def __repr__(self): return '' - + + 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 '' % self.name diff --git a/gerber/layers.py b/gerber/layers.py index b10cf16..c6a5bf7 100644 --- a/gerber/layers.py +++ b/gerber/layers.py @@ -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', ] - -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', ] - - +import os +import re +from collections import namedtuple + +from .excellon import ExcellonFile +from .ipc356 import IPC_D_356 +from .render.render import Renderable + +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(Renderable): + """ 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(Renderable): + 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 diff --git a/gerber/pcb.py b/gerber/pcb.py new file mode 100644 index 0000000..990a05c --- /dev/null +++ b/gerber/pcb.py @@ -0,0 +1,107 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# copyright 2015 Hamilton Kibbe +# +# 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 +from .render import theme + + +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 + self._theme = theme.THEMES['Default'] + self.theme = self._theme + + def __len__(self): + return len(self.layers) + + @property + def theme(self): + return self._theme + + @theme.setter + def theme(self, theme): + self._theme = theme + for layer in self.layers: + layer.settings = theme[layer.layer_class] + + @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 + diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py index 8283ae0..7acf29a 100644 --- a/gerber/render/cairo_backend.py +++ b/gerber/render/cairo_backend.py @@ -17,7 +17,7 @@ import cairocffi as cairo -from operator import mul +from operator import mul, div import math import tempfile @@ -39,16 +39,16 @@ 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 - def set_bounds(self, bounds): + 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: + 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) @@ -61,6 +61,36 @@ class GerberCairoContext(GerberContext): 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]) + def render_layers(self, layers, filename): + """ Render a set of layers + """ + self.set_bounds(layers[0].bounds, True) + self._paint_background(True) + for layer in layers: + self._render_layer(layer) + self.dump(filename) + + @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 _render_layer(self, layer): + self.color = layer.settings.color + self.alpha = layer.settings.alpha + self.invert = layer.settings.invert + if layer.settings.mirror: + raise Warning('mirrored layers aren\'t supported yet...') + if self.invert: + self._clear_mask() + for p in layer.primitives: + self.render(p) + if self.invert: + self._render_mask() + def _render_line(self, line, color): start = map(mul, line.start, self.scale) end = map(mul, line.end, self.scale) @@ -178,12 +208,13 @@ 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) + position = tuple(map(add, primitive.position, self.origin_in_inch)) + 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_rgb(*color) 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,14 +227,15 @@ 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(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])) 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): diff --git a/gerber/render/render.py b/gerber/render/render.py index 737061e..c76ead5 100644 --- a/gerber/render/render.py +++ b/gerber/render/render.py @@ -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: @@ -185,15 +184,7 @@ class GerberContext(object): class Renderable(object): - def __init__(self, color=None, alpha=None, invert=False): - self.color = color - self.alpha = alpha - self.invert = invert - - 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') + def __init__(self, settings=None): + self.settings = settings + self.primitives = [] + diff --git a/gerber/render/theme.py b/gerber/render/theme.py index eae3735..5978831 100644 --- a/gerber/render/theme.py +++ b/gerber/render/theme.py @@ -19,6 +19,9 @@ 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), @@ -31,29 +34,38 @@ COLORS = { class RenderSettings(object): - def __init__(self, color, alpha=1.0, invert=False): + def __init__(self, color, alpha=1.0, invert=False, mirror=False): self.color = color self.alpha = alpha - self.invert = False + self.invert = invert + self.mirror = mirror 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) THEMES = { 'Default': Theme(), - 'Osh Park': Theme(top=COLORS['enig copper'], - bottom=COLORS['enig copper'], - topmask=COLORS['purple soldermask'], - bottommask=COLORS['purple soldermask']), + '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)), } diff --git a/gerber/tests/test_layers.py b/gerber/tests/test_layers.py new file mode 100644 index 0000000..c77084d --- /dev/null +++ b/gerber/tests/test_layers.py @@ -0,0 +1,33 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Author: Hamilton Kibbe + +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 diff --git a/gerber/utils.py b/gerber/utils.py index 1c0af52..6653683 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -26,6 +26,7 @@ files. # Author: Hamilton Kibbe # 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 -- cgit