From 76c03a55c91addff71339d80cf17560926f1580b Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 10 Oct 2014 20:36:38 -0400 Subject: Working region fills and level polarity. Renders Altium-generated gerbers like a champ! --- gerber/gerber_statements.py | 16 ++++++++-------- gerber/render/render.py | 23 +++++++++++++++-------- gerber/render/svgwrite_backend.py | 22 ++++++++++++++++++++-- 3 files changed, 43 insertions(+), 18 deletions(-) (limited to 'gerber') diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 9072b58..a22eae2 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -17,9 +17,9 @@ __all__ = ['FSParamStmt', 'MOParamStmt', 'IPParamStmt', 'OFParamStmt', class Statement(object): """ Gerber statement Base class - + The statement class provides a type attribute. - + Parameters ---------- type : string @@ -27,7 +27,7 @@ class Statement(object): Attributes ---------- - type : string + type : string String identifying the statement type. """ def __init__(self, stype): @@ -45,9 +45,9 @@ class Statement(object): class ParamStmt(Statement): """ Gerber parameter statement Base class - + The parameter statement class provides a parameter type attribute. - + Parameters ---------- param : string @@ -55,7 +55,7 @@ class ParamStmt(Statement): Attributes ---------- - param : string + param : string Parameter type code """ def __init__(self, param): @@ -260,7 +260,7 @@ class LPParamStmt(ParamStmt): @classmethod def from_dict(cls, stmt_dict): - param = stmt_dict.get('lp') + param = stmt_dict['param'] lp = 'clear' if stmt_dict.get('lp') == 'C' else 'dark' return cls(param, lp) @@ -667,6 +667,6 @@ class UnknownStmt(Statement): def __init__(self, line): Statement.__init__(self, "UNKNOWN") self.line = line - + def to_gerber(self): return self.line diff --git a/gerber/render/render.py b/gerber/render/render.py index e76aed1..48a53f8 100644 --- a/gerber/render/render.py +++ b/gerber/render/render.py @@ -96,7 +96,7 @@ class GerberContext(object): self.level_polarity = 'dark' self.region_mode = 'off' self.quadrant_mode = 'multi-quadrant' - + self.step_and_repeat = (1, 1, 0, 0) 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) @@ -415,6 +415,12 @@ class GerberContext(object): """ pass + def region_contour(self, x, y): + pass + + def fill_region(self): + pass + def evaluate(self, stmt): """ Evaluate Gerber statement and update image accordingly. @@ -450,7 +456,7 @@ class GerberContext(object): def _evaluate_mode(self, stmt): if stmt.type == 'RegionMode': if self.region_mode == 'on' and stmt.mode == 'off': - self._fill_region() + self.fill_region() self.region_mode = stmt.mode elif stmt.type == 'QuadrantMode': self.quadrant_mode = stmt.mode @@ -460,11 +466,11 @@ class GerberContext(object): self.set_coord_format(stmt.zero_suppression, stmt.format, stmt.notation) self.set_coord_notation(stmt.notation) - elif stmt.param == "MO:": + elif stmt.param == "MO": self.set_coord_unit(stmt.mode) - elif stmt.param == "IP:": + elif stmt.param == "IP": self.set_image_polarity(stmt.ip) - elif stmt.param == "LP:": + elif stmt.param == "LP": self.set_level_polarity(stmt.lp) elif stmt.param == "AD": self.define_aperture(stmt.d, stmt.shape, stmt.modifiers) @@ -477,7 +483,10 @@ class GerberContext(object): self.direction = ('clockwise' if stmt.function in ('G02', 'G2') else 'counterclockwise') if stmt.op == "D01": - self.stroke(stmt.x, stmt.y, stmt.i, stmt.j) + if self.region_mode == 'on': + self.region_contour(stmt.x, stmt.y) + else: + self.stroke(stmt.x, stmt.y, stmt.i, stmt.j) elif stmt.op == "D02": self.move(stmt.x, stmt.y) elif stmt.op == "D03": @@ -486,5 +495,3 @@ class GerberContext(object): def _evaluate_aperture(self, stmt): self.set_aperture(stmt.d) - def _fill_region(self): - pass diff --git a/gerber/render/svgwrite_backend.py b/gerber/render/svgwrite_backend.py index 7570c84..886b4f8 100644 --- a/gerber/render/svgwrite_backend.py +++ b/gerber/render/svgwrite_backend.py @@ -118,6 +118,7 @@ class GerberSvgContext(GerberContext): self.apertures = {} self.dwg = svgwrite.Drawing() self.background = False + self.region_path = None def set_bounds(self, bounds): xbounds, ybounds = bounds @@ -125,7 +126,7 @@ class GerberSvgContext(GerberContext): if not self.background: self.dwg.add(self.dwg.rect(insert=(SCALE * xbounds[0], -SCALE * ybounds[1]), - size=size, fill="black")) + size=size, fill=convert_color(self.background_color))) self.background = True def define_aperture(self, d, shape, modifiers): @@ -173,7 +174,8 @@ class GerberSvgContext(GerberContext): ap = self.apertures.get(self.aperture, None) if ap is None: return - color = (convert_color(self.color) if self.level_polarity == 'dark' + + color = (convert_color(self.color) if self.level_polarity == 'dark' else convert_color(self.background_color)) for shape in ap.flash(self, x, y, color): self.dwg.add(shape) @@ -185,5 +187,21 @@ class GerberSvgContext(GerberContext): fill=convert_color(self.drill_color)) self.dwg.add(hit) + def region_contour(self, x, y): + super(GerberSvgContext, self).region_contour(x, y) + x, y = self.resolve(x, y) + color = (convert_color(self.color) if self.level_polarity == 'dark' + else convert_color(self.background_color)) + if self.region_path is None: + self.region_path = self.dwg.path(d = 'M %f, %f' % + (self.x*SCALE, -self.y*SCALE), + fill = color, stroke = 'none') + self.region_path.push('L %f, %f' % (x*SCALE, -y*SCALE)) + self.move(x, y, resolve=False) + + def fill_region(self): + self.dwg.add(self.region_path) + self.region_path = None + def dump(self, filename): self.dwg.saveas(filename) -- cgit From ae3bbff8b0849e0b49dc139396d7f8c57334a7b8 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 10 Oct 2014 23:07:51 -0400 Subject: Added excellon format detection --- gerber/cam.py | 124 ++++++++++++++++++++++++++ gerber/cnc.py | 119 ------------------------- gerber/excellon.py | 155 +++++++++++++++++++++++++++++++-- gerber/gerber.py | 4 +- gerber/gerber_statements.py | 7 +- gerber/tests/test_cam.py | 50 +++++++++++ gerber/tests/test_cnc.py | 50 ----------- gerber/tests/test_gerber_statements.py | 76 ++++++++++++---- 8 files changed, 392 insertions(+), 193 deletions(-) create mode 100644 gerber/cam.py delete mode 100644 gerber/cnc.py create mode 100644 gerber/tests/test_cam.py delete mode 100644 gerber/tests/test_cnc.py (limited to 'gerber') diff --git a/gerber/cam.py b/gerber/cam.py new file mode 100644 index 0000000..e7a49d1 --- /dev/null +++ b/gerber/cam.py @@ -0,0 +1,124 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# copyright 2014 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. +""" +CAM File +============ +**AM file classes** + +This module provides common base classes for Excellon/Gerber CNC files +""" + + +class FileSettings(object): + """ CAM File Settings + + Provides a common representation of gerber/excellon file settings + """ + def __init__(self, notation='absolute', units='inch', + zero_suppression='trailing', format=(2, 5)): + if notation not in ['absolute', 'incremental']: + raise ValueError('Notation must be either absolute or incremental') + self.notation = notation + + if units not in ['inch', 'metric']: + raise ValueError('Units must be either inch or metric') + self.units = units + + if zero_suppression not in ['leading', 'trailing']: + raise ValueError('Zero suppression must be either leading or \ + trailling') + self.zero_suppression = zero_suppression + + if len(format) != 2: + raise ValueError('Format must be a tuple(n=2) of integers') + self.format = format + + def __getitem__(self, key): + if key == 'notation': + return self.notation + elif key == 'units': + return self.units + elif key == 'zero_suppression': + return self.zero_suppression + elif key == 'format': + return self.format + else: + raise KeyError() + + +class CamFile(object): + """ Base class for Gerber/Excellon files. + + Provides a common set of settings parameters. + + Parameters + ---------- + settings : FileSettings + The current file configuration. + + filename : string + Name of the file that this CamFile represents. + + layer_name : string + Name of the PCB layer that the file represents + + Attributes + ---------- + settings : FileSettings + File settings as a FileSettings object + + notation : string + File notation setting. May be either 'absolute' or 'incremental' + + units : string + File units setting. May be 'inch' or 'metric' + + zero_suppression : string + File zero-suppression setting. May be either 'leading' or 'trailling' + + format : tuple (, ) + File decimal representation format as a tuple of (integer digits, + decimal digits) + """ + + def __init__(self, statements=None, settings=None, filename=None, + layer_name=None): + if settings is not None: + self.notation = settings['notation'] + self.units = settings['units'] + self.zero_suppression = settings['zero_suppression'] + self.format = settings['format'] + else: + self.notation = 'absolute' + self.units = 'inch' + self.zero_suppression = 'trailing' + self.format = (2, 5) + self.statements = statements if statements is not None else [] + self.filename = filename + self.layer_name = layer_name + + @property + def settings(self): + """ File settings + + Returns + ------- + settings : FileSettings (dict-like) + A FileSettings object with the specified configuration. + """ + return FileSettings(self.notation, self.units, self.zero_suppression, + self.format) diff --git a/gerber/cnc.py b/gerber/cnc.py deleted file mode 100644 index d17517a..0000000 --- a/gerber/cnc.py +++ /dev/null @@ -1,119 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# copyright 2014 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. -""" -gerber.cnc -============ -**CNC file classes** - -This module provides common base classes for Excellon/Gerber CNC files -""" - - -class FileSettings(object): - """ CNC File Settings - - Provides a common representation of gerber/excellon file settings - """ - def __init__(self, notation='absolute', units='inch', - zero_suppression='trailing', format=(2, 5)): - if notation not in ['absolute', 'incremental']: - raise ValueError('Notation must be either absolute or incremental') - self.notation = notation - - if units not in ['inch', 'metric']: - raise ValueError('Units must be either inch or metric') - self.units = units - - if zero_suppression not in ['leading', 'trailing']: - raise ValueError('Zero suppression must be either leading or \ - trailling') - self.zero_suppression = zero_suppression - - if len(format) != 2: - raise ValueError('Format must be a tuple(n=2) of integers') - self.format = format - - def __getitem__(self, key): - if key == 'notation': - return self.notation - elif key == 'units': - return self.units - elif key == 'zero_suppression': - return self.zero_suppression - elif key == 'format': - return self.format - else: - raise KeyError() - - -class CncFile(object): - """ Base class for Gerber/Excellon files. - - Provides a common set of settings parameters. - - Parameters - ---------- - settings : FileSettings - The current file configuration. - - filename : string - Name of the file that this CncFile represents. - - Attributes - ---------- - settings : FileSettings - File settings as a FileSettings object - - notation : string - File notation setting. May be either 'absolute' or 'incremental' - - units : string - File units setting. May be 'inch' or 'metric' - - zero_suppression : string - File zero-suppression setting. May be either 'leading' or 'trailling' - - format : tuple (, ) - File decimal representation format as a tuple of (integer digits, - decimal digits) - """ - - def __init__(self, statements=None, settings=None, filename=None): - if settings is not None: - self.notation = settings['notation'] - self.units = settings['units'] - self.zero_suppression = settings['zero_suppression'] - self.format = settings['format'] - else: - self.notation = 'absolute' - self.units = 'inch' - self.zero_suppression = 'trailing' - self.format = (2, 5) - self.statements = statements if statements is not None else [] - self.filename = filename - - @property - def settings(self): - """ File settings - - Returns - ------- - settings : FileSettings (dict-like) - A FileSettings object with the specified configuration. - """ - return FileSettings(self.notation, self.units, self.zero_suppression, - self.format) diff --git a/gerber/excellon.py b/gerber/excellon.py index 4166de6..1a498dc 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -24,16 +24,22 @@ This module provides Excellon file classes and parsing utilities from .excellon_statements import * -from .cnc import CncFile, FileSettings +from .cam import CamFile, FileSettings +import math def read(filename): """ Read data from filename and return an ExcellonFile """ - return ExcellonParser().parse(filename) + detected_settings = detect_excellon_format(filename) + settings = FileSettings(**detected_settings) + zeros = '' + print('Detected %d:%d format with %s zero suppression' % + (settings.format[0], settings.format[1], settings.zero_suppression)) + return ExcellonParser(settings).parse(filename) -class ExcellonFile(CncFile): +class ExcellonFile(CamFile): """ A class representing a single excellon file The ExcellonFile class represents a single excellon file. @@ -83,8 +89,13 @@ class ExcellonFile(CncFile): class ExcellonParser(object): """ Excellon File Parser + + Parameters + ---------- + settings : FileSettings or dict-like + Excellon file settings to use when interpreting the excellon file. """ - def __init__(self): + def __init__(self, settings=None): self.notation = 'absolute' self.units = 'inch' self.zero_suppression = 'trailing' @@ -95,7 +106,38 @@ class ExcellonParser(object): self.hits = [] self.active_tool = None self.pos = [0., 0.] - + if settings is not None: + self.units = settings['units'] + self.zero_suppression = settings['zero_suppression'] + self.notation = settings['notation'] + self.format = settings['format'] + + + @property + def coordinates(self): + return [(stmt.x, stmt.y) for stmt in self.statements if isinstance(stmt, CoordinateStmt)] + + @property + def bounds(self): + xmin = ymin = 100000000000 + xmax = ymax = -100000000000 + for x, y in self.coordinates: + if x is not None: + xmin = x if x < xmin else xmin + xmax = x if x > xmax else xmax + if y is not None: + ymin = y if y < ymin else ymin + ymax = y if y > ymax else ymax + return ((xmin, xmax), (ymin, ymax)) + + @property + def hole_sizes(self): + return [stmt.diameter for stmt in self.statements if isinstance(stmt, ExcellonTool)] + + @property + def hole_count(self): + return len(self.hits) + def parse(self, filename): with open(filename, 'r') as f: for line in f: @@ -194,3 +236,106 @@ class ExcellonParser(object): return FileSettings(units=self.units, format=self.format, zero_suppression=self.zero_suppression, notation=self.notation) + + +def detect_excellon_format(filename): + """ Detect excellon file decimal format and zero-suppression settings. + + Parameters + ---------- + filename : string + Name of the file to parse. This does not check if the file is actually + an Excellon file, so do that before calling this. + + Returns + ------- + settings : dict + Detected excellon file settings. Keys are + - `format`: decimal format as tuple (, ) + - `zero_suppression`: zero suppression, 'leading' or 'trailing' + """ + results = {} + detected_zeros = None + detected_format = None + zs_options = ('leading', 'trailing', ) + format_options = ((2, 4), (2, 5), (3, 3),) + + # Check for obvious clues: + p = ExcellonParser() + p.parse(filename) + + # Get zero_suppression from a unit statement + zero_statements = [stmt.zero_suppression for stmt in p.statements + if isinstance(stmt, UnitStmt)] + + # get format from altium comment + format_comment = [stmt.comment for stmt in p.statements + if isinstance(stmt, CommentStmt) + and 'FILE_FORMAT' in stmt.comment] + + detected_format = (tuple([int(val) for val in + format_comment[0].split('=')[1].split(':')]) + if len(format_comment) == 1 else None) + detected_zeros = zero_statements[0] if len(zero_statements) == 1 else None + + # Bail out here if possible + if detected_format is not None and detected_zeros is not None: + return {'format': detected_format, 'zero_suppression': detected_zeros} + + # Only look at remaining options + if detected_format is not None: + format_options = (detected_format,) + if detected_zeros is not None: + zs_options = (detected_zeros,) + + # Brute force all remaining options, and pick the best looking one... + for zs in zs_options: + for fmt in format_options: + key = (fmt, zs) + settings = FileSettings(zero_suppression=zs, format=fmt) + try: + p = ExcellonParser(settings) + p.parse(filename) + size = tuple([t[1] - t[0] for t in p.bounds]) + hole_area = 0.0 + for hit in p.hits: + tool = hit[0] + hole_area += math.pow(math.pi * tool.diameter, 2) + results[key] = (size, p.hole_count, hole_area) + except: + pass + + # See if any of the dimensions are left with only a single option + formats = set(key[0] for key in results.iterkeys()) + zeros = set(key[1] for key in results.iterkeys()) + if len(formats) == 1: + detected_format = formats.pop() + if len(zeros) == 1: + detected_zeros = zeros.pop() + + # Bail out here if we got everything.... + if detected_format is not None and detected_zeros is not None: + return {'format': detected_format, 'zero_suppression': detected_zeros} + + # Otherwise score each option and pick the best candidate + else: + scores = {} + for key in results.keys(): + size, count, diameter = results[key] + scores[key] = _layer_size_score(size, count, diameter) + minscore = min(scores.values()) + for key in scores.iterkeys(): + if scores[key] == minscore: + return {'format': key[0], 'zero_suppression': key[1]} + + +def _layer_size_score(size, hole_count, hole_area): + """ Heuristic used for determining the correct file number interpretation. + Lower is better. + """ + board_area = size[0] * size[1] + hole_percentage = hole_area / board_area + hole_score = (hole_percentage - 0.25) ** 2 + size_score = (board_area - 8) **2 + return hole_score * size_score + \ No newline at end of file diff --git a/gerber/gerber.py b/gerber/gerber.py index 4ce261d..215b970 100644 --- a/gerber/gerber.py +++ b/gerber/gerber.py @@ -27,7 +27,7 @@ This module provides an RS-274-X class and parser import re import json from .gerber_statements import * -from .cnc import CncFile, FileSettings +from .cam import CamFile, FileSettings @@ -38,7 +38,7 @@ def read(filename): return GerberParser().parse(filename) -class GerberFile(CncFile): +class GerberFile(CamFile): """ A class representing a single gerber file The GerberFile class represents a single gerber file. diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index a22eae2..218074f 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -133,7 +133,12 @@ class MOParamStmt(ParamStmt): @classmethod def from_dict(cls, stmt_dict): param = stmt_dict.get('param') - mo = 'inch' if stmt_dict.get('mo') == 'IN' else 'metric' + if stmt_dict.get('mo').lower() == 'in': + mo = 'inch' + elif stmt_dict.get('mo').lower() == 'mm': + mo = 'metric' + else: + mo = None return cls(param, mo) def __init__(self, param, mo): diff --git a/gerber/tests/test_cam.py b/gerber/tests/test_cam.py new file mode 100644 index 0000000..4af1984 --- /dev/null +++ b/gerber/tests/test_cam.py @@ -0,0 +1,50 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Author: Hamilton Kibbe + +from ..cam import CamFile, FileSettings +from tests import * + + +def test_smoke_filesettings(): + """ Smoke test FileSettings class + """ + fs = FileSettings() + + +def test_filesettings_defaults(): + """ Test FileSettings default values + """ + fs = FileSettings() + assert_equal(fs.format, (2, 5)) + assert_equal(fs.notation, 'absolute') + assert_equal(fs.zero_suppression, 'trailing') + assert_equal(fs.units, 'inch') + + +def test_filesettings_dict(): + """ Test FileSettings Dict + """ + fs = FileSettings() + assert_equal(fs['format'], (2, 5)) + assert_equal(fs['notation'], 'absolute') + assert_equal(fs['zero_suppression'], 'trailing') + assert_equal(fs['units'], 'inch') + + +def test_filesettings_assign(): + """ Test FileSettings attribute assignment + """ + fs = FileSettings() + fs.units = 'test' + fs.notation = 'test' + fs.zero_suppression = 'test' + fs.format = 'test' + assert_equal(fs.units, 'test') + assert_equal(fs.notation, 'test') + assert_equal(fs.zero_suppression, 'test') + assert_equal(fs.format, 'test') + +def test_smoke_camfile(): + cf = CamFile diff --git a/gerber/tests/test_cnc.py b/gerber/tests/test_cnc.py deleted file mode 100644 index ace047e..0000000 --- a/gerber/tests/test_cnc.py +++ /dev/null @@ -1,50 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# Author: Hamilton Kibbe - -from ..cnc import CncFile, FileSettings -from tests import * - - -def test_smoke_filesettings(): - """ Smoke test FileSettings class - """ - fs = FileSettings() - - -def test_filesettings_defaults(): - """ Test FileSettings default values - """ - fs = FileSettings() - assert_equal(fs.format, (2, 5)) - assert_equal(fs.notation, 'absolute') - assert_equal(fs.zero_suppression, 'trailing') - assert_equal(fs.units, 'inch') - - -def test_filesettings_dict(): - """ Test FileSettings Dict - """ - fs = FileSettings() - assert_equal(fs['format'], (2, 5)) - assert_equal(fs['notation'], 'absolute') - assert_equal(fs['zero_suppression'], 'trailing') - assert_equal(fs['units'], 'inch') - - -def test_filesettings_assign(): - """ Test FileSettings attribute assignment - """ - fs = FileSettings() - fs.units = 'test' - fs.notation = 'test' - fs.zero_suppression = 'test' - fs.format = 'test' - assert_equal(fs.units, 'test') - assert_equal(fs.notation, 'test') - assert_equal(fs.zero_suppression, 'test') - assert_equal(fs.format, 'test') - - def test_smoke_cncfile(): - pass diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index 9e73fd4..a463c9d 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -8,7 +8,7 @@ from ..gerber_statements import * def test_FSParamStmt_factory(): - """ Test FSParamStruct factory correctly handles parameters + """ Test FSParamStruct factory """ stmt = {'param': 'FS', 'zero': 'L', 'notation': 'A', 'x': '27'} fs = FSParamStmt.from_dict(stmt) @@ -24,6 +24,18 @@ def test_FSParamStmt_factory(): assert_equal(fs.notation, 'incremental') assert_equal(fs.format, (2, 7)) +def test_FSParamStmt(): + """ Test FSParamStmt initialization + """ + param = 'FS' + zeros = 'trailing' + notation = 'absolute' + fmt = (2, 5) + stmt = FSParamStmt(param, zeros, notation, fmt) + assert_equal(stmt.param, param) + assert_equal(stmt.zero_suppression, zeros) + assert_equal(stmt.notation, notation) + assert_equal(stmt.format, fmt) def test_FSParamStmt_dump(): """ Test FSParamStmt to_gerber() @@ -38,17 +50,31 @@ def test_FSParamStmt_dump(): def test_MOParamStmt_factory(): - """ Test MOParamStruct factory correctly handles parameters + """ Test MOParamStruct factory """ - stmt = {'param': 'MO', 'mo': 'IN'} - mo = MOParamStmt.from_dict(stmt) - assert_equal(mo.param, 'MO') - assert_equal(mo.mode, 'inch') + stmts = [{'param': 'MO', 'mo': 'IN'}, {'param': 'MO', 'mo': 'in'}, ] + for stmt in stmts: + mo = MOParamStmt.from_dict(stmt) + assert_equal(mo.param, 'MO') + assert_equal(mo.mode, 'inch') + + stmts = [{'param': 'MO', 'mo': 'MM'}, {'param': 'MO', 'mo': 'mm'}, ] + for stmt in stmts: + mo = MOParamStmt.from_dict(stmt) + assert_equal(mo.param, 'MO') + assert_equal(mo.mode, 'metric') + +def test_MOParamStmt(): + """ Test MOParamStmt initialization + """ + param = 'MO' + mode = 'inch' + stmt = MOParamStmt(param, mode) + assert_equal(stmt.param, param) - stmt = {'param': 'MO', 'mo': 'MM'} - mo = MOParamStmt.from_dict(stmt) - assert_equal(mo.param, 'MO') - assert_equal(mo.mode, 'metric') + for mode in ['inch', 'metric']: + stmt = MOParamStmt(param, mode) + assert_equal(stmt.mode, mode) def test_MOParamStmt_dump(): @@ -64,7 +90,7 @@ def test_MOParamStmt_dump(): def test_IPParamStmt_factory(): - """ Test IPParamStruct factory correctly handles parameters + """ Test IPParamStruct factory """ stmt = {'param': 'IP', 'ip': 'POS'} ip = IPParamStmt.from_dict(stmt) @@ -74,6 +100,15 @@ def test_IPParamStmt_factory(): ip = IPParamStmt.from_dict(stmt) assert_equal(ip.ip, 'negative') +def test_IPParamStmt(): + """ Test IPParamStmt initialization + """ + param = 'IP' + for ip in ['positive', 'negative']: + stmt = IPParamStmt(param, ip) + assert_equal(stmt.param, param) + assert_equal(stmt.ip, ip) + def test_IPParamStmt_dump(): """ Test IPParamStmt to_gerber() @@ -88,14 +123,23 @@ def test_IPParamStmt_dump(): def test_OFParamStmt_factory(): - """ Test OFParamStmt factory correctly handles parameters + """ Test OFParamStmt factory """ stmt = {'param': 'OF', 'a': '0.1234567', 'b': '0.1234567'} of = OFParamStmt.from_dict(stmt) assert_equal(of.a, 0.1234567) assert_equal(of.b, 0.1234567) - +def test_OFParamStmt(): + """ Test IPParamStmt initialization + """ + param = 'OF' + for val in [0.0, -3.4567]: + stmt = OFParamStmt(param, val, val) + assert_equal(stmt.param, param) + assert_equal(stmt.a, val) + assert_equal(stmt.b, val) + def test_OFParamStmt_dump(): """ Test OFParamStmt to_gerber() """ @@ -105,7 +149,7 @@ def test_OFParamStmt_dump(): def test_LPParamStmt_factory(): - """ Test LPParamStmt factory correctly handles parameters + """ Test LPParamStmt factory """ stmt = {'param': 'LP', 'lp': 'C'} lp = LPParamStmt.from_dict(stmt) @@ -128,7 +172,7 @@ def test_LPParamStmt_dump(): def test_INParamStmt_factory(): - """ Test INParamStmt factory correctly handles parameters + """ Test INParamStmt factory """ stmt = {'param': 'IN', 'name': 'test'} inp = INParamStmt.from_dict(stmt) @@ -143,7 +187,7 @@ def test_INParamStmt_dump(): def test_LNParamStmt_factory(): - """ Test LNParamStmt factory correctly handles parameters + """ Test LNParamStmt factory """ stmt = {'param': 'LN', 'name': 'test'} lnp = LNParamStmt.from_dict(stmt) -- cgit From 62c689be172a7a06d76fd4b69c3443f3ec053765 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sat, 11 Oct 2014 13:12:21 -0400 Subject: Doc update --- gerber/__main__.py | 1 + gerber/excellon.py | 28 ++++++++++++++++++++++------ gerber/gerber.py | 25 +++++++++++++++++++------ 3 files changed, 42 insertions(+), 12 deletions(-) (limited to 'gerber') diff --git a/gerber/__main__.py b/gerber/__main__.py index ab0f377..1af4c0f 100644 --- a/gerber/__main__.py +++ b/gerber/__main__.py @@ -30,6 +30,7 @@ if __name__ == '__main__': print "parsing %s" % filename gerberfile = read(filename) gerberfile.render(ctx) + ctx.set_color(tuple([color * 0.4 for color in ctx.color])) print('Saving image to test.svg') ctx.dump('test.svg') diff --git a/gerber/excellon.py b/gerber/excellon.py index 1a498dc..f5d6c29 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -30,6 +30,15 @@ import math def read(filename): """ Read data from filename and return an ExcellonFile + Parameters + ---------- + filename : string + Filename of file to parse + + Returns + ------- + file : :class:`gerber.excellon.ExcellonFile` + An ExcellonFile created from the specified file. """ detected_settings = detect_excellon_format(filename) settings = FileSettings(**detected_settings) @@ -75,6 +84,14 @@ class ExcellonFile(CamFile): def render(self, ctx, filename=None): """ Generate image of file + + Parameters + ---------- + ctx : :class:`gerber.render.GerberContext` + GerberContext subclass used for rendering the image + + filename : string + If provided, the rendered image will be saved to `filename` """ for tool, pos in self.hits: ctx.drill(pos[0], pos[1], tool.diameter) @@ -89,7 +106,7 @@ class ExcellonFile(CamFile): class ExcellonParser(object): """ Excellon File Parser - + Parameters ---------- settings : FileSettings or dict-like @@ -129,15 +146,15 @@ class ExcellonParser(object): ymin = y if y < ymin else ymin ymax = y if y > ymax else ymax return ((xmin, xmax), (ymin, ymax)) - + @property def hole_sizes(self): return [stmt.diameter for stmt in self.statements if isinstance(stmt, ExcellonTool)] - + @property def hole_count(self): return len(self.hits) - + def parse(self, filename): with open(filename, 'r') as f: for line in f: @@ -316,7 +333,7 @@ def detect_excellon_format(filename): # Bail out here if we got everything.... if detected_format is not None and detected_zeros is not None: return {'format': detected_format, 'zero_suppression': detected_zeros} - + # Otherwise score each option and pick the best candidate else: scores = {} @@ -338,4 +355,3 @@ def _layer_size_score(size, hole_count, hole_area): hole_score = (hole_percentage - 0.25) ** 2 size_score = (board_area - 8) **2 return hole_score * size_score - \ No newline at end of file diff --git a/gerber/gerber.py b/gerber/gerber.py index 215b970..335b443 100644 --- a/gerber/gerber.py +++ b/gerber/gerber.py @@ -15,12 +15,7 @@ # 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. -""" -Gerber File module -================== -**Gerber File module** - -This module provides an RS-274-X class and parser +""" This module provides an RS-274-X class and parser. """ @@ -34,6 +29,16 @@ from .cam import CamFile, FileSettings def read(filename): """ Read data from filename and return a GerberFile + + Parameters + ---------- + filename : string + Filename of file to parse + + Returns + ------- + file : :class:`gerber.gerber.GerberFile` + A GerberFile created from the specified file. """ return GerberParser().parse(filename) @@ -113,6 +118,14 @@ class GerberFile(CamFile): def render(self, ctx, filename=None): """ Generate image of layer. + + Parameters + ---------- + ctx : :class:`GerberContext` + GerberContext subclass used for rendering the image + + filename : string + If provided, the rendered image will be saved to `filename` """ ctx.set_bounds(self.bounds) for statement in self.statements: -- cgit From 8c5c7ec8bbc8a074884ef04b566f9c0ecd6e78bb Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 12 Oct 2014 12:38:40 -0400 Subject: update docs and example images --- gerber/__main__.py | 1 - gerber/common.py | 4 +- gerber/excellon.py | 3 +- gerber/gerber.py | 332 -------------------------------------- gerber/render/render.py | 19 ++- gerber/render/svgwrite_backend.py | 7 + gerber/rs274x.py | 327 +++++++++++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 337 deletions(-) delete mode 100644 gerber/gerber.py create mode 100644 gerber/rs274x.py (limited to 'gerber') diff --git a/gerber/__main__.py b/gerber/__main__.py index 1af4c0f..ab0f377 100644 --- a/gerber/__main__.py +++ b/gerber/__main__.py @@ -30,7 +30,6 @@ if __name__ == '__main__': print "parsing %s" % filename gerberfile = read(filename) gerberfile.render(ctx) - ctx.set_color(tuple([color * 0.4 for color in ctx.color])) print('Saving image to test.svg') ctx.dump('test.svg') diff --git a/gerber/common.py b/gerber/common.py index 0092ec8..6e8c862 100644 --- a/gerber/common.py +++ b/gerber/common.py @@ -30,12 +30,12 @@ def read(filename): CncFile object representing the file, either GerberFile or ExcellonFile. Returns None if file is not an Excellon or Gerber file. """ - import gerber + import rs274x import excellon from utils import detect_file_format fmt = detect_file_format(filename) if fmt == 'rs274x': - return gerber.read(filename) + return rs274x.read(filename) elif fmt == 'excellon': return excellon.read(filename) else: diff --git a/gerber/excellon.py b/gerber/excellon.py index f5d6c29..13aacc6 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -39,6 +39,7 @@ def read(filename): ------- file : :class:`gerber.excellon.ExcellonFile` An ExcellonFile created from the specified file. + """ detected_settings = detect_excellon_format(filename) settings = FileSettings(**detected_settings) @@ -317,7 +318,7 @@ def detect_excellon_format(filename): hole_area = 0.0 for hit in p.hits: tool = hit[0] - hole_area += math.pow(math.pi * tool.diameter, 2) + hole_area += math.pow(math.pi * tool.diameter / 2., 2) results[key] = (size, p.hole_count, hole_area) except: pass diff --git a/gerber/gerber.py b/gerber/gerber.py deleted file mode 100644 index 335b443..0000000 --- a/gerber/gerber.py +++ /dev/null @@ -1,332 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# copyright 2014 Hamilton Kibbe -# Modified from parser.py by Paulo Henrique Silva -# -# 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 module provides an RS-274-X class and parser. -""" - - -import re -import json -from .gerber_statements import * -from .cam import CamFile, FileSettings - - - - -def read(filename): - """ Read data from filename and return a GerberFile - - Parameters - ---------- - filename : string - Filename of file to parse - - Returns - ------- - file : :class:`gerber.gerber.GerberFile` - A GerberFile created from the specified file. - """ - return GerberParser().parse(filename) - - -class GerberFile(CamFile): - """ A class representing a single gerber file - - The GerberFile class represents a single gerber file. - - Parameters - ---------- - statements : list - list of gerber file statements - - settings : dict - Dictionary of gerber file settings - - filename : string - Filename of the source gerber file - - Attributes - ---------- - comments: list of strings - List of comments contained in the gerber file. - - size : tuple, (, ) - Size in [self.units] of the layer described by the gerber file. - - bounds: tuple, ((, ), (, )) - boundaries of the layer described by the gerber file. - `bounds` is stored as ((min x, max x), (min y, max y)) - - """ - def __init__(self, statements, settings, filename=None): - super(GerberFile, self).__init__(statements, settings, filename) - - @property - def comments(self): - return [comment.comment for comment in self.statements - if isinstance(comment, CommentStmt)] - - @property - def size(self): - xbounds, ybounds = self.bounds - return (xbounds[1] - xbounds[0], ybounds[1] - ybounds[0]) - - @property - def bounds(self): - xbounds = [0.0, 0.0] - ybounds = [0.0, 0.0] - for stmt in [stmt for stmt in self.statements - if isinstance(stmt, CoordStmt)]: - if stmt.x is not None and stmt.x < xbounds[0]: - xbounds[0] = stmt.x - if stmt.x is not None and stmt.x > xbounds[1]: - xbounds[1] = stmt.x - if stmt.i is not None and stmt.i < xbounds[0]: - xbounds[0] = stmt.i - if stmt.i is not None and stmt.i > xbounds[1]: - xbounds[1] = stmt.i - if stmt.y is not None and stmt.y < ybounds[0]: - ybounds[0] = stmt.y - if stmt.y is not None and stmt.y > ybounds[1]: - ybounds[1] = stmt.y - if stmt.j is not None and stmt.j < ybounds[0]: - ybounds[0] = stmt.j - if stmt.j is not None and stmt.j > ybounds[1]: - ybounds[1] = stmt.j - return (xbounds, ybounds) - - def write(self, filename): - """ Write data out to a gerber file - """ - with open(filename, 'w') as f: - for statement in self.statements: - f.write(statement.to_gerber()) - - def render(self, ctx, filename=None): - """ Generate image of layer. - - Parameters - ---------- - ctx : :class:`GerberContext` - GerberContext subclass used for rendering the image - - filename : string - If provided, the rendered image will be saved to `filename` - """ - ctx.set_bounds(self.bounds) - for statement in self.statements: - ctx.evaluate(statement) - if filename is not None: - ctx.dump(filename) - - -class GerberParser(object): - """ GerberParser - """ - NUMBER = r"[\+-]?\d+" - DECIMAL = r"[\+-]?\d+([.]?\d+)?" - STRING = r"[a-zA-Z0-9_+\-/!?<>”’(){}.\|&@# :]+" - NAME = "[a-zA-Z_$][a-zA-Z_$0-9]+" - FUNCTION = r"G\d{2}" - - COORD_OP = r"D[0]?[123]" - - FS = r"(?PFS)(?P(L|T))?(?P(A|I))X(?P[0-7][0-7])Y(?P[0-7][0-7])" - MO = r"(?PMO)(?P(MM|IN))" - IP = r"(?PIP)(?P(POS|NEG))" - LP = r"(?PLP)(?P(D|C))" - AD_CIRCLE = r"(?PAD)D(?P\d+)(?PC)[,](?P[^,]*)" - AD_RECT = r"(?PAD)D(?P\d+)(?PR)[,](?P[^,]*)" - AD_OBROUND = r"(?PAD)D(?P\d+)(?PO)[,](?P[^,]*)" - AD_POLY = r"(?PAD)D(?P\d+)(?PP)[,](?P[^,]*)" - AD_MACRO = r"(?PAD)D(?P\d+)+(?P{name})[,](?P[^,]*)".format(name=NAME) - AM = r"(?PAM)(?P{name})\*(?P.*)".format(name=NAME) - - # begin deprecated - OF = r"(?POF)(A(?P{decimal}))?(B(?P{decimal}))?".format(decimal=DECIMAL) - IN = r"(?PIN)(?P.*)" - LN = r"(?PLN)(?P.*)" - # end deprecated - - PARAMS = (FS, MO, IP, LP, AD_CIRCLE, AD_RECT, AD_OBROUND, AD_MACRO, AD_POLY, AM, OF, IN, LN) - PARAM_STMT = [re.compile(r"%{0}\*%".format(p)) for p in PARAMS] - - COORD_STMT = re.compile(( - r"(?P{function})?" - r"(X(?P{number}))?(Y(?P{number}))?" - r"(I(?P{number}))?(J(?P{number}))?" - r"(?P{op})?\*".format(number=NUMBER, function=FUNCTION, op=COORD_OP))) - - APERTURE_STMT = re.compile(r"(G54)?D(?P\d+)\*") - - COMMENT_STMT = re.compile(r"G04(?P[^*]*)(\*)?") - - EOF_STMT = re.compile(r"(?PM02)\*") - - REGION_MODE_STMT = re.compile(r'(?PG3[67])\*') - QUAD_MODE_STMT = re.compile(r'(?PG7[45])\*') - - def __init__(self): - self.settings = FileSettings() - self.statements = [] - - def parse(self, filename): - fp = open(filename, "r") - data = fp.readlines() - - for stmt in self._parse(data): - self.statements.append(stmt) - - return GerberFile(self.statements, self.settings, filename) - - def dump_json(self): - stmts = {"statements": [stmt.__dict__ for stmt in self.statements]} - return json.dumps(stmts) - - def dump_str(self): - s = "" - for stmt in self.statements: - s += str(stmt) + "\n" - return s - - def _parse(self, data): - oldline = '' - - for i, line in enumerate(data): - line = oldline + line.strip() - - # skip empty lines - if not len(line): - continue - - # deal with multi-line parameters - if line.startswith("%") and not line.endswith("%"): - oldline = line - continue - - did_something = True # make sure we do at least one loop - while did_something and len(line) > 0: - did_something = False - - # Region Mode - (mode, r) = self._match_one(self.REGION_MODE_STMT, line) - if mode: - yield RegionModeStmt.from_gerber(line) - line = r - did_something = True - continue - - # Quadrant Mode - (mode, r) = self._match_one(self.QUAD_MODE_STMT, line) - if mode: - yield QuadrantModeStmt.from_gerber(line) - line = r - did_something = True - continue - - # coord - (coord, r) = self._match_one(self.COORD_STMT, line) - if coord: - yield CoordStmt.from_dict(coord, self.settings) - line = r - did_something = True - continue - - # aperture selection - (aperture, r) = self._match_one(self.APERTURE_STMT, line) - if aperture: - yield ApertureStmt(**aperture) - - did_something = True - line = r - continue - - # comment - (comment, r) = self._match_one(self.COMMENT_STMT, line) - if comment: - yield CommentStmt(comment["comment"]) - did_something = True - line = r - continue - - # parameter - (param, r) = self._match_one_from_many(self.PARAM_STMT, line) - if param: - if param["param"] == "FS": - stmt = FSParamStmt.from_dict(param) - self.settings.zero_suppression = stmt.zero_suppression - self.settings.format = stmt.format - self.settings.notation = stmt.notation - yield stmt - elif param["param"] == "MO": - stmt = MOParamStmt.from_dict(param) - self.settings.units = stmt.mode - yield stmt - elif param["param"] == "IP": - yield IPParamStmt.from_dict(param) - elif param["param"] == "LP": - yield LPParamStmt.from_dict(param) - elif param["param"] == "AD": - yield ADParamStmt.from_dict(param) - elif param["param"] == "AM": - yield AMParamStmt.from_dict(param) - elif param["param"] == "OF": - yield OFParamStmt.from_dict(param) - elif param["param"] == "IN": - yield INParamStmt.from_dict(param) - elif param["param"] == "LN": - yield LNParamStmt.from_dict(param) - else: - yield UnknownStmt(line) - did_something = True - line = r - continue - - # eof - (eof, r) = self._match_one(self.EOF_STMT, line) - if eof: - yield EofStmt() - did_something = True - line = r - continue - - if False: - print self.COORD_STMT.pattern - print self.APERTURE_STMT.pattern - print self.COMMENT_STMT.pattern - print self.EOF_STMT.pattern - for i in self.PARAM_STMT: - print i.pattern - - if line.find('*') > 0: - yield UnknownStmt(line) - oldline = line - - def _match_one(self, expr, data): - match = expr.match(data) - if match is None: - return ({}, None) - else: - return (match.groupdict(), data[match.end(0):]) - - def _match_one_from_many(self, exprs, data): - for expr in exprs: - match = expr.match(data) - if match: - return (match.groupdict(), data[match.end(0):]) - - return ({}, None) diff --git a/gerber/render/render.py b/gerber/render/render.py index 48a53f8..f7e4485 100644 --- a/gerber/render/render.py +++ b/gerber/render/render.py @@ -83,6 +83,9 @@ class GerberContext(object): background_color : tuple (, , ) Color of the background. Used when exposing areas in 'clear' level polarity mode. Format is the same as for `color`. + + alpha : float + Rendering opacity. Between 0.0 (transparent) and 1.0 (opaque.) """ def __init__(self): self.settings = {} @@ -100,7 +103,8 @@ class GerberContext(object): 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 + def set_format(self, settings): """ Set source file format. @@ -260,6 +264,19 @@ class GerberContext(object): """ self.background_color = color + def set_alpha(self, alpha): + """ Set layer rendering opacity + + .. note:: + Not all backends/rendering devices support this parameter. + + Parameters + ---------- + alpha : float + Rendering opacity. must be between 0.0 (transparent) and 1.0 (opaque) + """ + self.alpha = alpha + def resolve(self, x, y): """ Resolve missing x or y coordinates in a coordinate command. diff --git a/gerber/render/svgwrite_backend.py b/gerber/render/svgwrite_backend.py index 886b4f8..78961da 100644 --- a/gerber/render/svgwrite_backend.py +++ b/gerber/render/svgwrite_backend.py @@ -117,6 +117,7 @@ class GerberSvgContext(GerberContext): self.apertures = {} self.dwg = svgwrite.Drawing() + self.dwg.transform = 'scale 1 -1' self.background = False self.region_path = None @@ -124,11 +125,17 @@ class GerberSvgContext(GerberContext): xbounds, ybounds = bounds size = (SCALE * (xbounds[1] - xbounds[0]), SCALE * (ybounds[1] - ybounds[0])) if not self.background: + self.dwg = svgwrite.Drawing(viewBox='%f, %f, %f, %f' % (SCALE*xbounds[0], -SCALE*ybounds[1],size[0], size[1])) self.dwg.add(self.dwg.rect(insert=(SCALE * xbounds[0], -SCALE * ybounds[1]), size=size, fill=convert_color(self.background_color))) self.background = True + def set_alpha(self, alpha): + super(GerberSvgContext, self).set_alpha(alpha) + import warnings + warnings.warn('SVG output does not support transparency') + def define_aperture(self, d, shape, modifiers): aperture = None if shape == 'C': diff --git a/gerber/rs274x.py b/gerber/rs274x.py new file mode 100644 index 0000000..4076f77 --- /dev/null +++ b/gerber/rs274x.py @@ -0,0 +1,327 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# copyright 2014 Hamilton Kibbe +# Modified from parser.py by Paulo Henrique Silva +# +# 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 module provides an RS-274-X class and parser. +""" + + +import re +import json +from .gerber_statements import * +from .cam import CamFile, FileSettings + + + + +def read(filename): + """ Read data from filename and return a GerberFile + + Parameters + ---------- + filename : string + Filename of file to parse + + Returns + ------- + file : :class:`gerber.rs274x.GerberFile` + A GerberFile created from the specified file. + """ + return GerberParser().parse(filename) + + +class GerberFile(CamFile): + """ A class representing a single gerber file + + The GerberFile class represents a single gerber file. + + Parameters + ---------- + statements : list + list of gerber file statements + + settings : dict + Dictionary of gerber file settings + + filename : string + Filename of the source gerber file + + Attributes + ---------- + comments: list of strings + List of comments contained in the gerber file. + + size : tuple, (, ) + Size in [self.units] of the layer described by the gerber file. + + bounds: tuple, ((, ), (, )) + boundaries of the layer described by the gerber file. + `bounds` is stored as ((min x, max x), (min y, max y)) + + """ + def __init__(self, statements, settings, filename=None): + super(GerberFile, self).__init__(statements, settings, filename) + + @property + def comments(self): + return [comment.comment for comment in self.statements + if isinstance(comment, CommentStmt)] + + @property + def size(self): + xbounds, ybounds = self.bounds + return (xbounds[1] - xbounds[0], ybounds[1] - ybounds[0]) + + @property + def bounds(self): + xbounds = [0.0, 0.0] + ybounds = [0.0, 0.0] + for stmt in [stmt for stmt in self.statements + if isinstance(stmt, CoordStmt)]: + if stmt.x is not None: + if stmt.x < xbounds[0]: + xbounds[0] = stmt.x + elif stmt.x > xbounds[1]: + xbounds[1] = stmt.x + if stmt.y is not None: + if stmt.y < ybounds[0]: + ybounds[0] = stmt.y + elif stmt.y > ybounds[1]: + ybounds[1] = stmt.y + return (xbounds, ybounds) + + + def write(self, filename): + """ Write data out to a gerber file + """ + with open(filename, 'w') as f: + for statement in self.statements: + f.write(statement.to_gerber()) + + def render(self, ctx, filename=None): + """ Generate image of layer. + + Parameters + ---------- + ctx : :class:`GerberContext` + GerberContext subclass used for rendering the image + + filename : string + If provided, the rendered image will be saved to `filename` + """ + ctx.set_bounds(self.bounds) + for statement in self.statements: + ctx.evaluate(statement) + if filename is not None: + ctx.dump(filename) + + +class GerberParser(object): + """ GerberParser + """ + NUMBER = r"[\+-]?\d+" + DECIMAL = r"[\+-]?\d+([.]?\d+)?" + STRING = r"[a-zA-Z0-9_+\-/!?<>”’(){}.\|&@# :]+" + NAME = "[a-zA-Z_$][a-zA-Z_$0-9]+" + FUNCTION = r"G\d{2}" + + COORD_OP = r"D[0]?[123]" + + FS = r"(?PFS)(?P(L|T))?(?P(A|I))X(?P[0-7][0-7])Y(?P[0-7][0-7])" + MO = r"(?PMO)(?P(MM|IN))" + IP = r"(?PIP)(?P(POS|NEG))" + LP = r"(?PLP)(?P(D|C))" + AD_CIRCLE = r"(?PAD)D(?P\d+)(?PC)[,](?P[^,]*)" + AD_RECT = r"(?PAD)D(?P\d+)(?PR)[,](?P[^,]*)" + AD_OBROUND = r"(?PAD)D(?P\d+)(?PO)[,](?P[^,]*)" + AD_POLY = r"(?PAD)D(?P\d+)(?PP)[,](?P[^,]*)" + AD_MACRO = r"(?PAD)D(?P\d+)+(?P{name})[,](?P[^,]*)".format(name=NAME) + AM = r"(?PAM)(?P{name})\*(?P.*)".format(name=NAME) + + # begin deprecated + OF = r"(?POF)(A(?P{decimal}))?(B(?P{decimal}))?".format(decimal=DECIMAL) + IN = r"(?PIN)(?P.*)" + LN = r"(?PLN)(?P.*)" + # end deprecated + + PARAMS = (FS, MO, IP, LP, AD_CIRCLE, AD_RECT, AD_OBROUND, AD_MACRO, AD_POLY, AM, OF, IN, LN) + PARAM_STMT = [re.compile(r"%{0}\*%".format(p)) for p in PARAMS] + + COORD_STMT = re.compile(( + r"(?P{function})?" + r"(X(?P{number}))?(Y(?P{number}))?" + r"(I(?P{number}))?(J(?P{number}))?" + r"(?P{op})?\*".format(number=NUMBER, function=FUNCTION, op=COORD_OP))) + + APERTURE_STMT = re.compile(r"(G54)?D(?P\d+)\*") + + COMMENT_STMT = re.compile(r"G04(?P[^*]*)(\*)?") + + EOF_STMT = re.compile(r"(?PM02)\*") + + REGION_MODE_STMT = re.compile(r'(?PG3[67])\*') + QUAD_MODE_STMT = re.compile(r'(?PG7[45])\*') + + def __init__(self): + self.settings = FileSettings() + self.statements = [] + + def parse(self, filename): + fp = open(filename, "r") + data = fp.readlines() + + for stmt in self._parse(data): + self.statements.append(stmt) + + return GerberFile(self.statements, self.settings, filename) + + def dump_json(self): + stmts = {"statements": [stmt.__dict__ for stmt in self.statements]} + return json.dumps(stmts) + + def dump_str(self): + s = "" + for stmt in self.statements: + s += str(stmt) + "\n" + return s + + def _parse(self, data): + oldline = '' + + for i, line in enumerate(data): + line = oldline + line.strip() + + # skip empty lines + if not len(line): + continue + + # deal with multi-line parameters + if line.startswith("%") and not line.endswith("%"): + oldline = line + continue + + did_something = True # make sure we do at least one loop + while did_something and len(line) > 0: + did_something = False + + # Region Mode + (mode, r) = self._match_one(self.REGION_MODE_STMT, line) + if mode: + yield RegionModeStmt.from_gerber(line) + line = r + did_something = True + continue + + # Quadrant Mode + (mode, r) = self._match_one(self.QUAD_MODE_STMT, line) + if mode: + yield QuadrantModeStmt.from_gerber(line) + line = r + did_something = True + continue + + # coord + (coord, r) = self._match_one(self.COORD_STMT, line) + if coord: + yield CoordStmt.from_dict(coord, self.settings) + line = r + did_something = True + continue + + # aperture selection + (aperture, r) = self._match_one(self.APERTURE_STMT, line) + if aperture: + yield ApertureStmt(**aperture) + + did_something = True + line = r + continue + + # comment + (comment, r) = self._match_one(self.COMMENT_STMT, line) + if comment: + yield CommentStmt(comment["comment"]) + did_something = True + line = r + continue + + # parameter + (param, r) = self._match_one_from_many(self.PARAM_STMT, line) + if param: + if param["param"] == "FS": + stmt = FSParamStmt.from_dict(param) + self.settings.zero_suppression = stmt.zero_suppression + self.settings.format = stmt.format + self.settings.notation = stmt.notation + yield stmt + elif param["param"] == "MO": + stmt = MOParamStmt.from_dict(param) + self.settings.units = stmt.mode + yield stmt + elif param["param"] == "IP": + yield IPParamStmt.from_dict(param) + elif param["param"] == "LP": + yield LPParamStmt.from_dict(param) + elif param["param"] == "AD": + yield ADParamStmt.from_dict(param) + elif param["param"] == "AM": + yield AMParamStmt.from_dict(param) + elif param["param"] == "OF": + yield OFParamStmt.from_dict(param) + elif param["param"] == "IN": + yield INParamStmt.from_dict(param) + elif param["param"] == "LN": + yield LNParamStmt.from_dict(param) + else: + yield UnknownStmt(line) + did_something = True + line = r + continue + + # eof + (eof, r) = self._match_one(self.EOF_STMT, line) + if eof: + yield EofStmt() + did_something = True + line = r + continue + + if False: + print self.COORD_STMT.pattern + print self.APERTURE_STMT.pattern + print self.COMMENT_STMT.pattern + print self.EOF_STMT.pattern + for i in self.PARAM_STMT: + print i.pattern + + if line.find('*') > 0: + yield UnknownStmt(line) + oldline = line + + def _match_one(self, expr, data): + match = expr.match(data) + if match is None: + return ({}, None) + else: + return (match.groupdict(), data[match.end(0):]) + + def _match_one_from_many(self, exprs, data): + for expr in exprs: + match = expr.match(data) + if match: + return (match.groupdict(), data[match.end(0):]) + + return ({}, None) -- cgit