summaryrefslogtreecommitdiff
path: root/gerbonara/gerber/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/gerber/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/gerber/rs274x.py')
-rw-r--r--gerbonara/gerber/rs274x.py973
1 files changed, 0 insertions, 973 deletions
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py
deleted file mode 100644
index 8bd622b..0000000
--- a/gerbonara/gerber/rs274x.py
+++ /dev/null
@@ -1,973 +0,0 @@
-#! /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)
-