summaryrefslogtreecommitdiff
path: root/gerbonara/rs274x.py
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
committerjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
commitc3ca4f95bd59f69d45e582a4149327f57a360760 (patch)
tree5f43c61a261698e2f671b5238a7aa9a71a0f6d23 /gerbonara/rs274x.py
parent259a56186820923c78a5688f59bd8249cf958b5f (diff)
downloadgerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.gz
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.bz2
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.zip
Rename gerbonara/gerber package to just gerbonara
Diffstat (limited to 'gerbonara/rs274x.py')
-rw-r--r--gerbonara/rs274x.py973
1 files changed, 973 insertions, 0 deletions
diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py
new file mode 100644
index 0000000..8bd622b
--- /dev/null
+++ b/gerbonara/rs274x.py
@@ -0,0 +1,973 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Modified from parser.py by Paulo Henrique Silva <ph.silva@gmail.com>
+# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
+# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
+# Copyright 2021 Jan Götte <code@jaseg.de>
+#
+# 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 math
+import warnings
+from pathlib import Path
+from itertools import count, chain
+from io import StringIO
+import dataclasses
+
+from .cam import CamFile, FileSettings
+from .utils import sq_distance, rotate_point, MM, Inch, units, InterpMode, UnknownStatementWarning
+from .aperture_macros.parse import ApertureMacro, GenericMacros
+from . import graphic_primitives as gp
+from . import graphic_objects as go
+from . import apertures
+from .excellon import ExcellonFile
+
+
+def points_close(a, b):
+ if a == b:
+ return True
+ elif a is None or b is None:
+ return False
+ elif None in a or None in b:
+ return False
+ else:
+ return math.isclose(a[0], b[0]) and math.isclose(a[1], b[1])
+
+class GerberFile(CamFile):
+ """ A class representing a single gerber file
+
+ The GerberFile class represents a single gerber file.
+ """
+
+ def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None,
+ layer_hints=None, file_attrs=None):
+ super().__init__(original_path=original_path)
+ self.objects = objects or []
+ self.comments = comments or []
+ self.generator_hints = generator_hints or []
+ self.layer_hints = layer_hints or []
+ self.import_settings = import_settings
+ self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
+ self.file_attrs = file_attrs or {}
+
+ def to_excellon(self):
+ new_objs = []
+ new_tools = {}
+ for obj in self.objects:
+ if not isinstance(obj, Line) or isinstance(obj, Arc) or isinstance(obj, Flash) or \
+ not isinstance(obj.aperture, CircleAperture):
+ raise ValueError('Cannot convert {type(obj)} to excellon!')
+
+ if not (new_tool := new_tools.get(id(obj.aperture))):
+ # TODO plating?
+ new_tool = new_tools[id(obj.aperture)] = ExcellonTool(obj.aperture.diameter)
+ new_obj = dataclasses.replace(obj, aperture=new_tool)
+
+ return ExcellonFile(objects=new_objs, comments=self.comments)
+
+ def merge(self, other):
+ """ Merge other GerberFile into this one """
+ if other is None:
+ return
+
+ self.import_settings = None
+ self.comments += other.comments
+
+ # dedup apertures
+ new_apertures = {}
+ replace_apertures = {}
+ mock_settings = FileSettings()
+ for ap in self.apertures + other.apertures:
+ gbr = ap.to_gerber(mock_settings)
+ if gbr not in new_apertures:
+ new_apertures[gbr] = ap
+ else:
+ replace_apertures[id(ap)] = new_apertures[gbr]
+ self.apertures = list(new_apertures.values())
+
+ self.objects += other.objects
+ for obj in self.objects:
+ # If object has an aperture attribute, replace that aperture.
+ if (ap := replace_apertures.get(id(getattr(obj, 'aperture', None)))):
+ obj.aperture = ap
+
+ # dedup aperture macros
+ macros = { m.to_gerber(): m
+ for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon] }
+ for ap in new_apertures.values():
+ if isinstance(ap, apertures.ApertureMacroInstance):
+ macro_grb = ap.macro.to_gerber() # use native unit to compare macros
+ if macro_grb in macros:
+ ap.macro = macros[macro_grb]
+ else:
+ macros[macro_grb] = ap.macro
+
+ # make macro names unique
+ seen_macro_names = set()
+ for macro in macros.values():
+ i = 2
+ while (new_name := f'{macro.name}{i}') in seen_macro_names:
+ i += 1
+ macro.name = new_name
+ seen_macro_names.add(new_name)
+
+ def dilate(self, offset, unit=MM, polarity_dark=True):
+ self.apertures = [ aperture.dilated(offset, unit) for aperture in self.apertures ]
+
+ offset_circle = CircleAperture(offset, unit=unit)
+ self.apertures.append(offset_circle)
+
+ new_primitives = []
+ for p in self.primitives:
+
+ p.polarity_dark = polarity_dark
+
+ # Ignore Line, Arc, Flash. Their actual dilation has already been done by dilating the apertures above.
+ if isinstance(p, Region):
+ ol = p.poly.outline
+ for start, end, arc_center in zip(ol, ol[1:] + ol[0], p.poly.arc_centers):
+ if arc_center is not None:
+ new_primitives.append(Arc(*start, *end, *arc_center,
+ polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
+
+ else:
+ new_primitives.append(Line(*start, *end,
+ polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
+
+ # it's safe to append these at the end since we compute a logical OR of opaque areas anyway.
+ self.primitives.extend(new_primitives)
+
+ @classmethod
+ def open(kls, filename, enable_includes=False, enable_include_dir=None):
+ filename = Path(filename)
+ with open(filename, "r") as f:
+ if enable_includes and enable_include_dir is None:
+ enable_include_dir = filename.parent
+ return kls.from_string(f.read(), enable_include_dir, filename=filename)
+
+ @classmethod
+ def from_string(kls, data, enable_include_dir=None, filename=None):
+ # filename arg is for error messages
+ obj = kls()
+ GerberParser(obj, include_dir=enable_include_dir).parse(data, filename=filename)
+ return obj
+
+ def generate_statements(self, settings, drop_comments=True):
+ yield 'G04 Gerber file generated by Gerbonara*'
+ for name, value in self.file_attrs.items():
+ attrdef = ','.join([name, *map(str, value)])
+ yield f'%TF{attrdef}*%'
+ yield '%MOMM*%' if (settings.unit == 'mm') else '%MOIN*%'
+
+ zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified
+ notation = 'I' if settings.notation == 'incremental' else 'A' # default to absolute
+ number_format = str(settings.number_format[0]) + str(settings.number_format[1])
+ yield f'%FS{zeros}{notation}X{number_format}Y{number_format}*%'
+ yield '%IPPOS*%'
+ yield 'G75'
+ yield '%LPD*%'
+
+ if not drop_comments:
+ yield 'G04 Comments from original gerber file:*'
+ for cmt in self.comments:
+ yield f'G04{cmt}*'
+
+ # Always emit gerbonara's generic, rotation-capable aperture macro replacements for the standard C/R/O/P shapes.
+ # Unconditionally emitting these here is easier than first trying to figure out if we need them later,
+ # and they are only a few bytes anyway.
+ am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(unit=settings.unit)}*\n%'
+ for macro in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon ]:
+ yield am_stmt(macro)
+
+ processed_macros = set()
+ aperture_map = {}
+ for number, aperture in enumerate(self.apertures, start=10):
+
+ if isinstance(aperture, apertures.ApertureMacroInstance):
+ macro_def = am_stmt(aperture._rotated().macro)
+ if macro_def not in processed_macros:
+ processed_macros.add(macro_def)
+ yield macro_def
+
+ yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
+
+ aperture_map[id(aperture)] = number
+
+ def warn(msg, kls=SyntaxWarning):
+ warnings.warn(msg, kls)
+
+ gs = GraphicsState(warn=warn, aperture_map=aperture_map, file_settings=settings)
+ for primitive in self.objects:
+ yield from primitive.to_statements(gs)
+
+ yield 'M02*'
+
+ def __str__(self):
+ name = f'{self.original_path.name} ' if self.original_path else ''
+ return f'<GerberFile {name}with {len(self.apertures)} apertures, {len(self.objects)} objects>'
+
+ def save(self, filename, settings=None, drop_comments=True):
+ with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec.
+ f.write(self.to_gerber(settings, drop_comments=drop_comments))
+
+ def to_gerber(self, settings=None, drop_comments=True):
+ # Use given settings, or use same settings as original file if not given, or use defaults if not imported from a
+ # file
+ if settings is None:
+ settings = self.import_settings.copy() or FileSettings()
+ settings.zeros = None
+ settings.number_format = (5,6)
+ return '\n'.join(self.generate_statements(settings, drop_comments=drop_comments))
+
+ @property
+ def is_empty(self):
+ return not self.objects
+
+ def __len__(self):
+ return len(self.objects)
+
+ def __bool__(self):
+ return not self.is_empty
+
+ def offset(self, dx=0, dy=0, unit=MM):
+ # TODO round offset to file resolution
+
+ self.objects = [ obj.with_offset(dx, dy, unit) for obj in self.objects ]
+
+ def rotate(self, angle:'radian', center=(0,0), unit=MM):
+ """ Rotate file contents around given point.
+
+ Arguments:
+ angle -- Rotation angle in radian clockwise.
+ center -- Center of rotation (default: document origin (0, 0))
+
+ Note that when rotating by odd angles other than 0, 90, 180 or 270 degree this method may replace standard
+ rect and oblong apertures by macro apertures. Existing macro apertures are re-written.
+ """
+ if math.isclose(angle % (2*math.pi), 0):
+ return
+
+ # First, rotate apertures. We do this separately from rotating the individual objects below to rotate each
+ # aperture exactly once.
+ for ap in self.apertures:
+ ap.rotation += angle
+
+ for obj in self.objects:
+ obj.rotate(angle, *center, unit)
+
+ def invert_polarity(self):
+ for obj in self.objects:
+ obj.polarity_dark = not p.polarity_dark
+
+
+class GraphicsState:
+ def __init__(self, warn, file_settings=None, aperture_map=None):
+ self.image_polarity = 'positive' # IP image polarity; deprecated
+ self.polarity_dark = True
+ self.point = None
+ self.aperture = None
+ self.file_settings = None
+ self.interpolation_mode = InterpMode.LINEAR
+ self.multi_quadrant_mode = None # used only for syntax checking
+ self.aperture_mirroring = (False, False) # LM mirroring (x, y)
+ self.aperture_rotation = 0 # LR rotation in degree, ccw
+ self.aperture_scale = 1 # LS scale factor, NOTE: same for both axes
+ # The following are deprecated file-wide settings. We normalize these during parsing.
+ self.image_offset = (0, 0)
+ self.image_rotation = 0 # IR image rotation in degree ccw, one of 0, 90, 180 or 270; deprecated
+ self.image_mirror = (False, False) # IM image mirroring, (x, y); deprecated
+ self.image_scale = (1.0, 1.0) # SF image scaling (x, y); deprecated
+ self.image_axes = 'AXBY' # AS axis mapping; deprecated
+ self._mat = None
+ self.file_settings = file_settings
+ self.aperture_map = aperture_map or {}
+ self.warn = warn
+ self.unit_warning = False
+
+ def __setattr__(self, name, value):
+ # input validation
+ if name == 'image_axes' and value not in [None, 'AXBY', 'AYBX']:
+ raise ValueError('image_axes must be either "AXBY", "AYBX" or None')
+ elif name == 'image_rotation' and value not in [0, 90, 180, 270]:
+ raise ValueError('image_rotation must be 0, 90, 180 or 270')
+ elif name == 'image_polarity' and value not in ['positive', 'negative']:
+ raise ValueError('image_polarity must be either "positive" or "negative"')
+ elif name == 'image_mirror' and len(value) != 2:
+ raise ValueError('mirror_image must be 2-tuple of bools: (mirror_a, mirror_b)')
+ elif name == 'image_offset' and len(value) != 2:
+ raise ValueError('image_offset must be 2-tuple of floats: (offset_a, offset_b)')
+ elif name == 'image_scale' and len(value) != 2:
+ raise ValueError('image_scale must be 2-tuple of floats: (scale_a, scale_b)')
+
+ # polarity handling
+ if name == 'image_polarity': # global IP statement image polarity, can only be set at beginning of file
+ if getattr(self, 'image_polarity', None) == 'negative':
+ self.polarity_dark = False # evaluated before image_polarity is set below through super().__setattr__
+
+ elif name == 'polarity_dark': # local LP statement polarity for subsequent objects
+ if self.image_polarity == 'negative':
+ value = not value
+
+ super().__setattr__(name, value)
+
+ def _update_xform(self):
+ a, b = 1, 0
+ c, d = 0, 1
+ off_x, off_y = self.image_offset
+
+ if self.image_mirror[0]:
+ a = -1
+ if self.image_mirror[1]:
+ d = -1
+
+ a *= self.image_scale[0]
+ d *= self.image_scale[1]
+
+ if self.image_rotation == 90:
+ a, b, c, d = 0, -d, a, 0
+ off_x, off_y = off_y, -off_x
+ elif self.image_rotation == 180:
+ a, b, c, d = -a, 0, 0, -d
+ off_x, off_y = -off_x, -off_y
+ elif self.image_rotation == 270:
+ a, b, c, d = 0, d, -a, 0
+ off_x, off_y = -off_y, off_x
+
+ self.image_offset = off_x, off_y
+ self._mat = a, b, c, d
+
+ def map_coord(self, x, y, relative=False):
+ if self._mat is None:
+ self._update_xform()
+ a, b, c, d = self._mat
+
+ if not relative:
+ rx, ry = (a*x + b*y + self.image_offset[0]), (c*x + d*y + self.image_offset[1])
+ return rx, ry
+ else:
+ # Apply mirroring, scale and rotation, but do not apply offset
+ rx, ry = (a*x + b*y), (c*x + d*y)
+ return rx, ry
+
+ def flash(self, x, y, attrs=None):
+ if self.file_settings.unit is None and not self.unit_warning:
+ self.warn('Gerber file does not contain a unit definition.')
+ self.unit_warning = True
+ attrs = attrs or {}
+ self.update_point(x, y)
+ obj = go.Flash(*self.map_coord(*self.point), self.aperture,
+ polarity_dark=self.polarity_dark,
+ unit=self.file_settings.unit,
+ attrs=attrs)
+ return obj
+
+ def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False, attrs=None):
+ if self.point is None:
+ self.warn('D01 interpolation without preceding D02 move.')
+ self.point = (0, 0)
+ old_point = self.map_coord(*self.update_point(x, y))
+
+ if self.file_settings.unit is None and not self.unit_warning:
+ self.warn('Gerber file does not contain a unit definition.')
+ self.unit_warning = True
+
+ if aperture:
+ if not self.aperture:
+ raise SyntaxError('Interpolation attempted without selecting aperture first')
+
+ if math.isclose(self.aperture.equivalent_width(), 0):
+ self.warn('D01 interpolation with a zero-size aperture. This is invalid according to spec, '
+ 'however, we pass through the created objects here. Note that these will not show up in e.g. '
+ 'SVG output since their line width is zero.')
+
+ if self.interpolation_mode == InterpMode.LINEAR:
+ if i is not None or j is not None:
+ raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
+
+ return self._create_line(old_point, self.map_coord(*self.point), aperture, attrs)
+
+ else:
+
+ if i is None and j is None:
+ self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
+ return self._create_line(old_point, self.map_coord(*self.point), aperture, attrs)
+
+ else:
+ if i is None:
+ self.warn('Arc is missing I value')
+ i = 0
+ if j is None:
+ self.warn('Arc is missing J value')
+ j = 0
+ return self._create_arc(old_point, self.map_coord(*self.point), (i, j), aperture, multi_quadrant, attrs)
+
+ def _create_line(self, old_point, new_point, aperture=True, attrs=None):
+ attrs = attrs or {}
+ return go.Line(*old_point, *new_point, self.aperture if aperture else None,
+ polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs)
+
+ def _create_arc(self, old_point, new_point, control_point, aperture=True, multi_quadrant=False, attrs=None):
+ attrs = attrs or {}
+ clockwise = self.interpolation_mode == InterpMode.CIRCULAR_CW
+
+ if not multi_quadrant:
+ return go.Arc(*old_point, *new_point, *self.map_coord(*control_point, relative=True),
+ clockwise=clockwise, aperture=(self.aperture if aperture else None),
+ polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs)
+
+ else:
+ if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
+ # In multi-quadrant mode, an arc with identical start and end points is not rendered at all. Only in
+ # single-quadrant mode it is rendered as a full circle.
+ return None
+
+ # Super-legacy. No one uses this EXCEPT everything that mentor graphics / siemens make uses this m(
+ (cx, cy) = self.map_coord(*control_point, relative=True)
+
+ arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
+ clockwise=clockwise, aperture=(self.aperture if aperture else None),
+ polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs)
+ arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
+ arcs = sorted(arcs, key=lambda a: a.numeric_error())
+
+ for a in arcs:
+ d = gp.point_line_distance(old_point, new_point, (old_point[0]+a.cx, old_point[1]+a.cy))
+ if (d > 0) == clockwise:
+ return a
+ assert False
+
+ def update_point(self, x, y, unit=None):
+ old_point = self.point
+ x, y = MM(x, unit), MM(y, unit)
+
+ if (x is None or y is None) and self.point is None:
+ self.warn('Coordinate omitted from first coordinate statement in the file. This is likely a Siemens '
+ 'file. We pretend the omitted coordinate was 0.')
+ self.point = (0, 0)
+
+ if x is None:
+ x = self.point[0]
+ if y is None:
+ y = self.point[1]
+
+ self.point = (x, y)
+ return old_point
+
+ # Helpers for gerber generation
+ def set_polarity(self, polarity_dark):
+ if self.polarity_dark != polarity_dark:
+ self.polarity_dark = polarity_dark
+ yield '%LPD*%' if polarity_dark else '%LPC*%'
+
+ def set_aperture(self, aperture):
+ if self.aperture != aperture:
+ self.aperture = aperture
+ yield f'D{self.aperture_map[id(aperture)]}*'
+
+ def set_current_point(self, point, unit=None):
+ point_mm = MM(point[0], unit), MM(point[1], unit)
+ # TODO calculate appropriate precision for math.isclose given file_settings.notation
+
+ if not points_close(self.point, point_mm):
+ self.point = point_mm
+ x = self.file_settings.write_gerber_value(point[0], unit=unit)
+ y = self.file_settings.write_gerber_value(point[1], unit=unit)
+ yield f'X{x}Y{y}D02*'
+
+ def set_interpolation_mode(self, mode):
+ if self.interpolation_mode != mode:
+ self.interpolation_mode = mode
+ yield self.interpolation_mode_statement()
+
+ def interpolation_mode_statement(self):
+ return {
+ InterpMode.LINEAR: 'G01',
+ InterpMode.CIRCULAR_CW: 'G02',
+ InterpMode.CIRCULAR_CCW: 'G03'}[self.interpolation_mode]
+
+
+class GerberParser:
+ NUMBER = r"[\+-]?\d+"
+ DECIMAL = r"[\+-]?\d+([.]?\d+)?"
+ NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
+
+ STATEMENT_REGEXES = {
+ 'region_start': r'G36$',
+ 'region_end': r'G37$',
+ 'coord': fr"(?P<interpolation>G0?[123]|G74|G75)?(X(?P<x>{NUMBER}))?(Y(?P<y>{NUMBER}))?" \
+ fr"(I(?P<i>{NUMBER}))?(J(?P<j>{NUMBER}))?" \
+ fr"(?P<operation>D0?[123])?$",
+ 'aperture': r"(G54|G55)?D(?P<number>\d+)",
+ # Allegro combines format spec and unit into one long illegal extended command.
+ 'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)",
+ 'unit_mode': r"MO(?P<unit>(MM|IN))",
+ 'format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*",
+ 'allegro_legacy_params': fr'^IR(?P<rotation>[0-9]+)\*IP(?P<polarity>(POS|NEG))\*OF(A(?P<a>{DECIMAL}))?(B(?P<b>{DECIMAL}))?\*MI(A(?P<ma>0|1))?(B(?P<mb>0|1))?\*SF(A(?P<sa>{DECIMAL}))?(B(?P<sb>{DECIMAL}))?',
+ 'load_polarity': r"LP(?P<polarity>(D|C))",
+ # FIXME LM, LR, LS
+ 'load_name': r"LN(?P<name>.*)",
+ 'offset': fr"OF(A(?P<a>{DECIMAL}))?(B(?P<b>{DECIMAL}))?",
+ 'include_file': r"IF(?P<filename>.*)",
+ 'image_name': r"^IN(?P<name>.*)",
+ 'axis_selection': r"^AS(?P<axes>AXBY|AYBX)",
+ 'image_polarity': r"^IP(?P<polarity>(POS|NEG))",
+ 'image_rotation': fr"^IR(?P<rotation>{NUMBER})",
+ 'mirror_image': r"^MI(A(?P<ma>0|1))?(B(?P<mb>0|1))?",
+ 'scale_factor': fr"^SF(A(?P<sa>{DECIMAL}))?(B(?P<sb>{DECIMAL}))?",
+ 'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
+ 'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
+ 'siemens_garbage': r'^ICAS$',
+ 'old_unit':r'(?P<mode>G7[01])',
+ 'old_notation': r'(?P<mode>G9[01])',
+ 'eof': r"M0?[02]",
+ 'ignored': r"(?P<stmt>M01)",
+ # NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense.
+ 'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)(,(?P<value>.*))",
+ # Eagle file attributes handled above.
+ 'comment': r"G0?4(?P<comment>[^*]*)",
+ }
+
+ STATEMENT_REGEXES = { key: re.compile(value) for key, value in STATEMENT_REGEXES.items() }
+
+
+ def __init__(self, target, include_dir=None):
+ """ Pass an include dir to enable IF include statements (potentially DANGEROUS!). """
+ self.target = target
+ self.include_dir = include_dir
+ self.include_stack = []
+ self.file_settings = FileSettings()
+ self.graphics_state = GraphicsState(warn=self.warn, file_settings=self.file_settings)
+ self.aperture_map = {}
+ self.aperture_macros = {}
+ self.current_region = None
+ self.eof_found = False
+ self.multi_quadrant_mode = None # used only for syntax checking
+ self.macros = {}
+ self.last_operation = None
+ self.generator_hints = []
+ self.layer_hints = []
+ self.file_attrs = {}
+ self.object_attrs = {}
+ self.aperture_attrs = {}
+ self.filename = None
+ self.lineno = None
+ self.line = None
+
+ def warn(self, msg, kls=SyntaxWarning):
+ line_joined = self.line.replace('\n', '\\n')
+ warnings.warn(f'{self.filename}:{self.lineno} "{line_joined}": {msg}', kls)
+
+ @classmethod
+ def _split_commands(kls, data):
+ start = 0
+ extended_command = False
+ lineno = 1
+
+ for pos, c in enumerate(data):
+ if c == '\n':
+ lineno += 1
+
+ if c == '%':
+ if extended_command:
+ yield lineno, data[start:pos]
+ extended_command = False
+
+ else:
+ # Ignore % inside G04 comments. Eagle uses a completely borked file attribute syntax with unbalanced
+ # percent signs inside G04 comments.
+ if not data[start:pos].startswith('G04'):
+ extended_command = True
+
+ start = pos + 1
+ continue
+
+ elif extended_command:
+ continue
+
+ if c in '*\r\n':
+ word_command = data[start:pos].strip()
+ if word_command and word_command != '*':
+ yield lineno, word_command
+ start = pos + 1
+
+ def parse(self, data, filename=None):
+ # filename arg is for error messages
+ filename = self.filename = filename or '<unknown>'
+
+ for lineno, line in self._split_commands(data):
+ if not line.strip():
+ continue
+ line = line.rstrip('*').strip()
+ self.lineno, self.line = lineno, line
+ # We cannot assume input gerber to use well-formed statement delimiters. Thus, we may need to parse
+ # multiple statements from one line.
+ if line.strip() and self.eof_found:
+ self.warn('Data found in gerber file after EOF.')
+ #print(f'Line {lineno}: {line}')
+
+ for name, le_regex in self.STATEMENT_REGEXES.items():
+ if (match := le_regex.match(line)):
+ #print(f' match: {name} / {match}')
+ try:
+ getattr(self, f'_parse_{name}')(match)
+ except Exception as e:
+ #print(f'Line {lineno}: {line}')
+ #print(f' match: {name} / {match}')
+ raise SyntaxError(f'{filename}:{lineno} "{line}": {e}') from e
+ line = line[match.end(0):]
+ break
+
+ else:
+ self.warn(f'Unknown statement found: "{line}", ignoring.', UnknownStatementWarning)
+ self.target.comments.append(f'Unknown statement found: "{line}", ignoring.')
+
+ self.target.apertures = list(self.aperture_map.values())
+ self.target.import_settings = self.file_settings
+ self.target.unit = self.file_settings.unit
+ self.target.file_attrs = self.file_attrs
+ self.target.original_path = filename
+
+ if not self.eof_found:
+ self.warn('File is missing mandatory M02 EOF marker. File may be truncated.')
+
+ def _parse_coord(self, match):
+ if match['interpolation'] == 'G01':
+ self.graphics_state.interpolation_mode = InterpMode.LINEAR
+ elif match['interpolation'] == 'G02':
+ self.graphics_state.interpolation_mode = InterpMode.CIRCULAR_CW
+ elif match['interpolation'] == 'G03':
+ self.graphics_state.interpolation_mode = InterpMode.CIRCULAR_CCW
+ elif match['interpolation'] == 'G74':
+ self.multi_quadrant_mode = True # used only for syntax checking
+ elif match['interpolation'] == 'G75':
+ self.multi_quadrant_mode = False
+
+ has_coord = (match['x'] or match['y'] or match['i'] or match['j'])
+ if match['interpolation'] in ('G74', 'G75') and has_coord:
+ raise SyntaxError('G74/G75 combined with coord')
+
+ x = self.file_settings.parse_gerber_value(match['x'])
+ y = self.file_settings.parse_gerber_value(match['y'])
+ i = self.file_settings.parse_gerber_value(match['i'])
+ j = self.file_settings.parse_gerber_value(match['j'])
+
+ if not (op := match['operation']) and has_coord:
+ if self.last_operation == 'D01':
+ self.warn('Coordinate statement without explicit operation code. This is forbidden by spec.')
+ op = 'D01'
+
+ else:
+ if 'siemens' in self.generator_hints:
+ self.warn('Ambiguous coordinate statement. Coordinate statement does not have an operation '\
+ 'mode and the last operation statement was not D01. This is garbage, and forbidden '\
+ 'by spec. but since this looks like a Siemens/Mentor Graphics file, we will let it '\
+ 'slide and treat this as the same as the last operation.')
+ # Yes, we repeat the last op, and don't do a D01. This is confirmed by
+ # resources/siemens/80101_0125_F200_L12_Bottom.gdo which contains an implicit-double-D02
+ op = self.last_operation
+ else:
+ raise SyntaxError('Ambiguous coordinate statement. Coordinate statement does not have an '\
+ 'operation mode and the last operation statement was not D01. This is garbage, and '\
+ 'forbidden by spec.')
+
+ self.last_operation = op
+
+ if op in ('D1', 'D01'):
+ if self.graphics_state.interpolation_mode != InterpMode.LINEAR:
+ if self.multi_quadrant_mode is None:
+ self.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\
+ 'This can cause problems with older gerber interpreters.')
+
+ elif self.multi_quadrant_mode:
+ self.warn('Deprecated G74 multi-quadant mode arc found. G74 is bad and you should feel bad.')
+
+ if self.current_region is None:
+ # in multi-quadrant mode this may return None if start and end point of the arc are the same.
+ obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=bool(self.multi_quadrant_mode))
+ if obj is not None:
+ self.target.objects.append(obj)
+ else:
+ obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=bool(self.multi_quadrant_mode))
+ if obj is not None:
+ self.current_region.append(obj)
+
+ elif op in ('D2', 'D02'):
+ self.graphics_state.update_point(x, y)
+ if self.current_region:
+ # Start a new region for every outline. As gerber has no concept of fill rules or winding numbers,
+ # it does not make a graphical difference, and it makes the implementation slightly easier.
+ self.target.objects.append(self.current_region)
+ self.current_region = go.Region(
+ polarity_dark=self.graphics_state.polarity_dark,
+ unit=self.file_settings.unit)
+
+ elif op in ('D3', 'D03'):
+ if self.current_region is None:
+ self.target.objects.append(self.graphics_state.flash(x, y))
+ else:
+ raise SyntaxError('DO3 flash statement inside region')
+
+ else:
+ # Do nothing if there is no explicit D code.
+ pass
+
+ def _parse_aperture(self, match):
+ number = int(match['number'])
+ if number < 10:
+ raise SyntaxError(f'Invalid aperture number {number}: Aperture number must be >= 10.')
+
+ if number not in self.aperture_map:
+ raise SyntaxError(f'Tried to access undefined aperture {number}')
+
+ self.graphics_state.aperture = self.aperture_map[number]
+
+ def _parse_aperture_definition(self, match):
+ # number, shape, modifiers
+ modifiers = [ float(val) for val in match['modifiers'].strip(' ,').split('X') ] if match['modifiers'] else []
+ number = int(match['number'])
+
+ aperture_classes = {
+ 'C': apertures.CircleAperture,
+ 'R': apertures.RectangleAperture,
+ 'O': apertures.ObroundAperture,
+ 'P': apertures.PolygonAperture,
+ }
+
+ if (kls := aperture_classes.get(match['shape'])):
+ if match['shape'] == 'P' and math.isclose(modifiers[0], 0):
+ self.warn('Definition of zero-size polygon aperture. This is invalid according to spec.' )
+
+ if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
+ self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' )
+
+ new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy(),
+ original_number=number)
+
+ elif (macro := self.aperture_macros.get(match['shape'])):
+ new_aperture = apertures.ApertureMacroInstance(macro, modifiers, unit=self.file_settings.unit,
+ attrs=self.aperture_attrs.copy(), original_number=number)
+
+ else:
+ raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
+
+ self.aperture_map[number] = new_aperture
+
+ def _parse_aperture_macro(self, match):
+ self.aperture_macros[match['name']] = ApertureMacro.parse_macro(
+ match['name'], match['macro'], self.file_settings.unit)
+
+ def _parse_format_spec(self, match):
+ # This is a common problem in Eagle files, so just suppress it
+ self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
+ self.file_settings.notation = 'incremental' if match['notation'] == 'I' else 'absolute'
+
+ if match['x'] != match['y']:
+ raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})')
+ self.file_settings.number_format = int(match['x'][0]), int(match['x'][1])
+
+ def _parse_unit_mode(self, match):
+ if match['unit'] == 'MM':
+ self.file_settings.unit = MM
+ else:
+ self.file_settings.unit = Inch
+
+ def _parse_allegro_format_spec(self, match):
+ self._parse_format_spec(match)
+ self._parse_unit_mode(match)
+
+ def _parse_load_polarity(self, match):
+ self.graphics_state.polarity_dark = match['polarity'] == 'D'
+
+ def _parse_offset(self, match):
+ a, b = match['a'], match['b']
+ a = float(a) if a else 0
+ b = float(b) if b else 0
+ self.graphics_state.offset = a, b
+
+ def _parse_allegro_legacy_params(self, match):
+ self._parse_image_rotation(match)
+ self._parse_offset(match)
+ self._parse_image_polarity(match)
+ self._parse_mirror_image(match)
+ self._parse_scale_factor(match)
+
+ def _parse_include_file(self, match):
+ if self.include_dir is None:
+ self.warn('IF include statement found, but includes are deactivated.', ResourceWarning)
+ else:
+ self.warn('IF include statement found. Includes are activated, but is this really a good idea?', ResourceWarning)
+
+ include_file = self.include_dir / param["filename"]
+ # Do not check if path exists to avoid leaking existence via error message
+ include_file = include_file.resolve(strict=False)
+
+ if not include_file.is_relative_to(self.include_dir):
+ raise FileNotFoundError('Attempted traversal to parent of include dir in path from IF include statement')
+
+ if not include_file.is_file():
+ raise FileNotFoundError('File pointed to by IF include statement does not exist')
+
+ if include_file in self.include_stack:
+ raise ValueError("Recusive inclusion via IF include statement.")
+ self.include_stack.append(include_file)
+
+ # Spec 2020-09 section 3.1: Gerber files must use UTF-8
+ self._parse(f.read_text(encoding='UTF-8'), filename=include_file.name)
+ self.include_stack.pop()
+
+ def _parse_image_name(self, match):
+ self.warn('Deprecated IN (image name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning)
+ self.target.comments.append(f'Image name: {match["name"]}')
+
+ def _parse_load_name(self, match):
+ self.warn('Deprecated LN (load name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning)
+
+ def _parse_axis_selection(self, match):
+ if match['axes'] != 'AXBY':
+ self.warn('Deprecated AS (axis selection) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning)
+ self.graphics_state.output_axes = match['axes']
+
+ def _parse_image_polarity(self, match):
+ polarity = dict(POS='positive', NEG='negative')[match['polarity']]
+ if polarity != 'positive':
+ self.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning)
+ self.graphics_state.image_polarity = polarity
+
+ def _parse_image_rotation(self, match):
+ rotation = int(match['rotation'])
+ if rotation:
+ self.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning)
+ self.graphics_state.image_rotation = rotation
+
+ def _parse_mirror_image(self, match):
+ mirror = bool(int(match['ma'] or '0')), bool(int(match['mb'] or '1'))
+ if mirror != (False, False):
+ self.warn('Deprecated MI (mirror image) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning)
+ self.graphics_state.image_mirror = mirror
+
+ def _parse_scale_factor(self, match):
+ a = float(match['sa']) if match['sa'] else 1.0
+ b = float(match['sb']) if match['sb'] else 1.0
+ if not math.isclose(math.dist((a, b), (1, 1)), 0):
+ self.warn('Deprecated SF (scale factor) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning)
+ self.graphics_state.scale_factor = a, b
+
+ def _parse_siemens_garbage(self, match):
+ self.generator_hints.append('siemens')
+
+ def _parse_comment(self, match):
+ cmt = match["comment"].strip()
+
+ # Parse metadata from allegro comments
+ # We do this for layer identification since allegro files usually do not follow any defined naming scheme
+ if cmt.startswith('File Origin:') and 'Allegro' in cmt:
+ self.generator_hints.append('allegro')
+
+ elif cmt.startswith('PADS') and 'generated Gerber' in cmt:
+ self.generator_hints.append('pads')
+
+ elif cmt.startswith('Layer:'):
+ if 'BOARD GEOMETRY' in cmt:
+ if 'SOLDERMASK_TOP' in cmt:
+ self.layer_hints.append('top mask')
+ if 'SOLDERMASK_BOTTOM' in cmt:
+ self.layer_hints.append('bottom mask')
+ if 'PASTEMASK_TOP' in cmt:
+ self.layer_hints.append('top paste')
+ if 'PASTEMASK_BOTTOM' in cmt:
+ self.layer_hints.append('bottom paste')
+ if 'SILKSCREEN_TOP' in cmt:
+ self.layer_hints.append('top silk')
+ if 'SILKSCREEN_BOTTOM' in cmt:
+ self.layer_hints.append('bottom silk')
+ elif 'ETCH' in cmt:
+ _1, _2, name = cmt.partition('/')
+ name = re.sub(r'\W+', '_', name)
+ self.layer_hints.append(f'{name} copper')
+
+ elif cmt.startswith('Mentor Graphics'):
+ self.generator_hints.append('siemens')
+
+ else:
+ self.target.comments.append(cmt)
+
+ def _parse_region_start(self, _match):
+ self.current_region = go.Region(
+ polarity_dark=self.graphics_state.polarity_dark,
+ unit=self.file_settings.unit)
+
+ def _parse_region_end(self, _match):
+ if self.current_region is None:
+ raise SyntaxError('Region end command (G37) outside of region')
+
+ if self.current_region: # ignore empty regions
+ self.target.objects.append(self.current_region)
+ self.current_region = None
+
+ def _parse_old_unit(self, match):
+ self.file_settings.unit = Inch if match['mode'] == 'G70' else MM
+ self.warn(f'Deprecated {match["mode"]} unit mode statement found. This deprecated since 2012.', DeprecationWarning)
+ self.target.comments.append('Replaced deprecated {match["mode"]} unit mode statement with MO statement')
+
+ def _parse_old_notation(self, match):
+ # FIXME make sure we always have FS at end of processing.
+ self.file_settings.notation = 'absolute' if match['mode'] == 'G90' else 'incremental'
+ self.warn(f'Deprecated {match["mode"]} notation mode statement found. This deprecated since 2012.', DeprecationWarning)
+ self.target.comments.append('Replaced deprecated {match["mode"]} notation mode statement with FS statement')
+
+ def _parse_attribute(self, match):
+ if match['type'] == 'TD':
+ if match['value']:
+ raise SyntaxError('TD attribute deletion command must not contain attribute fields')
+
+ if not match['name']:
+ self.object_attrs = {}
+ self.aperture_attrs = {}
+ return
+
+ if match['name'] in self.file_attrs:
+ raise SyntaxError('Attempt to TD delete file attribute. This does not make sense.')
+ elif match['name'] in self.object_attrs:
+ del self.object_attrs[match['name']]
+ elif match['name'] in self.aperture_attrs:
+ del self.aperture_attrs[match['name']]
+ else:
+ raise SyntaxError(f'Attempt to TD delete previously undefined attribute {match["name"]}.')
+
+ else:
+ target = {'TF': self.file_attrs, 'TO': self.object_attrs, 'TA': self.aperture_attrs}[match['type']]
+ target[match['name']] = match['value'].split(',')
+
+ if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
+ self.generator_hints.append('eagle')
+
+ def _parse_eof(self, _match):
+ self.eof_found = True
+
+ def _parse_ignored(self, match):
+ pass
+
+if __name__ == '__main__':
+ import argparse
+ parser = argparse.ArgumentParser()
+ parser.add_argument('testfile')
+ args = parser.parse_args()
+
+ bounds = (0.0, 0.0), (6.0, 6.0) # bottom left, top right
+ svg = str(GerberFile.open(args.testfile).to_svg(force_bounds=bounds, arg_unit='inch', fg='white', bg='black'))
+ print(svg)
+