WIP
This commit is contained in:
parent
25dd65fac0
commit
63e1eae8d8
11 changed files with 834 additions and 1249 deletions
|
|
@ -22,6 +22,5 @@ gerbonara provides utilities for working with Gerber (RS-274X) and Excellon
|
|||
files in python.
|
||||
"""
|
||||
|
||||
from .common import read, loads
|
||||
from .layers import load_layer, load_layer_data
|
||||
from .pcb import PCB
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import re
|
|||
import ast
|
||||
|
||||
|
||||
def expr(obj):
|
||||
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
|
||||
|
||||
|
||||
class Expression(object):
|
||||
@property
|
||||
def value(self):
|
||||
|
|
@ -28,6 +32,35 @@ class Expression(object):
|
|||
raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}')
|
||||
return expr.value
|
||||
|
||||
def __add__(self, other):
|
||||
return OperatorExpression(operator.add, self, expr(other)).optimized()
|
||||
|
||||
def __radd__(self, other):
|
||||
return expr(other) + self
|
||||
|
||||
def __sub__(self, other):
|
||||
return OperatorExpression(operator.sub, self, expr(other)).optimized()
|
||||
|
||||
def __rsub__(self, other):
|
||||
return expr(other) - self
|
||||
|
||||
def __mul__(self, other):
|
||||
return OperatorExpression(operator.mul, self, expr(other)).optimized()
|
||||
|
||||
def __rmul__(self, other):
|
||||
return expr(other) * self
|
||||
|
||||
def __truediv__(self, other):
|
||||
return OperatorExpression(operator.truediv, self, expr(other)).optimized()
|
||||
|
||||
def __rtruediv__(self, other):
|
||||
return expr(other) / self
|
||||
|
||||
def __neg__(self):
|
||||
return 0 - self
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
||||
class UnitExpression(Expression):
|
||||
def __init__(self, expr, unit):
|
||||
|
|
@ -50,10 +83,10 @@ class UnitExpression(Expression):
|
|||
return self._expr
|
||||
|
||||
elif unit == 'mm':
|
||||
return OperatorExpression.mul(self._expr, MILLIMETERS_PER_INCH)
|
||||
return self._expr * MILLIMETERS_PER_INCH
|
||||
|
||||
elif unit == 'inch':
|
||||
return OperatorExpression.div(self._expr, MILLIMETERS_PER_INCH)
|
||||
return self._expr / MILLIMETERS_PER_INCH)
|
||||
|
||||
else:
|
||||
raise ValueError('invalid unit, must be "inch" or "mm".')
|
||||
|
|
|
|||
|
|
@ -2,24 +2,37 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
|
||||
# Copyright 2022 Jan Götte <gerbonara@jaseg.de>
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
|
||||
from expression import Expression, UnitExpression, ConstantExpression, expr
|
||||
|
||||
from .. import graphic_primitivese as gp
|
||||
|
||||
|
||||
def point_distance(a, b):
|
||||
x1, y1 = a
|
||||
x2, y2 = b
|
||||
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
||||
|
||||
def deg_to_rad(a):
|
||||
return (a / 180) * math.pi
|
||||
|
||||
from dataclasses import dataclass, fields
|
||||
from expression import Expression, UnitExpression, ConstantExpression
|
||||
|
||||
class Primitive:
|
||||
def __init__(self, unit, args, is_abstract):
|
||||
def __init__(self, unit, args):
|
||||
self.unit = unit
|
||||
self.is_abstract = is_abstract
|
||||
|
||||
if len(args) > len(type(self).__annotations__):
|
||||
raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
|
||||
|
||||
for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()):
|
||||
if is_abstract:
|
||||
if fieldtype == UnitExpression:
|
||||
setattr(self, name, UnitExpression(arg, unit))
|
||||
else:
|
||||
setattr(self, name, arg)
|
||||
arg = expr(arg) # convert int/float to Expression object
|
||||
|
||||
if fieldtype == UnitExpression:
|
||||
setattr(self, name, UnitExpression(arg, unit))
|
||||
else:
|
||||
setattr(self, name, arg)
|
||||
|
||||
|
|
@ -28,8 +41,6 @@ class Primitive:
|
|||
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
if not self.is_abstract:
|
||||
raise TypeError(f"Something went wrong, tried to gerber'ize bound aperture macro primitive {self}")
|
||||
return self.code + ',' + ','.join(
|
||||
getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + '*'
|
||||
|
||||
|
|
@ -37,27 +48,42 @@ class Primitive:
|
|||
attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__)
|
||||
return f'<{type(self).__name__} {attrs}>'
|
||||
|
||||
def bind(self, variable_binding={}):
|
||||
if not self.is_abstract:
|
||||
raise TypeError('{type(self).__name__} object is already instantiated, cannot bind again.')
|
||||
# Return instance of the same class, but replace all attributes by their actual numeric values
|
||||
return type(self)(unit=self.unit, is_abstract=False, args=[
|
||||
getattr(self, name).calculate(variable_binding) for name in type(self).__annotations__
|
||||
])
|
||||
@contextlib.contextmanager
|
||||
class Calculator:
|
||||
def __init__(self, instance, variable_binding={}, unit=None):
|
||||
self.instance = instance
|
||||
self.variable_binding = variable_binding
|
||||
self.unit = unit
|
||||
|
||||
class CommentPrimitive(Primitive):
|
||||
code = 0
|
||||
comment : str
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
class CirclePrimitive(Primitive):
|
||||
def __exit__(self, _type, _value, _traceback):
|
||||
pass
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.instance, name).calculate(self.variable_binding, self.unit)
|
||||
|
||||
def __call__(self, expr):
|
||||
return expr.calculate(self.variable_binding, self.unit)
|
||||
|
||||
|
||||
class Circle(Primitive):
|
||||
code = 1
|
||||
exposure : Expression
|
||||
diameter : UnitExpression
|
||||
center_x : UnitExpression
|
||||
center_y : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
rotation : Expression = ConstantExpression(0.0)
|
||||
|
||||
class VectorLinePrimitive(Primitive):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
with self.Calculator(variable_binding, unit) as calc:
|
||||
x, y = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
return [ gp.Circle(x, y, calc.r, polarity_dark=bool(calc.exposure)) ]
|
||||
|
||||
class VectorLine(Primitive):
|
||||
code = 20
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
|
|
@ -67,40 +93,90 @@ class VectorLinePrimitive(Primitive):
|
|||
end_y : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
class CenterLinePrimitive(Primitive):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
with self.Calculator(variable_binding, unit) as calc:
|
||||
center_x = (calc.end_x + calc.start_x) / 2
|
||||
center_y = (calc.end_y + calc.start_y) / 2
|
||||
delta_x = calc.end_x - calc.start_x
|
||||
delta_y = calc.end_y - calc.start_y
|
||||
length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y))
|
||||
|
||||
center_x, center_y = center_x+offset[0], center_y+offset[1]
|
||||
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
|
||||
|
||||
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
|
||||
polarity_dark=bool(calc.exposure)) ]
|
||||
|
||||
|
||||
class CenterLine(Primitive):
|
||||
code = 21
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
height : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, variable_binding={}, unit=None):
|
||||
with self.Calculator(variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
w, h = calc.width, calc.height
|
||||
|
||||
class PolygonPrimitive(Primitive):
|
||||
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=bool(calc.exposure)) ]
|
||||
|
||||
|
||||
class Polygon(Primitive):
|
||||
code = 5
|
||||
exposure : Expression
|
||||
n_vertices : Expression
|
||||
center_x : UnitExpression
|
||||
center_y : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
diameter : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
with self.Calculator(variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
return [ gp.RegularPolygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
|
||||
polarity_dark=bool(calc.exposure)) ]
|
||||
|
||||
class ThermalPrimitive(Primitive):
|
||||
|
||||
class Thermal(Primitive):
|
||||
code = 7
|
||||
center_x : UnitExpression
|
||||
center_y : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
d_outer : UnitExpression
|
||||
d_inner : UnitExpression
|
||||
gap_w : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
with self.Calculator(variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
class OutlinePrimitive(Primitive):
|
||||
dark = bool(calc.exposure)
|
||||
|
||||
return [
|
||||
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
|
||||
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
|
||||
]
|
||||
|
||||
|
||||
class Outline(Primitive):
|
||||
code = 4
|
||||
|
||||
def __init__(self, unit, args, is_abstract):
|
||||
def __init__(self, unit, args):
|
||||
if len(args) < 11:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).')
|
||||
if len(args) > 5004:
|
||||
|
|
@ -108,42 +184,36 @@ class OutlinePrimitive(Primitive):
|
|||
|
||||
self.exposure = args[0]
|
||||
|
||||
if is_abstract:
|
||||
# length arg must not contain variabels (that would not make sense)
|
||||
length_arg = args[1].calculate()
|
||||
# length arg must not contain variables (that would not make sense)
|
||||
length_arg = args[1].calculate()
|
||||
|
||||
if length_arg != len(args)//2 - 2:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, given size does not match length of coordinate list({len(args)}).')
|
||||
|
||||
if len(args) % 1 != 1:
|
||||
self.rotation = args.pop()
|
||||
else:
|
||||
self.rotation = ConstantExpression(0.0)
|
||||
|
||||
if args[2] != args[-2] or args[3] != args[-1]:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
|
||||
|
||||
self.coords = [UnitExpression(arg, unit) for arg in args[1:]]
|
||||
if length_arg != len(args)//2 - 2:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, given size does not match length of coordinate list({len(args)}).')
|
||||
|
||||
if len(args) % 1 != 1:
|
||||
self.rotation = args.pop()
|
||||
else:
|
||||
if len(args) % 1 != 1:
|
||||
self.rotation = args.pop()
|
||||
else:
|
||||
self.rotation = 0
|
||||
self.rotation = ConstantExpression(0.0)
|
||||
|
||||
if args[2] != args[-2] or args[3] != args[-1]:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
|
||||
|
||||
self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[1::2], args[2::2])]
|
||||
|
||||
self.coords = args[1:]
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
if not self.is_abstract:
|
||||
raise TypeError(f"Something went wrong, tried to gerber'ize bound aperture macro primitive {self}")
|
||||
coords = ','.join(coord.to_gerber(unit) for coord in self.coords)
|
||||
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)//2-1},{coords},{self.rotation.to_gerber()}'
|
||||
|
||||
def bind(self, variable_binding={}):
|
||||
if not self.is_abstract:
|
||||
raise TypeError('{type(self).__name__} object is already instantiated, cannot bind again.')
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
with self.Calculator(variable_binding, unit) as calc:
|
||||
bound_coords = [ (calc(x)+offset[0], calc(y)+offset[1]) for x, y in self.coords ]
|
||||
bound_radii = [None] * len(bound_coords)
|
||||
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
bound_coords = [ rotate_point(*p, rotation, 0, 0) for p in bound_coords ]
|
||||
|
||||
return gp.ArcPoly(bound_coords, bound_radii, polarity_dark=calc.exposure)
|
||||
|
||||
return OutlinePrimitive(self.unit, is_abstract=False, args=[None, *self.coords, self.rotation])
|
||||
|
||||
class Comment:
|
||||
def __init__(self, comment):
|
||||
|
|
@ -154,13 +224,13 @@ class Comment:
|
|||
|
||||
PRIMITIVE_CLASSES = {
|
||||
**{cls.code: cls for cls in [
|
||||
CommentPrimitive,
|
||||
CirclePrimitive,
|
||||
VectorLinePrimitive,
|
||||
CenterLinePrimitive,
|
||||
OutlinePrimitive,
|
||||
PolygonPrimitive,
|
||||
ThermalPrimitive,
|
||||
Comment,
|
||||
Circle,
|
||||
VectorLine,
|
||||
CenterLine,
|
||||
Outline,
|
||||
Polygon,
|
||||
Thermal,
|
||||
]},
|
||||
# alternative codes
|
||||
2: VectorLinePrimitive,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from dataclasses import dataclass, replace
|
||||
from aperture_macros.parse import GenericMacros
|
||||
|
||||
from primitives import Primitive
|
||||
import graphic_primitives as gp
|
||||
|
||||
def _flash_hole(self, x, y):
|
||||
if self.hole_rect_h is not None:
|
||||
return self.primitives(x, y), Rectangle((x, y), (self.hole_dia, self.hole_rect_h), polarity_dark=False)
|
||||
return self.primitives(x, y), Rectangle((x, y), (self.hole_dia, self.hole_rect_h), rotation=self.rotation, polarity_dark=False)
|
||||
else:
|
||||
return self.primitives(x, y), Circle((x, y), self.hole_dia, polarity_dark=False)
|
||||
|
||||
|
|
@ -21,65 +23,185 @@ class Aperture:
|
|||
def hole_size(self):
|
||||
return (self.hole_dia, self.hole_rect_h)
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
return dataclasses.astuple(self)
|
||||
|
||||
def flash(self, x, y):
|
||||
return self.primitives(x, y)
|
||||
|
||||
@parameter
|
||||
def equivalent_width(self):
|
||||
raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.')
|
||||
|
||||
@dataclass
|
||||
class ApertureCircle(Aperture):
|
||||
def to_gerber(self):
|
||||
# Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use,
|
||||
# we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at
|
||||
# export time during to_gerber, this parameter is evaluated.
|
||||
actual_inst = self._rotated()
|
||||
params = 'X'.join(f'{par:.4}' for par in actual_inst.params)
|
||||
return f'{actual_inst.aperture.gerber_shape_code},{params}'
|
||||
|
||||
def __eq__(self, other):
|
||||
return hasattr(other, to_gerber) and self.to_gerber() == other.to_gerber()
|
||||
|
||||
def _rotate_hole_90(self):
|
||||
if self.hole_rect_h is None:
|
||||
return {'hole_dia': self.hole_dia, 'hole_rect_h': None}
|
||||
else:
|
||||
return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CircleAperture(Aperture):
|
||||
gerber_shape_code = 'C'
|
||||
human_readable_shape = 'circle'
|
||||
diameter : float
|
||||
hole_dia : float = 0
|
||||
hole_rect_h : float = None
|
||||
rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber
|
||||
|
||||
def primitives(self, x, y):
|
||||
return Circle((x, y), self.diameter, polarity_dark=True),
|
||||
def primitives(self, x, y, rotation):
|
||||
return [ gp.Circle(x, y, self.diameter/2) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<circle aperture d={self.diameter:.3}>'
|
||||
|
||||
flash = _flash_hole
|
||||
|
||||
@parameter
|
||||
def equivalent_width(self):
|
||||
return self.diameter
|
||||
|
||||
@dataclass
|
||||
class ApertureRectangle(Aperture):
|
||||
def rotated(self):
|
||||
if math.isclose(rotation % (2*math.pi), 0) or self.hole_rect_h is None:
|
||||
return self
|
||||
else:
|
||||
return self.to_macro(self.rotation)
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.circle, *self.params)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RectangleAperture(Aperture):
|
||||
gerber_shape_code = 'R'
|
||||
human_readable_shape = 'rect'
|
||||
w : float
|
||||
h : float
|
||||
hole_dia : float = 0
|
||||
hole_rect_h : float = None
|
||||
rotation : float = 0 # radians
|
||||
|
||||
def primitives(self, x, y):
|
||||
return Rectangle((x, y), (self.w, self.h), polarity_dark=True),
|
||||
return [ gp.Rectangle(x, y, self.w, self.h, rotation=self.rotation) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<rect aperture {self.w:.3}x{self.h:.3}>'
|
||||
|
||||
flash = _flash_hole
|
||||
|
||||
@parameter
|
||||
def equivalent_width(self):
|
||||
return math.sqrt(self.w**2 + self.h**2)
|
||||
|
||||
@dataclass
|
||||
class ApertureObround(Aperture):
|
||||
def _rotated(self):
|
||||
if math.isclose(self.rotation % math.pi, 0):
|
||||
return self
|
||||
elif math.isclose(self.rotation % math.pi, math.pi/2):
|
||||
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90())
|
||||
else: # odd angle
|
||||
return self.to_macro()
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.rect, *self.params)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObroundAperture(Aperture):
|
||||
gerber_shape_code = 'O'
|
||||
human_readable_shape = 'obround'
|
||||
w : float
|
||||
h : float
|
||||
hole_dia : float = 0
|
||||
hole_rect_h : float = None
|
||||
rotation : float = 0
|
||||
|
||||
def primitives(self, x, y):
|
||||
return Obround((x, y), self.w, self.h, polarity_dark=True)
|
||||
return [ gp.Obround(x, y, self.w, self.h, rotation=self.rotation) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<obround aperture {self.w:.3}x{self.h:.3}>'
|
||||
|
||||
flash = _flash_hole
|
||||
|
||||
def _rotated(self):
|
||||
if math.isclose(self.rotation % math.pi, 0):
|
||||
return self
|
||||
elif math.isclose(self.rotation % math.pi, math.pi/2):
|
||||
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90())
|
||||
else:
|
||||
return self.to_macro()
|
||||
|
||||
@dataclass
|
||||
class AperturePolygon(Aperture):
|
||||
def to_macro(self, rotation:'radians'=0):
|
||||
# generic macro only supports w > h so flip x/y if h > w
|
||||
inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self))
|
||||
return ApertureMacroInstance(GenericMacros.obround, *inst.params)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolygonAperture(Aperture):
|
||||
gerber_shape_code = 'P'
|
||||
diameter : float
|
||||
n_vertices : int
|
||||
rotation : float = 0
|
||||
hole_dia : float = 0
|
||||
hole_rect_h : float = None
|
||||
|
||||
def primitives(self, x, y):
|
||||
return Polygon((x, y), diameter, n_vertices, rotation, polarity_dark=True),
|
||||
return [ gp.RegularPolygon(x, y, diameter, n_vertices, rotation=self.rotation) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<{self.n_vertices}-gon aperture d={self.diameter:.3}'
|
||||
|
||||
flash = _flash_hole
|
||||
|
||||
class MacroAperture(Aperture):
|
||||
parameters : [float]
|
||||
self.macro : ApertureMacro
|
||||
def _rotated(self):
|
||||
self.rotation %= (2*math.pi / self.n_vertices)
|
||||
return self
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.polygon, *self.params)
|
||||
|
||||
|
||||
class ApertureMacroInstance(Aperture):
|
||||
params : [float]
|
||||
rotation : float = 0
|
||||
|
||||
def __init__(self, macro, *parameters):
|
||||
self.params = parameters
|
||||
self._primitives = macro.to_graphic_primitives(parameters)
|
||||
self.macro = macro
|
||||
|
||||
@property
|
||||
def gerber_shape_code(self):
|
||||
return self.macro.name
|
||||
|
||||
def primitives(self, x, y):
|
||||
return self.macro.execute(x, y, self.parameters)
|
||||
# FIXME return graphical primitives not macro primitives here
|
||||
return [ primitive.with_offset(x, y).rotated(self.rotation, cx=0, cy=0) for primitive in self._primitives ]
|
||||
|
||||
def _rotated(self):
|
||||
if math.isclose(self.rotation % (2*math.pi), 0):
|
||||
return self
|
||||
else:
|
||||
return self.to_macro()
|
||||
|
||||
def to_macro(self):
|
||||
return type(self)(self.macro.rotated(self.rotation), self.params)
|
||||
|
||||
def __eq__(self, other):
|
||||
return hasattr(other, 'macro') and self.macro == other.macro and \
|
||||
hasattr(other, 'params') and self.params == other.params and \
|
||||
hasattr(other, 'rotation') and self.rotation == other.rotation
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,16 @@ from dataclasses import dataclass
|
|||
|
||||
@dataclass
|
||||
class FileSettings:
|
||||
output_axes : str = 'AXBY' # For deprecated AS statement
|
||||
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
|
||||
'''
|
||||
.. note::
|
||||
Format and zero suppression are configurable. Note that the Excellon
|
||||
and Gerber formats use opposite terminology with respect to leading
|
||||
and trailing zeros. The Gerber format specifies which zeros are
|
||||
suppressed, while the Excellon format specifies which zeros are
|
||||
included. This function uses the Gerber-file convention, so an
|
||||
Excellon file in LZ (leading zeros) mode would use
|
||||
`zero_suppression='trailing'`
|
||||
'''
|
||||
notation : str = 'absolute'
|
||||
units : str = 'inch'
|
||||
angle_units : str = 'degrees'
|
||||
|
|
@ -34,18 +38,6 @@ class FileSettings:
|
|||
|
||||
# input validation
|
||||
def __setattr__(self, name, value):
|
||||
if name == 'output_axes' and value not in [None, 'AXBY', 'AYBX']:
|
||||
raise ValueError('output_axes must be either "AXBY", "AYBX" or None')
|
||||
if 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 == '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']:
|
||||
raise ValueError('Units must be either "inch" or "mm"')
|
||||
elif name == 'units' and value not in ['absolute', 'incremental']:
|
||||
|
|
@ -54,14 +46,65 @@ class FileSettings:
|
|||
raise ValueError('Angle units may be "degrees" or "radians"')
|
||||
elif name == 'zeros' and value not in [None, 'leading', 'trailing']:
|
||||
raise ValueError('zero_suppression must be either "leading" or "trailing" or None')
|
||||
elif name == 'number_format' and len(value) != 2:
|
||||
raise ValueError('Number format must be a (integer, fractional) tuple of integers')
|
||||
elif name == 'number_format':
|
||||
if len(value) != 2:
|
||||
raise ValueError('Number format must be a (integer, fractional) tuple of integers')
|
||||
|
||||
if value[0] > 6 or value[1] > 7:
|
||||
raise ValueError('Requested precision is too high. Only up to 6.7 digits are supported by spec.')
|
||||
|
||||
|
||||
super().__setattr__(name, value)
|
||||
|
||||
def __str__(self):
|
||||
return f'<File settings: units={self.units}/{self.angle_units} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
|
||||
|
||||
def parse_gerber_value(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
# Handle excellon edge case with explicit decimal. "That was easy!"
|
||||
if '.' in value:
|
||||
return float(value)
|
||||
|
||||
# Format precision
|
||||
integer_digits, decimal_digits = self.number_format
|
||||
|
||||
# Remove extraneous information
|
||||
sign = '-' if value[0] == '-' else ''
|
||||
value = value.lstrip('+-')
|
||||
|
||||
missing_digits = MAX_DIGITS - len(value)
|
||||
|
||||
if self.zero_suppression == 'leading':
|
||||
return float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:])
|
||||
|
||||
else: # no or trailing zero suppression
|
||||
return float(sign + value[:integer_digits] + '.' + value[integer_digits:])
|
||||
|
||||
def write_gerber_value(self, value):
|
||||
""" Convert a floating point number to a Gerber/Excellon-formatted string. """
|
||||
|
||||
integer_digits, decimal_digits = self.number_format
|
||||
|
||||
# negative sign affects padding, so deal with it at the end...
|
||||
sign = '-' if value < 0 else ''
|
||||
|
||||
num = format(abs(value), f'0{integer_digits+decimal_digits+1}.{decimal_digits}f')
|
||||
|
||||
# Suppression...
|
||||
if self.zero_suppression == 'trailing':
|
||||
num = num.rstrip('0')
|
||||
|
||||
elif self.zero_suppression == 'leading':
|
||||
num = num.lstrip('0')
|
||||
|
||||
# Edge case. Per Gerber spec if the value is 0 we should return a single '0' in all cases, see page 77.
|
||||
elif not num.strip('0'):
|
||||
num = '0'
|
||||
|
||||
return sign + (num or '0')
|
||||
|
||||
|
||||
class CamFile(object):
|
||||
""" Base class for Gerber/Excellon files.
|
||||
|
|
@ -101,39 +144,12 @@ class CamFile(object):
|
|||
decimal digits)
|
||||
"""
|
||||
|
||||
def __init__(self, statements=None, settings=None, primitives=None,
|
||||
def __init__(self, settings=None, primitives=None,
|
||||
filename=None, layer_name=None):
|
||||
if settings is not None:
|
||||
self.notation = settings['notation']
|
||||
self.units = settings['units']
|
||||
self.zero_suppression = settings['zero_suppression']
|
||||
self.zeros = settings['zeros']
|
||||
self.format = settings['format']
|
||||
else:
|
||||
self.notation = 'absolute'
|
||||
self.units = 'inch'
|
||||
self.zero_suppression = 'trailing'
|
||||
self.zeros = 'leading'
|
||||
self.format = (2, 5)
|
||||
|
||||
self.statements = statements if statements is not None else []
|
||||
if primitives is not None:
|
||||
self.primitives = primitives
|
||||
self.settings = settings if settings is not None else FileSettings()
|
||||
self.filename = filename
|
||||
self.layer_name = layer_name
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
""" File settings
|
||||
|
||||
Returns
|
||||
-------
|
||||
settings : FileSettings (dict-like)
|
||||
A FileSettings object with the specified configuration.
|
||||
"""
|
||||
return FileSettings(self.notation, self.units, self.zero_suppression,
|
||||
self.format)
|
||||
|
||||
@property
|
||||
def bounds(self):
|
||||
""" File boundaries
|
||||
|
|
@ -144,12 +160,6 @@ class CamFile(object):
|
|||
def bounding_box(self):
|
||||
pass
|
||||
|
||||
def to_inch(self):
|
||||
pass
|
||||
|
||||
def to_metric(self):
|
||||
pass
|
||||
|
||||
def render(self, ctx=None, invert=False, filename=None):
|
||||
""" Generate image of layer.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
|
||||
# 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.
|
||||
|
||||
from . import rs274x
|
||||
from . import excellon
|
||||
from . import ipc356
|
||||
from .exceptions import ParseError
|
||||
from .utils import detect_file_format
|
||||
|
||||
|
||||
def read(filename):
|
||||
""" Read a gerber or excellon file and return a representative object.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filename : string
|
||||
Filename of the file to read.
|
||||
|
||||
Returns
|
||||
-------
|
||||
file : CncFile subclass
|
||||
CncFile object representing the file, either GerberFile, ExcellonFile,
|
||||
or IPCNetlist. Returns None if file is not of the proper type.
|
||||
"""
|
||||
with open(filename, 'r') as f:
|
||||
data = f.read()
|
||||
return loads(data, filename)
|
||||
|
||||
|
||||
def loads(data, filename=None):
|
||||
""" Read gerber or excellon file contents from a string and return a
|
||||
representative object.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : string
|
||||
Source file contents as a string.
|
||||
|
||||
filename : string, optional
|
||||
String containing the filename of the data source.
|
||||
|
||||
Returns
|
||||
-------
|
||||
file : CncFile subclass
|
||||
CncFile object representing the data, either GerberFile, ExcellonFile,
|
||||
or IPCNetlist. Returns None if data is not of the proper type.
|
||||
"""
|
||||
|
||||
fmt = detect_file_format(data)
|
||||
if fmt == 'rs274x':
|
||||
return rs274x.loads(data, filename=filename)
|
||||
elif fmt == 'excellon':
|
||||
return excellon.loads(data, filename=filename)
|
||||
elif fmt == 'ipc_d_356':
|
||||
return ipc356.loads(data, filename=filename)
|
||||
else:
|
||||
raise ParseError('Unable to detect file format')
|
||||
|
|
@ -20,14 +20,7 @@ Gerber (RS-274X) Statements
|
|||
**Gerber RS-274X file statement classes**
|
||||
|
||||
"""
|
||||
from .utils import (parse_gerber_value, write_gerber_value, decimal_string,
|
||||
inch, metric)
|
||||
|
||||
from .am_statements import *
|
||||
from .am_read import read_macro
|
||||
from .am_primitive import eval_macro
|
||||
from .primitives import AMGroup
|
||||
|
||||
from utils import parse_gerber_value, write_gerber_value, decimal_string, inch, metric
|
||||
|
||||
class Statement:
|
||||
pass
|
||||
|
|
@ -86,202 +79,28 @@ class LoadPolarityStmt(ParamStmt):
|
|||
class ApertureDefStmt(ParamStmt):
|
||||
""" AD - Aperture Definition Statement """
|
||||
|
||||
@classmethod
|
||||
def rect(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None):
|
||||
'''Create a rectangular aperture definition statement'''
|
||||
if hole_diameter is not None and hole_diameter > 0:
|
||||
return cls('AD', dcode, 'R', ([width, height, hole_diameter],))
|
||||
elif (hole_width is not None and hole_width > 0
|
||||
and hole_height is not None and hole_height > 0):
|
||||
return cls('AD', dcode, 'R', ([width, height, hole_width, hole_height],))
|
||||
return cls('AD', dcode, 'R', ([width, height],))
|
||||
|
||||
@classmethod
|
||||
def circle(cls, dcode, diameter, hole_diameter=None, hole_width=None, hole_height=None):
|
||||
'''Create a circular aperture definition statement'''
|
||||
if hole_diameter is not None and hole_diameter > 0:
|
||||
return cls('AD', dcode, 'C', ([diameter, hole_diameter],))
|
||||
elif (hole_width is not None and hole_width > 0
|
||||
and hole_height is not None and hole_height > 0):
|
||||
return cls('AD', dcode, 'C', ([diameter, hole_width, hole_height],))
|
||||
return cls('AD', dcode, 'C', ([diameter],))
|
||||
|
||||
@classmethod
|
||||
def obround(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None):
|
||||
'''Create an obround aperture definition statement'''
|
||||
if hole_diameter is not None and hole_diameter > 0:
|
||||
return cls('AD', dcode, 'O', ([width, height, hole_diameter],))
|
||||
elif (hole_width is not None and hole_width > 0
|
||||
and hole_height is not None and hole_height > 0):
|
||||
return cls('AD', dcode, 'O', ([width, height, hole_width, hole_height],))
|
||||
return cls('AD', dcode, 'O', ([width, height],))
|
||||
|
||||
@classmethod
|
||||
def polygon(cls, dcode, diameter, num_vertices, rotation, hole_diameter=None, hole_width=None, hole_height=None):
|
||||
'''Create a polygon aperture definition statement'''
|
||||
if hole_diameter is not None and hole_diameter > 0:
|
||||
return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_diameter],))
|
||||
elif (hole_width is not None and hole_width > 0
|
||||
and hole_height is not None and hole_height > 0):
|
||||
return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_width, hole_height],))
|
||||
return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation],))
|
||||
|
||||
|
||||
@classmethod
|
||||
def macro(cls, dcode, name):
|
||||
return cls('AD', dcode, name, '')
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, stmt_dict):
|
||||
param = stmt_dict.get('param')
|
||||
d = int(stmt_dict.get('d'))
|
||||
shape = stmt_dict.get('shape')
|
||||
modifiers = stmt_dict.get('modifiers')
|
||||
return cls(param, d, shape, modifiers)
|
||||
|
||||
def __init__(self, param, d, shape, modifiers):
|
||||
""" Initialize ADParamStmt class
|
||||
|
||||
Parameters
|
||||
----------
|
||||
param : string
|
||||
Parameter code
|
||||
|
||||
d : int
|
||||
Aperture D-code
|
||||
|
||||
shape : string
|
||||
aperture name
|
||||
|
||||
modifiers : list of lists of floats
|
||||
Shape modifiers
|
||||
|
||||
Returns
|
||||
-------
|
||||
ParamStmt : ADParamStmt
|
||||
Initialized ADParamStmt class.
|
||||
|
||||
"""
|
||||
ParamStmt.__init__(self, param)
|
||||
self.d = d
|
||||
self.shape = shape
|
||||
if isinstance(modifiers, tuple):
|
||||
self.modifiers = modifiers
|
||||
elif modifiers:
|
||||
self.modifiers = [tuple([float(x) for x in m.split("X") if len(x)])
|
||||
for m in modifiers.split(",") if len(m)]
|
||||
else:
|
||||
self.modifiers = [tuple()]
|
||||
|
||||
def to_inch(self):
|
||||
if self.units == 'metric':
|
||||
self.units = 'inch'
|
||||
self.modifiers = [tuple([inch(x) for x in modifier])
|
||||
for modifier in self.modifiers]
|
||||
|
||||
def to_metric(self):
|
||||
if self.units == 'inch':
|
||||
self.units = 'metric'
|
||||
self.modifiers = [tuple([metric(x) for x in modifier])
|
||||
for modifier in self.modifiers]
|
||||
def __init__(self, number, aperture):
|
||||
self.number = number
|
||||
self.aperture = aperture
|
||||
|
||||
def to_gerber(self, settings=None):
|
||||
if any(self.modifiers):
|
||||
return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(["%.4g" % x for x in modifier]) for modifier in self.modifiers]))
|
||||
else:
|
||||
return '%ADD{0}{1}*%'.format(self.d, self.shape)
|
||||
return '%ADD{self.number}{self.aperture.to_gerber()}*%'
|
||||
|
||||
def __str__(self):
|
||||
if self.shape == 'C':
|
||||
shape = 'circle'
|
||||
elif self.shape == 'R':
|
||||
shape = 'rectangle'
|
||||
elif self.shape == 'O':
|
||||
shape = 'obround'
|
||||
else:
|
||||
shape = self.shape
|
||||
|
||||
return '<Aperture Definition: %d: %s>' % (self.d, shape)
|
||||
return f'<AD aperture def for {str(self.aperture).strip("<>")}>'
|
||||
|
||||
|
||||
class AMParamStmt(ParamStmt):
|
||||
""" AM - Aperture Macro Statement
|
||||
"""
|
||||
class ApertureMacroStmt(ParamStmt):
|
||||
""" AM - Aperture Macro Statement """
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, stmt_dict, units):
|
||||
return cls(**stmt_dict, units=units)
|
||||
|
||||
def __init__(self, param, name, macro, units):
|
||||
""" Initialize AMParamStmt class
|
||||
|
||||
Parameters
|
||||
----------
|
||||
param : string
|
||||
Parameter code
|
||||
|
||||
name : string
|
||||
Aperture macro name
|
||||
|
||||
macro : string
|
||||
Aperture macro string
|
||||
|
||||
Returns
|
||||
-------
|
||||
ParamStmt : AMParamStmt
|
||||
Initialized AMParamStmt class.
|
||||
|
||||
"""
|
||||
ParamStmt.__init__(self, param)
|
||||
self.name = name
|
||||
def __init__(self, macro):
|
||||
self.macro = macro
|
||||
self.units = units
|
||||
self.primitives = list(eval_macro(read_macro(macro), units))
|
||||
|
||||
@classmethod
|
||||
def circle(cls, name, units):
|
||||
return cls('AM', name, '1,1,$1,0,0,0*1,0,$2,0,0,0', units)
|
||||
|
||||
@classmethod
|
||||
def rectangle(cls, name, units):
|
||||
return cls('AM', name, '21,1,$1,$2,0,0,0*1,0,$3,0,0,0', units)
|
||||
|
||||
@classmethod
|
||||
def landscape_obround(cls, name, units):
|
||||
return cls(
|
||||
'AM', name,
|
||||
'$4=$1-$2*'
|
||||
'$5=$1-$4*'
|
||||
'21,1,$5,$2,0,0,0*'
|
||||
'1,1,$4,$4/2,0,0*'
|
||||
'1,1,$4,-$4/2,0,0*'
|
||||
'1,0,$3,0,0,0', units)
|
||||
|
||||
@classmethod
|
||||
def portrate_obround(cls, name, units):
|
||||
return cls(
|
||||
'AM', name,
|
||||
'$4=$2-$1*'
|
||||
'$5=$2-$4*'
|
||||
'21,1,$1,$5,0,0,0*'
|
||||
'1,1,$4,0,$4/2,0*'
|
||||
'1,1,$4,0,-$4/2,0*'
|
||||
'1,0,$3,0,0,0', units)
|
||||
|
||||
@classmethod
|
||||
def polygon(cls, name, units):
|
||||
return cls('AM', name, '5,1,$2,0,0,$1,$3*1,0,$4,0,0,0', units)
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
primitive_defs = '\n'.join(primitive.to_gerber(unit=unit) for primitive in self.primitives)
|
||||
return f'%AM{self.name}*\n{primitive_defs}%'
|
||||
|
||||
def rotate(self, angle, center=None):
|
||||
for primitive_def in self.primitives:
|
||||
primitive_def.rotate(angle, center)
|
||||
return f'%AM{self.macro.name}*\n{self.macro.to_gerber(unit=unit)}*\n%'
|
||||
|
||||
def __str__(self):
|
||||
return '<AM Aperture Macro %s: %s>' % (self.name, self.macro)
|
||||
return f'<AM Aperture Macro {self.macro.name}: {self.macro}>'
|
||||
|
||||
|
||||
class ImagePolarityStmt(ParamStmt):
|
||||
|
|
@ -298,7 +117,7 @@ class ImagePolarityStmt(ParamStmt):
|
|||
class CoordStmt(Statement):
|
||||
""" D01 - D03 operation statements """
|
||||
|
||||
def __init__(self, x, y, i, j):
|
||||
def __init__(self, x, y, i=None, j=None):
|
||||
self.x, self.y, self.i, self.j = x, y, i, j
|
||||
|
||||
def to_gerber(self, settings=None):
|
||||
|
|
@ -309,22 +128,12 @@ class CoordStmt(Statement):
|
|||
ret += var.upper() + write_gerber_value(val, settings.number_format, settings.zero_suppression)
|
||||
return ret + self.code + '*'
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
if self.x is not None:
|
||||
self.x += x
|
||||
if self.y is not None:
|
||||
self.y += y
|
||||
|
||||
def __str__(self):
|
||||
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]>'
|
||||
|
||||
def render_primitives(self, state):
|
||||
if state.interpolation_mode == InterpolateStmt:
|
||||
yield Line(state.current_point, (self.x, self.y))
|
||||
|
||||
class InterpolateStmt(Statement):
|
||||
""" D01 Interpolation """
|
||||
code = 'D01'
|
||||
|
|
@ -369,20 +178,12 @@ 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)
|
||||
self.deprecated = True if deprecated is not None and deprecated is not False else False
|
||||
|
||||
def to_gerber(self, settings=None):
|
||||
if self.deprecated:
|
||||
return 'G54D{0}*'.format(self.d)
|
||||
else:
|
||||
return 'D{0}*'.format(self.d)
|
||||
return 'D{0}*'.format(self.d)
|
||||
|
||||
def __str__(self):
|
||||
return '<Aperture: %d>' % self.d
|
||||
|
|
|
|||
140
gerbonara/gerber/graphic_primitives.py
Normal file
140
gerbonara/gerber/graphic_primitives.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
|
||||
import math
|
||||
import itertools
|
||||
|
||||
from dataclasses import dataclass, KW_ONLY, replace
|
||||
|
||||
from gerber_statements import *
|
||||
|
||||
|
||||
class GraphicPrimitive:
|
||||
_ : KW_ONLY
|
||||
polarity_dark : bool = True
|
||||
|
||||
|
||||
def rotate_point(x, y, angle, cx=None, cy=None):
|
||||
if cx is None:
|
||||
return (x, y)
|
||||
else:
|
||||
return (cx + (x - cx) * math.cos(angle) - (y - cy) * math.sin(angle),
|
||||
cy + (x - cx) * math.sin(angle) + (y - cy) * math.cos(angle))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Circle(GraphicPrimitive):
|
||||
x : float
|
||||
y : float
|
||||
r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses.
|
||||
|
||||
def bounds(self):
|
||||
return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Obround(GraphicPrimitive):
|
||||
x : float
|
||||
y : float
|
||||
w : float
|
||||
h : float
|
||||
rotation : float # radians!
|
||||
|
||||
def decompose(self):
|
||||
''' decompose obround to two circles and one rectangle '''
|
||||
|
||||
cx = self.x + self.w/2
|
||||
cy = self.y + self.h/2
|
||||
|
||||
if self.w > self.h:
|
||||
x = self.x + self.h/2
|
||||
yield Circle(x, cy, self.h/2)
|
||||
yield Circle(x + self.w, cy, self.h/2)
|
||||
yield Rectangle(x, self.y, self.w - self.h, self.h)
|
||||
|
||||
elif self.h > self.w:
|
||||
y = self.y + self.w/2
|
||||
yield Circle(cx, y, self.w/2)
|
||||
yield Circle(cx, y + self.h, self.w/2)
|
||||
yield Rectangle(self.x, y, self.w, self.h - self.w)
|
||||
|
||||
else:
|
||||
yield Circle(cx, cy, self.w/2)
|
||||
|
||||
def bounds(self):
|
||||
return ((self.x-self.w/2, self.y-self.h/2), (self.x+self.w/2, self.y+self.h/2))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArcPoly(GraphicPrimitive):
|
||||
""" Polygon whose sides may be either straight lines or circular arcs """
|
||||
|
||||
# list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered
|
||||
# connected.
|
||||
outline : list(tuple(float))
|
||||
# list of radii of segments, must be either None (all segments are straight lines) or same length as outline.
|
||||
# Straight line segments have None entry.
|
||||
arc_centers : list(tuple(float))
|
||||
|
||||
@property
|
||||
def segments(self):
|
||||
return itertools.zip_longest(self.outline[:-1], self.outline[1:], self.radii or [])
|
||||
|
||||
def bounds(self):
|
||||
for (x1, y1), (x2, y2), radius in self.segments:
|
||||
return
|
||||
|
||||
|
||||
@dataclass
|
||||
class Line(GraphicPrimitive):
|
||||
x1 : float
|
||||
y1 : float
|
||||
x2 : float
|
||||
y2 : float
|
||||
width : float
|
||||
|
||||
# FIXME bounds
|
||||
|
||||
@dataclass
|
||||
class Arc(GraphicPrimitive):
|
||||
x : float
|
||||
y : float
|
||||
r : float
|
||||
angle1 : float # radians!
|
||||
angle2 : float # radians!
|
||||
width : float
|
||||
|
||||
# FIXME bounds
|
||||
|
||||
@dataclass
|
||||
class Rectangle(GraphicPrimitive):
|
||||
# coordinates are center coordinates
|
||||
x : float
|
||||
y : float
|
||||
w : float
|
||||
h : float
|
||||
rotation : float # radians, around center!
|
||||
|
||||
def bounds(self):
|
||||
return ((self.x, self.y), (self.x+self.w, self.y+self.h))
|
||||
|
||||
@prorperty
|
||||
def center(self):
|
||||
return self.x + self.w/2, self.y + self.h/2
|
||||
|
||||
|
||||
class RegularPolygon(GraphicPrimitive):
|
||||
x : float
|
||||
y : float
|
||||
r : float
|
||||
n : int
|
||||
rotation : float # radians!
|
||||
|
||||
def decompose(self):
|
||||
''' convert n-sided gerber polygon to normal Region defined by outline '''
|
||||
|
||||
delta = 2*math.pi / self.n
|
||||
|
||||
yield Region([
|
||||
(self.x + math.cos(self.rotation + i*delta) * self.r,
|
||||
self.y + math.sin(self.rotation + i*delta) * self.r)
|
||||
for i in range(self.n) ])
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ class Primitive:
|
|||
|
||||
|
||||
class Line(Primitive):
|
||||
def __init__(self, start, end, aperture, polarity_dark=True, rotation=0, **meta):
|
||||
def __init__(self, start, end, aperture=None, polarity_dark=True, rotation=0, **meta):
|
||||
super().__init__(polarity_dark, rotation, **meta)
|
||||
self.start = start
|
||||
self.end = end
|
||||
|
|
@ -240,9 +240,6 @@ class Arc(Primitive):
|
|||
|
||||
|
||||
class Circle(Primitive):
|
||||
"""
|
||||
"""
|
||||
|
||||
def __init__(self, position, diameter, polarity_dark=True):
|
||||
super(Circle, self).__init__(**kwargs)
|
||||
validate_coordinates(position)
|
||||
|
|
@ -922,3 +919,14 @@ class TestRecord(Primitive):
|
|||
self.net_name = net_name
|
||||
self.layer = layer
|
||||
self._to_convert = ['position']
|
||||
|
||||
class RegionGroup:
|
||||
def __init__(self):
|
||||
self.outline = []
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.outline)
|
||||
|
||||
def append(self, primitive):
|
||||
self.outline.append(primitive)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -29,148 +29,6 @@ from math import radians, sin, cos, sqrt, atan2, pi
|
|||
MILLIMETERS_PER_INCH = 25.4
|
||||
|
||||
|
||||
def parse_gerber_value(value, settings):
|
||||
""" Convert gerber/excellon formatted string to floating-point number
|
||||
|
||||
.. note::
|
||||
Format and zero suppression are configurable. Note that the Excellon
|
||||
and Gerber formats use opposite terminology with respect to leading
|
||||
and trailing zeros. The Gerber format specifies which zeros are
|
||||
suppressed, while the Excellon format specifies which zeros are
|
||||
included. This function uses the Gerber-file convention, so an
|
||||
Excellon file in LZ (leading zeros) mode would use
|
||||
`zero_suppression='trailing'`
|
||||
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : string
|
||||
A Gerber/Excellon-formatted string representing a numerical value.
|
||||
|
||||
format : tuple (int,int)
|
||||
Gerber/Excellon precision format expressed as a tuple containing:
|
||||
(number of integer-part digits, number of decimal-part digits)
|
||||
|
||||
zero_suppression : string
|
||||
Zero-suppression mode. May be 'leading', 'trailing' or 'none'
|
||||
|
||||
Returns
|
||||
-------
|
||||
value : float
|
||||
The specified value as a floating-point number.
|
||||
|
||||
"""
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
# Handle excellon edge case with explicit decimal. "That was easy!"
|
||||
if '.' in value:
|
||||
return float(value)
|
||||
|
||||
# Format precision
|
||||
integer_digits, decimal_digits = settings.format
|
||||
MAX_DIGITS = integer_digits + decimal_digits
|
||||
|
||||
# Absolute maximum number of digits supported. This will handle up to
|
||||
# 6:7 format, which is somewhat supported, even though the gerber spec
|
||||
# only allows up to 6:6
|
||||
if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7:
|
||||
raise ValueError('Parser only supports precision up to 6:7 format')
|
||||
|
||||
# Remove extraneous information
|
||||
value = value.lstrip('+')
|
||||
negative = '-' in value
|
||||
if negative:
|
||||
value = value.lstrip('-')
|
||||
|
||||
missing_digits = MAX_DIGITS - len(value)
|
||||
|
||||
if settings.zero_suppression == 'trailing':
|
||||
digits = list(value + ('0' * missing_digits))
|
||||
elif settings.zero_suppression == 'leading':
|
||||
digits = list(('0' * missing_digits) + value)
|
||||
else:
|
||||
digits = list(value)
|
||||
|
||||
result = float(
|
||||
''.join(digits[:integer_digits] + ['.'] + digits[integer_digits:]))
|
||||
return -result if negative else result
|
||||
|
||||
|
||||
def write_gerber_value(value, settings):
|
||||
""" Convert a floating point number to a Gerber/Excellon-formatted string.
|
||||
|
||||
.. note::
|
||||
Format and zero suppression are configurable. Note that the Excellon
|
||||
and Gerber formats use opposite terminology with respect to leading
|
||||
and trailing zeros. The Gerber format specifies which zeros are
|
||||
suppressed, while the Excellon format specifies which zeros are
|
||||
included. This function uses the Gerber-file convention, so an
|
||||
Excellon file in LZ (leading zeros) mode would use
|
||||
`zero_suppression='trailing'`
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : float
|
||||
A floating point value.
|
||||
|
||||
format : tuple (n=2)
|
||||
Gerber/Excellon precision format expressed as a tuple containing:
|
||||
(number of integer-part digits, number of decimal-part digits)
|
||||
|
||||
zero_suppression : string
|
||||
Zero-suppression mode. May be 'leading', 'trailing' or 'none'
|
||||
|
||||
Returns
|
||||
-------
|
||||
value : string
|
||||
The specified value as a Gerber/Excellon-formatted string.
|
||||
"""
|
||||
|
||||
if format[0] == float:
|
||||
return "%f" %value
|
||||
|
||||
# Format precision
|
||||
integer_digits, decimal_digits = settings.format
|
||||
MAX_DIGITS = integer_digits + decimal_digits
|
||||
|
||||
if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7:
|
||||
raise ValueError('Parser only supports precision up to 6:7 format')
|
||||
|
||||
# Edge case... (per Gerber spec we should return 0 in all cases, see page
|
||||
# 77)
|
||||
if value == 0:
|
||||
return '0'
|
||||
|
||||
# negative sign affects padding, so deal with it at the end...
|
||||
negative = value < 0.0
|
||||
if negative:
|
||||
value = -1.0 * value
|
||||
|
||||
# Format string for padding out in both directions
|
||||
fmtstring = '%%0%d.0%df' % (MAX_DIGITS + 1, decimal_digits)
|
||||
digits = [val for val in fmtstring % value if val != '.']
|
||||
|
||||
# If all the digits are 0, return '0'.
|
||||
digit_sum = sum([int(digit) for digit in digits])
|
||||
if digit_sum == 0:
|
||||
return '0'
|
||||
|
||||
# Suppression...
|
||||
if settings.zero_suppression == 'trailing':
|
||||
while digits and digits[-1] == '0':
|
||||
digits.pop()
|
||||
elif settings.zero_suppression == 'leading':
|
||||
while digits and digits[0] == '0':
|
||||
digits.pop(0)
|
||||
|
||||
if not digits:
|
||||
return '0'
|
||||
|
||||
return ''.join(digits) if not negative else ''.join(['-'] + digits)
|
||||
|
||||
|
||||
def decimal_string(value, precision=6, padding=False):
|
||||
""" Convert float to string with limited precision
|
||||
|
||||
|
|
@ -208,32 +66,6 @@ def decimal_string(value, precision=6, padding=False):
|
|||
else:
|
||||
return int(floatstr)
|
||||
|
||||
|
||||
def detect_file_format(data):
|
||||
""" Determine format of a file
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : string
|
||||
string containing file data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
format : string
|
||||
File format. 'excellon' or 'rs274x' or 'unknown'
|
||||
"""
|
||||
lines = data.split('\n')
|
||||
for line in lines:
|
||||
if 'M48' in line:
|
||||
return 'excellon'
|
||||
elif '%FS' in line:
|
||||
return 'rs274x'
|
||||
elif ((len(line.split()) >= 2) and
|
||||
(line.split()[0] == 'P') and (line.split()[1] == 'JOB')):
|
||||
return 'ipc_d_356'
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def validate_coordinates(position):
|
||||
if position is not None:
|
||||
if len(position) != 2:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue