This commit is contained in:
jaseg 2021-11-10 21:39:03 +01:00
parent 125eb821b9
commit d21a2e67ff
8 changed files with 206 additions and 2033 deletions

View file

@ -1,255 +0,0 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
# copyright 2014 Paulo Henrique Silva <ph.silva@gmail.com>
#
# 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 RS-274-X AM macro modifiers parsing.
"""
from .am_opcode import OpCode
import string
class Token:
ADD = "+"
SUB = "-"
# compatibility as many gerber writes do use non compliant X
MULT = ("x", "X")
DIV = "/"
OPERATORS = (ADD, SUB, *MULT, DIV)
LEFT_PARENS = "("
RIGHT_PARENS = ")"
EQUALS = "="
EOF = "EOF"
def token_to_opcode(token):
if token == Token.ADD:
return OpCode.ADD
elif token == Token.SUB:
return OpCode.SUB
elif token in Token.MULT:
return OpCode.MUL
elif token == Token.DIV:
return OpCode.DIV
else:
return None
def precedence(token):
if token == Token.ADD or token == Token.SUB:
return 1
elif token in Token.MULT or token == Token.DIV:
return 2
else:
return 0
def is_op(token):
return token in Token.OPERATORS
class Scanner:
def __init__(self, s):
self.buff = s
self.n = 0
def eof(self):
return self.n == len(self.buff)
def peek(self):
if not self.eof():
return self.buff[self.n]
return Token.EOF
def ungetc(self):
if self.n > 0:
self.n -= 1
def getc(self):
if self.eof():
return ""
c = self.buff[self.n]
self.n += 1
return c
def readint(self):
n = ""
while not self.eof() and (self.peek() in string.digits):
n += self.getc()
return int(n)
def readfloat(self):
n = ""
while not self.eof() and (self.peek() in string.digits or self.peek() == "."):
n += self.getc()
# weird case where zero is ommited inthe last modifider, like in ',0.'
if n == ".":
return 0
return float(n)
def readstr(self, end="*"):
s = ""
while not self.eof() and self.peek() != end:
s += self.getc()
return s.strip()
def print_instructions(instructions):
for opcode, argument in instructions:
print("%s %s" % (OpCode.str(opcode),
str(argument) if argument is not None else ""))
def read_macro(macro):
instructions = []
for block in macro.split("*"):
is_primitive = False
is_equation = False
found_equation_left_side = False
found_primitive_code = False
equation_left_side = 0
primitive_code = 0
unary_minus_allowed = False
unary_minus = False
if Token.EQUALS in block:
is_equation = True
else:
is_primitive = True
scanner = Scanner(block)
# inlined here for compactness and convenience
op_stack = []
def pop():
return op_stack.pop()
def push(op):
op_stack.append(op)
def top():
return op_stack[-1]
def empty():
return len(op_stack) == 0
while not scanner.eof():
c = scanner.getc()
if c == ",":
found_primitive_code = True
# add all instructions on the stack to finish last modifier
while not empty():
instructions.append((token_to_opcode(pop()), None))
unary_minus_allowed = True
elif c in Token.OPERATORS:
if c == Token.SUB and unary_minus_allowed:
unary_minus = True
unary_minus_allowed = False
continue
while not empty() and is_op(top()) and precedence(top()) >= precedence(c):
instructions.append((token_to_opcode(pop()), None))
push(c)
elif c == Token.LEFT_PARENS:
push(c)
elif c == Token.RIGHT_PARENS:
while not empty() and top() != Token.LEFT_PARENS:
instructions.append((token_to_opcode(pop()), None))
if empty():
raise ValueError("unbalanced parentheses")
# discard "("
pop()
elif c.startswith("$"):
n = scanner.readint()
if is_equation and not found_equation_left_side:
equation_left_side = n
else:
instructions.append((OpCode.LOAD, n))
elif c == Token.EQUALS:
found_equation_left_side = True
elif c == "0":
if is_primitive and not found_primitive_code:
instructions.append((OpCode.PUSH, scanner.readstr("*")))
found_primitive_code = True
else:
# decimal or integer disambiguation
if scanner.peek() not in '.' or scanner.peek() == Token.EOF:
instructions.append((OpCode.PUSH, 0))
elif c in "123456789.":
scanner.ungetc()
if is_primitive and not found_primitive_code:
primitive_code = scanner.readint()
else:
n = scanner.readfloat()
if unary_minus:
unary_minus = False
n *= -1
instructions.append((OpCode.PUSH, n))
else:
# whitespace or unknown char
pass
# add all instructions on the stack to finish last modifier (if any)
while not empty():
instructions.append((token_to_opcode(pop()), None))
# at end, we either have a primitive or a equation
if is_primitive and found_primitive_code:
instructions.append((OpCode.PRIM, primitive_code))
if is_equation:
instructions.append((OpCode.STORE, equation_left_side))
return instructions
if __name__ == '__main__':
import sys
instructions = read_macro(sys.argv[1])
print("insructions:")
print_instructions(instructions)
print("eval:")
from .am_primitive import eval_macro
for primitive in eval_macro(instructions, 'mm'):
print(primitive)

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ class FileSettings:
image_polarity : str = 'positive'
image_rotation: int = 0
mirror_image : tuple = (False, False)
offset : tuple = (0, 0)
scale_factor : tuple = (1.0, 1.0) # For deprecated SF statement
notation : str = 'absolute'
units : str = 'inch'
@ -41,6 +42,8 @@ class FileSettings:
raise ValueError('image_polarity must be either "positive" or "negative"')
elif name == 'mirror_image' and len(value) != 2:
raise ValueError('mirror_image must be 2-tuple of bools: (mirror_a, mirror_b)')
elif name == 'offset' and len(value) != 2:
raise ValueError('offset must be 2-tuple of floats: (offset_a, offset_b)')
elif name == 'scale_factor' and len(value) != 2:
raise ValueError('scale_factor must be 2-tuple of floats: (scale_a, scale_b)')
elif name == 'notation' and value not in ['inch', 'mm']:

View file

@ -30,48 +30,23 @@ from .primitives import AMGroup
class Statement:
""" Gerber statement Base class
pass
The statement class provides a type attribute.
Parameters
----------
type : string
String identifying the statement type.
Attributes
----------
type : string
String identifying the statement type.
"""
def __str__(self):
s = "<{0} ".format(self.__class__.__name__)
for key, value in self.__dict__.items():
s += "{0}={1} ".format(key, value)
s = s.rstrip() + ">"
return s
def offset(self, x_offset=0, y_offset=0):
def update_graphics_state(self, _state):
pass
def __eq__(self, other):
return self.__dict__ == other.__dict__
def render_primitives(self, _state):
pass
class ParamStmt(Statement):
pass
class FormatSpecStmt(ParamStmt):
""" FS - Gerber Format Specification Statement """
code = 'FS'
def to_gerber(self, settings):
zeros = 'L' if settings.zero_suppression == 'leading' else 'T'
notation = 'A' if settings.notation == 'absolute' else 'I'
fmt = settings.number_format
number_format = str(settings.number_format[0]) + str(settings.number_format[1])
return f'%FS{zeros}{notation}X{number_format}Y{number_format}*%'
@ -104,8 +79,11 @@ class LoadPolarityStmt(ParamStmt):
lp = 'dark' if self.dark else 'clear'
return f'<LP Level Polarity: {lp}>'
def update_graphics_state(self, state):
state.polarity_dark = self.dark
class ADParamStmt(ParamStmt):
class ApertureDefStmt(ParamStmt):
""" AD - Aperture Definition Statement """
@classmethod
@ -306,15 +284,6 @@ class AMParamStmt(ParamStmt):
return '<AM Aperture Macro %s: %s>' % (self.name, self.macro)
class AxisSelectionStmt(ParamStmt):
""" AS - Axis Selection Statement. (Deprecated) """
def to_gerber(self, settings):
return f'%AS{settings.output_axes}*%'
def __str__(self):
return '<AS Axis Select>'
class ImagePolarityStmt(ParamStmt):
""" IP - Image Polarity Statement. (Deprecated) """
@ -326,157 +295,49 @@ class ImagePolarityStmt(ParamStmt):
return '<IP Image Polarity>'
class ImageRotationStmt(ParamStmt):
""" IR - Image Rotation Statement. (Deprecated) """
def to_gerber(self, settings):
return f'%IR{settings.image_rotation}*%'
def __str__(self):
return '<IR Image Rotation>'
class MirrorImageStmt(ParamStmt):
""" MI - Mirror Image Statement. (Deprecated) """
def to_gerber(self, settings):
return f'%SFA{int(bool(settings.mirror_image[0]))}B{int(bool(settings.mirror_image[1]))}*%'
def __str__(self):
return '<MI Mirror Image>'
class OffsetStmt(ParamStmt):
""" OF - File Offset Statement. (Deprecated) """
def __init__(self, a, b):
self.a, self.b = a, b
def to_gerber(self, settings=None):
# FIXME unit conversion
return f'%OFA{decimal_string(self.a, precision=5)}B{decimal_string(self.b, precision=5)}*%'
def __str__(self):
return f'<OF Offset a={self.a} b={self.b}>'
class SFParamStmt(ParamStmt):
""" SF - Scale Factor Statement. (Deprecated) """
def __init__(self, a, b):
self.a, self.b = a, b
def to_gerber(self, settings=None):
return '%SFA{decimal_string(self.a, precision=5)}B{decimal_string(self.b, precision=5)}*%'
def __str__(self):
return '<SF Scale Factor>'
class CoordStmt(Statement):
""" D01 - D03 operation statements """
def __init__(self, x, y, i, j):
self.x = x
self.y = y
self.i = i
self.j = j
@classmethod
def move(cls, func, point):
if point:
return cls(func, point[0], point[1], None, None, CoordStmt.OP_MOVE, None)
# No point specified, so just write the function. This is normally for ending a region (D02*)
return cls(func, None, None, None, None, CoordStmt.OP_MOVE, None)
@classmethod
def line(cls, func, point):
return cls(func, point[0], point[1], None, None, CoordStmt.OP_DRAW, None)
@classmethod
def mode(cls, func):
return cls(func, None, None, None, None, None, None)
@classmethod
def arc(cls, func, point, center):
return cls(func, point[0], point[1], center[0], center[1], CoordStmt.OP_DRAW, None)
@classmethod
def flash(cls, point):
if point:
return cls(None, point[0], point[1], None, None, CoordStmt.OP_FLASH, None)
else:
return cls(None, None, None, None, None, CoordStmt.OP_FLASH, None)
self.x, self.y, self.i, self.j = x, y, i, j
def to_gerber(self, settings=None):
ret = ''
if self.x is not None:
ret += 'X{0}'.format(write_gerber_value(self.x, settings.format, settings.zero_suppression))
if self.y is not None:
ret += 'Y{0}'.format(write_gerber_value(self.y, settings.format, settings.zero_suppression))
if self.i is not None:
ret += 'I{0}'.format(write_gerber_value(self.i, settings.format, settings.zero_suppression))
if self.j is not None:
ret += 'J{0}'.format(write_gerber_value(self.j, settings.format, settings.zero_suppression))
if self.op:
ret += self.op
return ret + '*'
for var in 'xyij':
val = getattr(self, var)
if val is not None:
ret += var.upper() + write_gerber_value(val, settings.number_format, settings.zero_suppression)
return ret + self.code + '*'
def offset(self, x_offset=0, y_offset=0):
def offset(self, x=0, y=0):
if self.x is not None:
self.x += x_offset
self.x += x
if self.y is not None:
self.y += y_offset
if self.i is not None:
self.i += x_offset
if self.j is not None:
self.j += y_offset
self.y += y
def __str__(self):
coord_str = ''
if self.function:
coord_str += 'Fn: %s ' % self.function
if self.x is not None:
coord_str += 'X: %g ' % self.x
if self.y is not None:
coord_str += 'Y: %g ' % self.y
if self.i is not None:
coord_str += 'I: %g ' % self.i
if self.j is not None:
coord_str += 'J: %g ' % self.j
if self.op:
if self.op == 'D01':
op = 'Lights On'
elif self.op == 'D02':
op = 'Lights Off'
elif self.op == 'D03':
op = 'Flash'
else:
op = self.op
coord_str += 'Op: %s' % op
if self.i is None:
return f'<{self.__name__.strip()} x={self.x} y={self.y}>'
else
return f'<{self.__name__.strip()} x={self.x} y={self.y} i={self.i} j={self.j]>'
return '<Coordinate Statement: %s>' % coord_str
def render_primitives(self, state):
if state.interpolation_mode == InterpolateStmt:
yield Line(state.current_point, (self.x, self.y))
@property
def only_function(self):
"""
Returns if the statement only set the function.
"""
# TODO I would like to refactor this so that the function is handled separately and then
# TODO this isn't required
return self.function != None and self.op == None and self.x == None and self.y == None and self.i == None and self.j == None
class InterpolateStmt(CoordStmt):
""" D01 interpolation operation """
class InterpolateStmt(Statement):
""" D01 Interpolation """
code = 'D01'
class MoveStmt(CoordStmt):
""" D02 move operation """
""" D02 Move """
code = 'D02'
class FlashStmt(CoordStmt):
""" D03 flash operation """
""" D03 Flash """
code = 'D03'
class InterpolationStmt(Statement):
class InterpolationModeStmt(Statement):
""" G01 / G02 / G03 interpolation mode statement """
def to_gerber(self, **_kwargs):
return self.code + '*'
@ -484,34 +345,34 @@ class InterpolationStmt(Statement):
def __str__(self):
return f'<{self.__doc__.strip()}>'
class LinearModeStmt(InterpolationStmt):
class LinearModeStmt(InterpolationModeStmt):
""" G01 linear interpolation mode statement """
code = 'G01'
class CircularCWModeStmt(InterpolationStmt):
class CircularCWModeStmt(InterpolationModeStmt):
""" G02 circular interpolation mode statement """
code = 'G02'
class CircularCCWModeStmt(InterpolationStmt):
class CircularCCWModeStmt(InterpolationModeStmt):
""" G03 circular interpolation mode statement """
code = 'G03'
class SingleQuadrantModeStmt(InterpolationStmt):
class SingleQuadrantModeStmt(InterpolationModeStmt):
""" G75 single-quadrant arc interpolation mode statement """
code = 'G75'
class MultiQuadrantModeStmt(InterpolationStmt):
""" G74 multi-quadrant arc interpolation mode statement """
code = 'G74'
class RegionStartStatement(InterpolationStmt):
class RegionStartStatement(InterpolationModeStmt):
""" G36 Region Mode Start Statement. """
code = 'G36'
class RegionEndStatement(InterpolationStmt):
class RegionEndStatement(InterpolationModeStmt):
""" G37 Region Mode End Statement. """
code = 'G37'
class RegionGroup:
def __init__(self):
self.outline = []
class ApertureStmt(Statement):
def __init__(self, d):
self.d = int(d)

View file

@ -20,7 +20,6 @@ import os
from .exceptions import ParseError
from .layers import PCBLayer, sort_layers, layer_signatures
from .common import read as gerber_read
from .utils import listdir
class PCB(object):
@ -36,7 +35,7 @@ class PCB(object):
raise TypeError('{} is not a directory.'.format(directory))
# Load gerber files
for filename in listdir(directory, True, True):
for filename in os.listdir(directory):
try:
camfile = gerber_read(os.path.join(directory, filename))
layer = PCBLayer.from_cam(camfile)

View file

@ -24,239 +24,44 @@ from .utils import rotate_point, nearly_equal
class Primitive(object):
""" Base class for all Cam file primitives
Parameters
---------
level_polarity : string
Polarity of the parameter. May be 'dark' or 'clear'. Dark indicates
a "positive" primitive, i.e. indicating where coppper should remain,
and clear indicates a negative primitive, such as where copper should
be removed. clear primitives are often used to create cutouts in region
pours.
rotation : float
Rotation of a primitive about its origin in degrees. Positive rotation
is counter-clockwise as viewed from the board top.
units : string
Units in which primitive was defined. 'inch' or 'metric'
net_name : string
Name of the electrical net the primitive belongs to
"""
def __init__(self, level_polarity='dark', rotation=0, units=None, net_name=None):
self.level_polarity = level_polarity
self.net_name = net_name
self._to_convert = list()
self._memoized = list()
self._units = units
self._rotation = rotation
self._cos_theta = math.cos(math.radians(rotation))
self._sin_theta = math.sin(math.radians(rotation))
self._bounding_box = None
self._vertices = None
self._segments = None
@property
def flashed(self):
'''Is this a flashed primitive'''
raise NotImplementedError('Is flashed must be '
'implemented in subclass')
class Primitive:
def __init__(self, polarity_dark=True, rotation=0, **meta):
self.polarity_dark = polarity_dark
self.meta = meta
self.rotation = rotation
def __eq__(self, other):
return self.__dict__ == other.__dict__
@property
def units(self):
return self._units
@units.setter
def units(self, value):
self._changed()
self._units = value
@property
def rotation(self):
return self._rotation
@rotation.setter
def rotation(self, value):
self._changed()
self._rotation = value
self._cos_theta = math.cos(math.radians(value))
self._sin_theta = math.sin(math.radians(value))
@property
def vertices(self):
def aperture(self):
return None
@property
def segments(self):
if self._segments is None:
if self.vertices is not None and len(self.vertices):
self._segments = [segment for segment in
combinations(self.vertices, 2)]
return self._segments
@property
def bounding_box(self):
""" Calculate axis-aligned bounding box
will be helpful for sweep & prune during DRC clearance checks.
Return ((min x, max x), (min y, max y))
"""
raise NotImplementedError('Bounding box calculation must be '
'implemented in subclass')
@property
def bounding_box_no_aperture(self):
""" Calculate bouxing box without considering the aperture
for most objects, this is the same as the bounding_box, but is different for
Lines and Arcs (which are not flashed)
Return ((min x, min y), (max x, max y))
"""
return self.bounding_box
def to_inch(self):
""" Convert primitive units to inches.
"""
if self.units == 'metric':
self.units = 'inch'
for attr, value in [(attr, getattr(self, attr))
for attr in self._to_convert]:
if hasattr(value, 'to_inch'):
value.to_inch()
else:
try:
if len(value) > 1:
if hasattr(value[0], 'to_inch'):
for v in value:
v.to_inch()
elif isinstance(value[0], tuple):
setattr(self, attr,
[tuple(map(inch, point))
for point in value])
else:
setattr(self, attr, tuple(map(inch, value)))
except:
if value is not None:
setattr(self, attr, inch(value))
def to_metric(self):
""" Convert primitive units to metric.
"""
if self.units == 'inch':
self.units = 'metric'
for attr, value in [(attr, getattr(self, attr)) for attr in self._to_convert]:
if hasattr(value, 'to_metric'):
value.to_metric()
else:
try:
if len(value) > 1:
if hasattr(value[0], 'to_metric'):
for v in value:
v.to_metric()
elif isinstance(value[0], tuple):
setattr(self, attr,
[tuple(map(metric, point))
for point in value])
else:
setattr(self, attr, tuple(map(metric, value)))
except:
if value is not None:
setattr(self, attr, metric(value))
def offset(self, x_offset=0, y_offset=0):
""" Move the primitive by the specified x and y offset amount.
values are specified in the primitive's native units
"""
if hasattr(self, 'position'):
self._changed()
self.position = tuple([coord + offset for coord, offset
in zip(self.position,
(x_offset, y_offset))])
def to_statement(self):
pass
def _changed(self):
""" Clear memoized properties.
Forces a recalculation next time any memoized propery is queried.
This must be called from a subclass every time a parameter that affects
a memoized property is changed. The easiest way to do this is to call
_changed() from property.setter methods.
"""
self._bounding_box = None
self._vertices = None
self._segments = None
for attr in self._memoized:
setattr(self, attr, None)
class Line(Primitive):
"""
"""
def __init__(self, start, end, aperture, level_polarity=None, **kwargs):
super(Line, self).__init__(**kwargs)
self.level_polarity = level_polarity
self._start = start
self._end = end
def __init__(self, start, end, aperture, polarity_dark=True, rotation=0, **meta):
super().__init__(polarity_dark, rotation, **meta)
self.start = start
self.end = end
self.aperture = aperture
self._to_convert = ['start', 'end', 'aperture']
@property
def flashed(self):
return False
@property
def start(self):
return self._start
@start.setter
def start(self, value):
self._changed()
self._start = value
@property
def end(self):
return self._end
@end.setter
def end(self, value):
self._changed()
self._end = value
@property
def angle(self):
delta_x, delta_y = tuple(
[end - start for end, start in zip(self.end, self.start)])
angle = math.atan2(delta_y, delta_x)
return angle
delta_x, delta_y = tuple(end - start for end, start in zip(self.end, self.start))
return math.atan2(delta_y, delta_x)
@property
def bounding_box(self):
if self._bounding_box is None:
if isinstance(self.aperture, Circle):
width_2 = self.aperture.radius
height_2 = width_2
else:
width_2 = self.aperture.width / 2.
height_2 = self.aperture.height / 2.
min_x = min(self.start[0], self.end[0]) - width_2
max_x = max(self.start[0], self.end[0]) + width_2
min_y = min(self.start[1], self.end[1]) - height_2
max_y = max(self.start[1], self.end[1]) + height_2
self._bounding_box = ((min_x, min_y), (max_x, max_y))
return self._bounding_box
if isinstance(self.aperture, Circle):
width_2 = self.aperture.radius
height_2 = width_2
else:
width_2 = self.aperture.width / 2.
height_2 = self.aperture.height / 2.
min_x = min(self.start[0], self.end[0]) - width_2
max_x = max(self.start[0], self.end[0]) + width_2
min_y = min(self.start[1], self.end[1]) - height_2
max_y = max(self.start[1], self.end[1]) + height_2
return (min_x, min_y), (max_x, max_y)
@property
def bounding_box_no_aperture(self):
@ -320,11 +125,7 @@ class Line(Primitive):
return str(self)
class Arc(Primitive):
"""
"""
def __init__(self, start, end, center, direction, aperture, quadrant_mode,
level_polarity=None, **kwargs):
def __init__(self, start, end, center, direction, aperture, level_polarity=None, **kwargs):
super(Arc, self).__init__(**kwargs)
self.level_polarity = level_polarity
self._start = start
@ -332,7 +133,6 @@ class Arc(Primitive):
self._center = center
self.direction = direction
self.aperture = aperture
self._quadrant_mode = quadrant_mode
self._to_convert = ['start', 'end', 'center', 'aperture']
@property
@ -366,15 +166,6 @@ class Arc(Primitive):
self._changed()
self._center = value
@property
def quadrant_mode(self):
return self._quadrant_mode
@quadrant_mode.setter
def quadrant_mode(self, quadrant_mode):
self._changed()
self._quadrant_mode = quadrant_mode
@property
def radius(self):
dy, dx = tuple([start - center for start, center
@ -411,39 +202,6 @@ class Arc(Primitive):
theta0 = (self.start_angle + two_pi) % two_pi
theta1 = (self.end_angle + two_pi) % two_pi
points = [self.start, self.end]
if self.quadrant_mode == 'multi-quadrant':
if self.direction == 'counterclockwise':
# Passes through 0 degrees
if theta0 >= theta1:
points.append((self.center[0] + self.radius, self.center[1]))
# Passes through 90 degrees
if (((theta0 <= math.pi / 2.) and ((theta1 >= math.pi / 2.) or (theta1 <= theta0)))
or ((theta1 > math.pi / 2.) and (theta1 <= theta0))):
points.append((self.center[0], self.center[1] + self.radius))
# Passes through 180 degrees
if ((theta0 <= math.pi and (theta1 >= math.pi or theta1 <= theta0))
or ((theta1 > math.pi) and (theta1 <= theta0))):
points.append((self.center[0] - self.radius, self.center[1]))
# Passes through 270 degrees
if (theta0 <= math.pi * 1.5 and (theta1 >= math.pi * 1.5 or theta1 <= theta0)
or ((theta1 > math.pi * 1.5) and (theta1 <= theta0))):
points.append((self.center[0], self.center[1] - self.radius))
else:
# Passes through 0 degrees
if theta1 >= theta0:
points.append((self.center[0] + self.radius, self.center[1]))
# Passes through 90 degrees
if (((theta1 <= math.pi / 2.) and (theta0 >= math.pi / 2. or theta0 <= theta1))
or ((theta0 > math.pi / 2.) and (theta0 <= theta1))):
points.append((self.center[0], self.center[1] + self.radius))
# Passes through 180 degrees
if (((theta1 <= math.pi) and (theta0 >= math.pi or theta0 <= theta1))
or ((theta0 > math.pi) and (theta0 <= theta1))):
points.append((self.center[0] - self.radius, self.center[1]))
# Passes through 270 degrees
if (((theta1 <= math.pi * 1.5) and (theta0 >= math.pi * 1.5 or theta0 <= theta1))
or ((theta0 > math.pi * 1.5) and (theta0 <= theta1))):
points.append((self.center[0], self.center[1] - self.radius))
x, y = zip(*points)
if hasattr(self.aperture, 'radius'):
min_x = min(x) - self.aperture.radius
@ -466,43 +224,6 @@ class Arc(Primitive):
theta0 = (self.start_angle + two_pi) % two_pi
theta1 = (self.end_angle + two_pi) % two_pi
points = [self.start, self.end]
if self.quadrant_mode == 'multi-quadrant':
if self.direction == 'counterclockwise':
# Passes through 0 degrees
if theta0 >= theta1:
points.append((self.center[0] + self.radius, self.center[1]))
# Passes through 90 degrees
if (((theta0 <= math.pi / 2.) and (
(theta1 >= math.pi / 2.) or (theta1 <= theta0)))
or ((theta1 > math.pi / 2.) and (theta1 <= theta0))):
points.append((self.center[0], self.center[1] + self.radius))
# Passes through 180 degrees
if ((theta0 <= math.pi and (theta1 >= math.pi or theta1 <= theta0))
or ((theta1 > math.pi) and (theta1 <= theta0))):
points.append((self.center[0] - self.radius, self.center[1]))
# Passes through 270 degrees
if (theta0 <= math.pi * 1.5 and (
theta1 >= math.pi * 1.5 or theta1 <= theta0)
or ((theta1 > math.pi * 1.5) and (theta1 <= theta0))):
points.append((self.center[0], self.center[1] - self.radius))
else:
# Passes through 0 degrees
if theta1 >= theta0:
points.append((self.center[0] + self.radius, self.center[1]))
# Passes through 90 degrees
if (((theta1 <= math.pi / 2.) and (
theta0 >= math.pi / 2. or theta0 <= theta1))
or ((theta0 > math.pi / 2.) and (theta0 <= theta1))):
points.append((self.center[0], self.center[1] + self.radius))
# Passes through 180 degrees
if (((theta1 <= math.pi) and (theta0 >= math.pi or theta0 <= theta1))
or ((theta0 > math.pi) and (theta0 <= theta1))):
points.append((self.center[0] - self.radius, self.center[1]))
# Passes through 270 degrees
if (((theta1 <= math.pi * 1.5) and (
theta0 >= math.pi * 1.5 or theta0 <= theta1))
or ((theta0 > math.pi * 1.5) and (theta0 <= theta1))):
points.append((self.center[0], self.center[1] - self.radius))
x, y = zip(*points)
min_x = min(x)
@ -522,8 +243,7 @@ class Circle(Primitive):
"""
"""
def __init__(self, position, diameter, hole_diameter=None,
hole_width=0, hole_height=0, **kwargs):
def __init__(self, position, diameter, polarity_dark=True):
super(Circle, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
@ -1087,7 +807,7 @@ class Region(Primitive):
@property
def bounding_box(self):
if self._bounding_box is None:
xlims, ylims = zip(*[p.bounding_box_no_aperture for p in self.primitives])
xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
minx, maxx = zip(*xlims)
miny, maxy = zip(*ylims)
min_x = min(minx)

View file

@ -145,22 +145,34 @@ class GerberFile(CamFile):
return ((min_x, max_x), (min_y, max_y))
# TODO: re-add settings arg
def write(self, filename):
def generate_statements(self):
self.settings.notation = 'absolute'
self.settings.zeros = 'trailing'
self.settings.format = self.format
self.units = self.units
with open(filename, 'w') as f:
print(UnitStmt().to_gerber(self.settings), file=f)
print(FormatSpecStmt().to_gerber(self.settings), file=f)
print(ImagePolarityStmt().to_gerber(self.settings), file=f)
yield UnitStmt()
yield FormatSpecStmt()
yield ImagePolarityStmt()
yield SingleQuadrantModeStmt()
for thing in chain(self.aperture_macros.values(), self.aperture_defs, self.main_statements):
print(thing.to_gerber(self.settings), file=f)
yield from self.aperture_macros.values()
yield from self.aperture_defs
yield from self.main_statements
print('M02*', file=f)
yield EofStmt()
def __str__(self):
return '\n'.join(self.generate_statements())
def save(self, filename):
with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec.
for stmt in self.generate_statements():
print(stmt.to_gerber(self.settings), file=f)
def render_primitives(self):
for stmt in self.main_statements:
yield from stmt.render_primitives()
def to_inch(self):
if self.units == 'metric':
@ -245,6 +257,45 @@ class GerberFile(CamFile):
statement.shape = polygon
@dataclass
class GraphicsState:
polarity_dark : bool = True
point : tuple = None
aperture : ApertureDefStmt = None
interpolation_mode : InterpolationModeStmt = None
multi_quadrant_mode : bool = None # used only for syntax checking
def flash(self, x, y):
self.point = (x, y)
return Aperture(self.aperture, x, y)
def interpolate(self, x, y, i=None, j=None):
if self.interpolation_mode == LinearModeStmt:
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(x, y)
else:
return self._create_arc(x, y, i, j)
def _create_line(self, x, y):
old_point, self.point = self.point, (x, y)
return Line(old_point, self.point, self.aperture, self.polarity_dark)
def _create_arc(self, x, y, i, j):
if self.multi_quadrant_mode is None:
warnings.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\
'This can cause problems with older gerber interpreters.', SyntaxWarning)
elif self.multi_quadrant_mode:
raise SyntaxError('Circular arc interpolation in multi-quadrant mode (G74) is not implemented.')
old_point, self.point = self.point, (x, y)
direction = 'ccw' if self.interpolation_mode == CircularCCWModeStmt else 'cw'
return Arc(old_point, self.point, (i, j), direction, self.aperture, self.polarity_dark):
class GerberParser:
NUMBER = r"[\+-]?\d+"
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
@ -260,6 +311,7 @@ class GerberParser:
'comment': r"G0?4(?P<comment>[^*]*)(\*)?",
'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]*",
'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>.*)",
@ -271,8 +323,8 @@ class GerberParser:
'scale_factor': fr"SF(A(?P<a>{DECIMAL}))?(B(?P<b>{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>[^%]*)",
'region_mode': r'(?P<mode>G3[67])\*',
'quadrant_mode': r'(?P<mode>G7[45])\*',
'region_start': r'G36\*',
'region_end': r'G37\*',
'old_unit':r'(?P<mode>G7[01])\*',
'old_notation': r'(?P<mode>G9[01])\*',
'eof': r"M0?[02]\*",
@ -287,11 +339,13 @@ class GerberParser:
self.include_dir = include_dir
self.include_stack = []
self.settings = FileSettings()
self.current_region = None
self.graphics_state = GraphicsState()
self.statements = []
self.primitives = []
self.apertures = {}
self.macros = {}
self.current_region = None
self.x = 0
self.y = 0
self.last_operation = None
@ -302,13 +356,15 @@ class GerberParser:
self.image_polarity = 'positive'
self.level_polarity = 'dark'
self.region_mode = 'off'
self.quadrant_mode = 'multi-quadrant'
self.step_and_repeat = (1, 1, 0, 0)
def parse(self, data):
for stmt in self._parse(data):
if self.current_region is None:
self.statements.append(stmt)
else:
self.current_region.append(stmt)
self.evaluate(stmt)
self.statements.append(stmt)
# Initialize statement units
for stmt in self.statements:
@ -370,21 +426,26 @@ class GerberParser:
def _parse_interpolation_mode(self, match):
if match['code'] == 'G01':
self.graphics_state.interpolation_mode = LinearModeStmt
yield LinearModeStmt()
elif match['code'] == 'G02':
self.graphics_state.interpolation_mode = CircularCWModeStmt
yield CircularCWModeStmt()
elif match['code'] == 'G03':
self.graphics_state.interpolation_mode = CircularCCWModeStmt
yield CircularCCWModeStmt()
elif match['code'] == 'G74':
yield MultiQuadrantModeStmt()
self.graphics_state.multi_quadrant_mode = True # used only for syntax checking
elif match['code'] == 'G75':
yield SingleQuadrantModeStmt()
self.graphics_state.multi_quadrant_mode = False
# we always emit a G75 at the beginning of the file.
def _parse_coord(self, match):
x = parse_gerber_value(match.get('x'), self.settings)
y = parse_gerber_value(match.get('y'), self.settings)
i = parse_gerber_value(match.get('i'), self.settings)
j = parse_gerber_value(match.get('j'), self.settings)
x = parse_gerber_value(match['x'], self.settings)
y = parse_gerber_value(match['y'], self.settings)
i = parse_gerber_value(match['i'], self.settings)
j = parse_gerber_value(match['j'], self.settings)
if not (op := match['operation']):
if self.last_operation == 'D01':
warnings.warn('Coordinate statement without explicit operation code. This is forbidden by spec.',
@ -395,22 +456,28 @@ class GerberParser:
'mode and the last operation statement was not D01.')
if op in ('D1', 'D01'):
yield InterpolateStmt(x, y, i, j)
yield self.graphics_state.interpolate(x, y, i, j)
if i is not None or j is not None:
raise SyntaxError("i/j coordinates given for D02/D03 operation (which doesn't take i/j)")
if op in ('D2', 'D02'):
yield MoveStmt(x, y, i, j)
else: # D03
yield FlashStmt(x, y, i, j)
else:
if i is not None or j is not None:
raise SyntaxError("i/j coordinates given for D02/D03 operation (which doesn't take i/j)")
if op in ('D2', 'D02'):
self.graphics_state.point = (x, y)
else: # D03
yield self.graphics_state.flash(x, y)
def _parse_aperture(self, match):
number = int(match['number'])
if number < 10:
raise SyntaxError(f'Invalid aperture number {number}: Aperture number must be >= 10.')
yield ApertureStmt(number)
if number not in self.apertures:
raise SyntaxError(f'Tried to access undefined aperture {number}')
self.graphics_state.aperture = self.apertures[number]
def _parse_format_spec(self, match):
# This is a common problem in Eagle files, so just suppress it
@ -421,7 +488,7 @@ class GerberParser:
raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})')
self.settings.number_format = int(match['x'][0]), int(match['x'][1])
yield FormatSpecStmt()
yield from () # We always force a format spec statement at the beginning of the file
def _parse_unit_mode(self, match):
if match['unit'] == 'MM':
@ -429,16 +496,17 @@ class GerberParser:
else:
self.settings.units = 'inch'
yield MOParamStmt()
yield from () # We always force a unit mode statement at the beginning of the file
def _parse_load_polarity(self, match):
yield LoadPolarityStmt(dark=(match['polarity'] == 'D'))
yield LoadPolarityStmt(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
yield OffsetStmt(a, b)
self.settings.offset = a, b
yield from () # Handled by coordinate normalization
def _parse_include_file(self, match):
if self.include_dir is None:
@ -470,25 +538,25 @@ class GerberParser:
warnings.warn('Deprecated AS (axis selection) statement found. This deprecated since rev. I1 (Dec 2012).',
DeprecationWarning)
self.settings.output_axes = match['axes']
yield AxisSelectionStmt()
yield from () # Handled by coordinate normalization
def _parse_image_polarity(self, match):
warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).',
DeprecationWarning)
self.settings.image_polarity = match['polarity']
yield ImagePolarityStmt()
yield from () # We always emit this in the header
def _parse_image_rotation(self, match):
warnings.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).',
DeprecationWarning)
self.settings.image_rotation = int(match['rotation'])
yield ImageRotationStmt()
yield from () # Handled by coordinate normalization
def _parse_mirror_image(self, match):
warnings.warn('Deprecated MI (mirror image) statement found. This deprecated since rev. I1 (Dec 2012).',
DeprecationWarning)
self.settings.mirror = bool(int(match['a'] or '0')), bool(int(match['b'] or '1'))
yield MirrorImageStmt()
yield from () # Handled by coordinate normalization
def _parse_scale_factor(self, match):
warnings.warn('Deprecated SF (scale factor) statement found. This deprecated since rev. I1 (Dec 2012).',
@ -496,26 +564,20 @@ class GerberParser:
a = float(match['a']) if match['a'] else 1.0
b = float(match['b']) if match['b'] else 1.0
self.settings.scale_factor = a, b
yield ScaleFactorStmt()
yield from () # Handled by coordinate normalization
def _parse_comment(self, match):
yield CommentStmt(match["comment"])
def _parse_region_mode(self, match):
yield RegionStartStatement() if match['mode'] == 'G36' else RegionEndStatement()
def _parse_region_start(self, _match):
current_region = RegionGroup()
elif param["param"] == "AM":
yield AMParamStmt.from_dict(param, units=self.settings.units)
elif param["param"] == "AD":
yield ADParamStmt.from_dict(param)
def _parse_quadrant_mode(self, match):
if match['mode'] == 'G74':
warnings.warn('Deprecated G74 single quadrant mode statement found. This deprecated since 2021.',
DeprecationWarning)
yield SingleQuadrantModeStmt()
else:
yield MultiQuadrantModeStmt()
def _parse_region_end(self, _match):
if self.current_region is None:
raise SyntaxError('Region end command (G37) outside of region')
yield self.current_region
self.current_region = None
def _parse_old_unit(self, match):
self.settings.units = 'inch' if match['mode'] == 'G70' else 'mm'
@ -531,12 +593,33 @@ class GerberParser:
DeprecationWarning)
yield CommentStmt(f'Replaced deprecated {match["mode"]} notation mode statement with FS statement')
def _parse_eof(self, match):
def _parse_eof(self, _match):
yield EofStmt()
def _parse_ignored(self, match):
yield CommentStmt(f'Ignoring {match{"stmt"]} statement.')
def _parse_aperture_definition(self, match):
modifiers = [ float(mod) for mod in match['modifiers'].split(',') ]
if match['shape'] == 'C':
aperture = ApertureCircle(*modifiers)
elif match['shape'] == 'R'
aperture = ApertureRectangle(*modifiers)
elif shape == 'O':
aperture = ApertureObround(*modifiers)
elif shape == 'P':
aperture = AperturePolygon(*modifiers)
else:
aperture = self.macros[shape].build(modifiers)
self.apertures[d] = aperture
def evaluate(self, stmt):
""" Evaluate Gerber statement and update image accordingly.
@ -567,83 +650,6 @@ class GerberParser:
else:
raise Exception("Invalid statement to evaluate")
def _define_aperture(self, d, shape, modifiers):
aperture = None
if shape == 'C':
diameter = modifiers[0][0]
hole_diameter = 0
rectangular_hole = (0, 0)
if len(modifiers[0]) == 2:
hole_diameter = modifiers[0][1]
elif len(modifiers[0]) == 3:
rectangular_hole = modifiers[0][1:3]
aperture = Circle(position=None, diameter=diameter,
hole_diameter=hole_diameter,
hole_width=rectangular_hole[0],
hole_height=rectangular_hole[1],
units=self.settings.units)
elif shape == 'R':
width = modifiers[0][0]
height = modifiers[0][1]
hole_diameter = 0
rectangular_hole = (0, 0)
if len(modifiers[0]) == 3:
hole_diameter = modifiers[0][2]
elif len(modifiers[0]) == 4:
rectangular_hole = modifiers[0][2:4]
aperture = Rectangle(position=None, width=width, height=height,
hole_diameter=hole_diameter,
hole_width=rectangular_hole[0],
hole_height=rectangular_hole[1],
units=self.settings.units)
elif shape == 'O':
width = modifiers[0][0]
height = modifiers[0][1]
hole_diameter = 0
rectangular_hole = (0, 0)
if len(modifiers[0]) == 3:
hole_diameter = modifiers[0][2]
elif len(modifiers[0]) == 4:
rectangular_hole = modifiers[0][2:4]
aperture = Obround(position=None, width=width, height=height,
hole_diameter=hole_diameter,
hole_width=rectangular_hole[0],
hole_height=rectangular_hole[1],
units=self.settings.units)
elif shape == 'P':
outer_diameter = modifiers[0][0]
number_vertices = int(modifiers[0][1])
if len(modifiers[0]) > 2:
rotation = modifiers[0][2]
else:
rotation = 0
hole_diameter = 0
rectangular_hole = (0, 0)
if len(modifiers[0]) == 4:
hole_diameter = modifiers[0][3]
elif len(modifiers[0]) >= 5:
rectangular_hole = modifiers[0][3:5]
aperture = Polygon(position=None, sides=number_vertices,
radius=outer_diameter/2.0,
hole_diameter=hole_diameter,
hole_width=rectangular_hole[0],
hole_height=rectangular_hole[1],
rotation=rotation)
else:
aperture = self.macros[shape].build(modifiers)
aperture.units = self.settings.units
self.apertures[d] = aperture
def _evaluate_mode(self, stmt):
if stmt.type == 'RegionMode':
if self.region_mode == 'on' and stmt.mode == 'off':
@ -658,14 +664,6 @@ class GerberParser:
self.quadrant_mode = stmt.mode
def _evaluate_param(self, stmt):
if stmt.param == "FS":
self.settings.zero_suppression = stmt.zero_suppression
self.settings.format = stmt.format
self.settings.notation = stmt.notation
elif stmt.param == "MO":
self.settings.units = stmt.mode
elif stmt.param == "IP":
self.image_polarity = stmt.ip
elif stmt.param == "LP":
self.level_polarity = stmt.lp
elif stmt.param == "AM":

View file

@ -61,7 +61,7 @@ def parse_gerber_value(value, settings):
"""
if value is None:
if not value:
return None
# Handle excellon edge case with explicit decimal. "That was easy!"
@ -317,146 +317,3 @@ def sq_distance(point1, point2):
return diff1 * diff1 + diff2 * diff2
def listdir(directory, ignore_hidden=True, ignore_os=True):
""" List files in given directory.
Differs from os.listdir() in that hidden and OS-generated files are ignored
by default.
Parameters
----------
directory : str
path to the directory for which to list files.
ignore_hidden : bool
If True, ignore files beginning with a leading '.'
ignore_os : bool
If True, ignore OS-generated files, e.g. Thumbs.db
Returns
-------
files : list
list of files in specified directory
"""
os_files = ('.DS_Store', 'Thumbs.db', 'ethumbs.db')
files = os.listdir(directory)
if ignore_hidden:
files = [f for f in files if not f.startswith('.')]
if ignore_os:
files = [f for f in files if not f in os_files]
return files
def ConvexHull_qh(points):
#a hull must be a planar shape with nonzero area, so there must be at least 3 points
if(len(points)<3):
raise Exception("not a planar shape")
#find points with lowest and highest X coordinates
minxp=0;
maxxp=0;
for i in range(len(points)):
if(points[i][0]<points[minxp][0]):
minxp=i;
if(points[i][0]>points[maxxp][0]):
maxxp=i;
if minxp==maxxp:
#all points are collinear
raise Exception("not a planar shape")
#separate points into those above and those below the minxp-maxxp line
lpoints=[]
rpoints=[]
#to detemine if point X is on the left or right of dividing line A-B, compare slope of A-B to slope of A-X
#slope is (By-Ay)/(Bx-Ax)
a=points[minxp]
b=points[maxxp]
slopeab=atan2(b[1]-a[1],b[0]-a[0])
for i in range(len(points)):
p=points[i]
if i == minxp or i == maxxp:
continue
slopep=atan2(p[1]-a[1],p[0]-a[0])
sdiff=slopep-slopeab
if(sdiff<pi):sdiff+=2*pi
if(sdiff>pi):sdiff-=2*pi
if(sdiff>0):
lpoints+=[i]
if(sdiff<0):
rpoints+=[i]
hull=[minxp]+_findhull(rpoints, maxxp, minxp, points)+[maxxp]+_findhull(lpoints, minxp, maxxp, points)
hullo=_optimize(hull,points)
return hullo
def _optimize(hull,points):
#find triplets that are collinear and remove middle point
toremove=[]
newhull=hull[:]
l=len(hull)
for i in range(l):
p1=hull[i]
p2=hull[(i+1)%l]
p3=hull[(i+2)%l]
#(p1.y-p2.y)*(p1.x-p3.x)==(p1.y-p3.y)*(p1.x-p2.x)
if (points[p1][1]-points[p2][1])*(points[p1][0]-points[p3][0])==(points[p1][1]-points[p3][1])*(points[p1][0]-points[p2][0]):
toremove+=[p2]
for i in toremove:
newhull.remove(i)
return newhull
def _distance(a, b, x):
#find the distance between point x and line a-b
return abs((b[1]-a[1])*x[0]-(b[0]-a[0])*x[1]+b[0]*a[1]-a[0]*b[1])/sqrt((b[1]-a[1])**2 + (b[0]-a[0])**2 );
def _findhull(idxp, a_i, b_i, points):
#if no points in input, return no points in output
if(len(idxp)==0):
return [];
#find point c furthest away from line a-b
farpoint=-1
fdist=-1.0;
for i in idxp:
d=_distance(points[a_i], points[b_i], points[i])
if(d>fdist):
fdist=d;
farpoint=i
if(fdist<=0):
#none of the points have a positive distance from line, bad things have happened
return []
#separate points into those inside triangle, those outside triangle left of far point, and those outside triangle right of far point
a=points[a_i]
b=points[b_i]
c=points[farpoint]
slopeac=atan2(c[1]-a[1],c[0]-a[0])
slopecb=atan2(b[1]-c[1],b[0]-c[0])
lpoints=[]
rpoints=[]
for i in idxp:
if i==farpoint:
#ignore triangle vertex
continue
x=points[i]
#if point x is left of line a-c it's in left set
slopeax=atan2(x[1]-a[1],x[0]-a[0])
if slopeac==slopeax:
continue
sdiff=slopeac-slopeax
if(sdiff<-pi):sdiff+=2*pi
if(sdiff>pi):sdiff-=2*pi
if(sdiff<0):
lpoints+=[i]
else:
#if point x is right of line b-c it's in right set, otherwise it's inside triangle and can be ignored
slopecx=atan2(x[1]-c[1],x[0]-c[0])
if slopecx==slopecb:
continue
sdiff=slopecx-slopecb
if(sdiff<-pi):sdiff+=2*pi
if(sdiff>pi):sdiff-=2*pi
if(sdiff>0):
rpoints+=[i]
#the hull segment between points a and b consists of the hull segment between a and c, the point c, and the hull segment between c and b
ret=_findhull(rpoints, farpoint, b_i, points)+[farpoint]+_findhull(lpoints, a_i, farpoint, points)
return ret
def convex_hull(points):
vertices = ConvexHull_qh(points)
return [points[idx] for idx in vertices]