Fix a whole bunch of SVG export bugs
This commit is contained in:
parent
deb2bb2bbf
commit
4ed8358096
10 changed files with 162 additions and 109 deletions
|
|
@ -119,7 +119,7 @@ class ApertureMacro:
|
|||
primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ]
|
||||
return '*\n'.join(comments + variable_defs + primitive_defs)
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None):
|
||||
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
|
||||
variables = dict(self.variables)
|
||||
for number, value in enumerate(parameters, start=1):
|
||||
if number in variables:
|
||||
|
|
@ -127,7 +127,7 @@ class ApertureMacro:
|
|||
variables[number] = value
|
||||
|
||||
for primitive in self.primitives:
|
||||
yield from primitive.to_graphic_primitives(offset, rotation, variables, unit)
|
||||
yield from primitive.to_graphic_primitives(offset, rotation, variables, unit, polarity_dark)
|
||||
|
||||
def rotated(self, angle):
|
||||
dup = copy.deepcopy(self)
|
||||
|
|
|
|||
|
|
@ -81,11 +81,11 @@ class Circle(Primitive):
|
|||
if self.rotation is None:
|
||||
self.rotation = ConstantExpression(0)
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, 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.diameter/2, polarity_dark=bool(calc.exposure)) ]
|
||||
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.diameter += UnitExpression(offset, unit)
|
||||
|
|
@ -100,7 +100,7 @@ class VectorLine(Primitive):
|
|||
end_y : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
center_x = (calc.end_x + calc.start_x) / 2
|
||||
center_y = (calc.end_y + calc.start_y) / 2
|
||||
|
|
@ -112,7 +112,7 @@ class VectorLine(Primitive):
|
|||
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)) ]
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.width += UnitExpression(2*offset, unit)
|
||||
|
|
@ -128,14 +128,14 @@ class CenterLine(Primitive):
|
|||
y : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, 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
|
||||
|
||||
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=bool(calc.exposure)) ]
|
||||
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.width += UnitExpression(2*offset, unit)
|
||||
|
|
@ -151,13 +151,13 @@ class Polygon(Primitive):
|
|||
diameter : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, 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)) ]
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.diameter += UnitExpression(2*offset, unit)
|
||||
|
|
@ -174,13 +174,13 @@ class Thermal(Primitive):
|
|||
gap_w : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, 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]
|
||||
|
||||
dark = bool(calc.exposure)
|
||||
dark = (bool(calc.exposure) == polarity_dark)
|
||||
|
||||
return [
|
||||
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
|
||||
|
|
@ -226,7 +226,7 @@ class Outline(Primitive):
|
|||
coords = ','.join(coord.to_gerber(unit) for xy in self.coords for coord in xy)
|
||||
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)-1},{coords},{self.rotation.to_gerber()}'
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, 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)
|
||||
|
|
@ -234,7 +234,7 @@ class Outline(Primitive):
|
|||
rotation += deg_to_rad(calc.rotation)
|
||||
bound_coords = [ gp.rotate_point(*p, rotation, 0, 0) for p in bound_coords ]
|
||||
|
||||
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=calc.exposure)]
|
||||
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
# we would need a whole polygon offset/clipping library here
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
import math
|
||||
from dataclasses import dataclass, replace, fields, InitVar, KW_ONLY
|
||||
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
|
||||
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
from .utils import MM, Inch
|
||||
|
|
@ -8,17 +8,17 @@ from .utils import MM, Inch
|
|||
from . import graphic_primitives as gp
|
||||
|
||||
|
||||
def _flash_hole(self, x, y, unit=None):
|
||||
def _flash_hole(self, x, y, unit=None, polarity_dark=True):
|
||||
if getattr(self, 'hole_rect_h', None) is not None:
|
||||
return [*self.primitives(x, y, unit),
|
||||
return [*self.primitives(x, y, unit, polarity_dark),
|
||||
gp.Rectangle((x, y),
|
||||
(self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)),
|
||||
rotation=self.rotation, polarity_dark=False)]
|
||||
rotation=self.rotation, polarity_dark=(not polarity_dark))]
|
||||
elif self.hole_dia is not None:
|
||||
return [*self.primitives(x, y, unit),
|
||||
gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=False)]
|
||||
return [*self.primitives(x, y, unit, polarity_dark),
|
||||
gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))]
|
||||
else:
|
||||
return self.primitives(x, y, unit)
|
||||
return self.primitives(x, y, unit, polarity_dark)
|
||||
|
||||
def strip_right(*args):
|
||||
args = list(args)
|
||||
|
|
@ -42,6 +42,7 @@ class Length:
|
|||
class Aperture:
|
||||
_ : KW_ONLY
|
||||
unit : str = None
|
||||
attrs : dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def hole_shape(self):
|
||||
|
|
@ -63,8 +64,8 @@ class Aperture:
|
|||
|
||||
return out
|
||||
|
||||
def flash(self, x, y, unit=None):
|
||||
return self.primitives(x, y, unit)
|
||||
def flash(self, x, y, unit=None, polarity_dark=True):
|
||||
return self.primitives(x, y, unit, polarity_dark)
|
||||
|
||||
def equivalent_width(self, unit=None):
|
||||
raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.')
|
||||
|
|
@ -74,7 +75,6 @@ class Aperture:
|
|||
# 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.
|
||||
unit = settings.unit if settings else None
|
||||
#print(f'aperture to gerber {self.unit=} {settings=} {unit=}')
|
||||
actual_inst = self._rotated()
|
||||
params = 'X'.join(f'{float(par):.4}' for par in actual_inst.params(unit) if par is not None)
|
||||
return ','.join((actual_inst.gerber_shape_code, params))
|
||||
|
|
@ -96,8 +96,8 @@ class ExcellonTool(Aperture):
|
|||
plated : bool = None
|
||||
depth_offset : Length(float) = 0
|
||||
|
||||
def primitives(self, x, y, unit=None):
|
||||
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2)) ]
|
||||
def primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ]
|
||||
|
||||
def to_xnc(self, settings):
|
||||
z_off = 'Z' + settings.write_excellon_value(self.depth_offset, self.unit) if self.depth_offset is not None else ''
|
||||
|
|
@ -146,8 +146,8 @@ class CircleAperture(Aperture):
|
|||
hole_rect_h : Length(float) = None
|
||||
rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber
|
||||
|
||||
def primitives(self, x, y, unit=None):
|
||||
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2)) ]
|
||||
def primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<circle aperture d={self.diameter:.3} [{self.unit}]>'
|
||||
|
|
@ -187,8 +187,9 @@ class RectangleAperture(Aperture):
|
|||
hole_rect_h : Length(float) = None
|
||||
rotation : float = 0 # radians
|
||||
|
||||
def primitives(self, x, y, unit=None):
|
||||
return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), rotation=self.rotation) ]
|
||||
def primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
|
||||
rotation=self.rotation, polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<rect aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
|
||||
|
|
@ -236,8 +237,9 @@ class ObroundAperture(Aperture):
|
|||
hole_rect_h : Length(float) = None
|
||||
rotation : float = 0
|
||||
|
||||
def primitives(self, x, y, unit=None):
|
||||
return [ gp.Obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), rotation=self.rotation) ]
|
||||
def primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
|
||||
rotation=self.rotation, polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<obround aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
|
||||
|
|
@ -285,8 +287,9 @@ class PolygonAperture(Aperture):
|
|||
def __post_init__(self):
|
||||
self.n_vertices = int(self.n_vertices)
|
||||
|
||||
def primitives(self, x, y, unit=None):
|
||||
return [ gp.RegularPolygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices, rotation=self.rotation) ]
|
||||
def primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.RegularPolygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices,
|
||||
rotation=self.rotation, polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<{self.n_vertices}-gon aperture d={self.diameter:.3} [{self.unit}]>'
|
||||
|
|
@ -322,10 +325,10 @@ class ApertureMacroInstance(Aperture):
|
|||
def gerber_shape_code(self):
|
||||
return self.macro.name
|
||||
|
||||
def primitives(self, x, y, unit=None):
|
||||
def primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return self.macro.to_graphic_primitives(
|
||||
offset=(x, y), rotation=self.rotation,
|
||||
parameters=self.parameters, unit=unit)
|
||||
parameters=self.parameters, unit=unit, polarity_dark=polarity_dark)
|
||||
|
||||
def dilated(self, offset, unit=MM):
|
||||
return replace(self, macro=self.macro.dilated(offset, unit))
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ class CamFile:
|
|||
self.import_settings = None
|
||||
self.objects = []
|
||||
|
||||
def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color='black'):
|
||||
def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white'):
|
||||
|
||||
if force_bounds is None:
|
||||
(min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
|
||||
|
|
@ -180,6 +180,8 @@ class CamFile:
|
|||
h = 1.0 if math.isclose(h, 0.0) else h
|
||||
|
||||
primitives = [ prim for obj in self.objects for prim in obj.to_primitives(unit=svg_unit) ]
|
||||
view = tag('sodipodi:namedview', [], id='namedview1', pagecolor=bg,
|
||||
inkscape__document_units=svg_unit.shorthand)
|
||||
tags = []
|
||||
polyline = None
|
||||
for primitive in primitives:
|
||||
|
|
@ -188,25 +190,29 @@ class CamFile:
|
|||
polyline = gp.Polyline(primitive)
|
||||
else:
|
||||
if not polyline.append(primitive):
|
||||
tags.append(polyline.to_svg(tag, color))
|
||||
tags.append(polyline.to_svg(tag, fg, bg))
|
||||
polyline = gp.Polyline(primitive)
|
||||
else:
|
||||
if polyline:
|
||||
tags.append(polyline.to_svg(tag, color))
|
||||
tags.append(polyline.to_svg(tag, fg, bg))
|
||||
polyline = None
|
||||
tags.append(primitive.to_svg(tag, color))
|
||||
tags.append(primitive.to_svg(tag, fg, bg))
|
||||
if polyline:
|
||||
tags.append(polyline.to_svg(tag, color))
|
||||
tags.append(polyline.to_svg(tag, fg, bg))
|
||||
|
||||
# setup viewport transform flipping y axis
|
||||
xform = f'translate({min_x} {min_y+h}) scale(1 -1) translate({-min_x} {-min_y})'
|
||||
|
||||
svg_unit = 'in' if svg_unit == 'inch' else 'mm'
|
||||
# TODO export apertures as <uses> where reasonable.
|
||||
return tag('svg', [tag('g', tags, transform=xform)],
|
||||
return tag('svg', [view, tag('g', tags, transform=xform)],
|
||||
width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
|
||||
viewBox=f'{min_x} {min_y} {w} {h}',
|
||||
xmlns="http://www.w3.org/2000/svg", xmlns__xlink="http://www.w3.org/1999/xlink", root=True)
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns__xlink="http://www.w3.org/1999/xlink",
|
||||
xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
|
||||
xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape',
|
||||
root=True)
|
||||
|
||||
def size(self, unit=MM):
|
||||
(x0, y0), (x1, y1) = self.bounding_box(unit, default=((0, 0), (0, 0)))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
import math
|
||||
from dataclasses import dataclass, KW_ONLY, astuple, replace, fields
|
||||
from dataclasses import dataclass, KW_ONLY, astuple, replace, field, fields
|
||||
|
||||
from .utils import MM, InterpMode
|
||||
from . import graphic_primitives as gp
|
||||
|
|
@ -23,6 +23,7 @@ class GerberObject:
|
|||
_ : KW_ONLY
|
||||
polarity_dark : bool = True
|
||||
unit : str = None
|
||||
attrs : dict = field(default_factory=dict)
|
||||
|
||||
def converted(self, unit):
|
||||
return replace(self,
|
||||
|
|
@ -74,7 +75,7 @@ class Flash(GerberObject):
|
|||
|
||||
def to_primitives(self, unit=None):
|
||||
conv = self.converted(unit)
|
||||
yield from self.aperture.flash(conv.x, conv.y, unit)
|
||||
yield from self.aperture.flash(conv.x, conv.y, unit, self.polarity_dark)
|
||||
|
||||
def to_statements(self, gs):
|
||||
yield from gs.set_polarity(self.polarity_dark)
|
||||
|
|
@ -141,13 +142,14 @@ class Region(GerberObject):
|
|||
self.poly.polarity_dark = self.polarity_dark # FIXME: is this the right spot to do this?
|
||||
if unit == self.unit:
|
||||
yield self.poly
|
||||
|
||||
else:
|
||||
to = lambda value: self.unit.convert_to(unit, value)
|
||||
conv_outline = [ (to(x), to(y)) for x, y in self.poly.outline ]
|
||||
convert_entry = lambda entry: (entry[0], (to(entry[1][0]), to(entry[1][1])))
|
||||
conv_arc = [ None if entry is None else convert_entry(entry) for entry in self.poly.arc_centers ]
|
||||
|
||||
yield gp.ArcPoly(conv_outline, conv_arc)
|
||||
yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark)
|
||||
|
||||
def to_statements(self, gs):
|
||||
yield from gs.set_polarity(self.polarity_dark)
|
||||
|
|
@ -329,7 +331,7 @@ class Arc(GerberObject):
|
|||
conv = self.converted(unit)
|
||||
yield gp.Arc(x1=conv.x1, y1=conv.y1,
|
||||
x2=conv.x2, y2=conv.y2,
|
||||
cx=conv.cx+conv.x1, cy=conv.cy+conv.y1,
|
||||
cx=conv.cx, cy=conv.cy,
|
||||
clockwise=self.clockwise,
|
||||
width=self.aperture.equivalent_width(unit),
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ class Circle(GraphicPrimitive):
|
|||
def bounding_box(self):
|
||||
return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
def to_svg(self, tag, fg, bg):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('circle', cx=self.x, cy=self.y, r=self.r, style=f'fill: {color}')
|
||||
|
||||
|
||||
|
|
@ -75,8 +76,8 @@ class Obround(GraphicPrimitive):
|
|||
def bounding_box(self):
|
||||
return self.to_line().bounding_box()
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
return self.to_line().to_svg(tag, color)
|
||||
def to_svg(self, tag, fg, bg):
|
||||
return self.to_line().to_svg(tag, fg, bg)
|
||||
|
||||
|
||||
def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
||||
|
|
@ -162,22 +163,18 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
|||
return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy)
|
||||
|
||||
|
||||
# FIXME use math.dist instead
|
||||
def point_distance(a, b):
|
||||
return math.sqrt((b[0] - a[0])**2 + (b[1] - a[1])**2)
|
||||
|
||||
def point_line_distance(l1, l2, p):
|
||||
# https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
|
||||
x1, y1 = l1
|
||||
x2, y2 = l2
|
||||
x0, y0 = p
|
||||
length = point_distance(l1, l2)
|
||||
length = math.dist(l1, l2)
|
||||
if math.isclose(length, 0):
|
||||
return point_distance(l1, p)
|
||||
return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length
|
||||
return math.dist(l1, p)
|
||||
return ((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length
|
||||
|
||||
def svg_arc(old, new, center, clockwise):
|
||||
r = math.hypot(*center)
|
||||
d = point_line_distance(old, new, center)
|
||||
# invert sweep flag since the svg y axis is mirrored
|
||||
sweep_flag = int(not clockwise)
|
||||
# In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc"
|
||||
|
|
@ -190,7 +187,8 @@ def svg_arc(old, new, center, clockwise):
|
|||
f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
|
||||
else: # normal case
|
||||
large_arc = int((d > 0) == clockwise)
|
||||
d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1]))
|
||||
large_arc = int((d < 0) == clockwise)
|
||||
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
|
||||
@dataclass
|
||||
|
|
@ -231,7 +229,8 @@ class ArcPoly(GraphicPrimitive):
|
|||
if len(self.outline) == 0:
|
||||
return
|
||||
|
||||
yield f'M {self.outline[0][0]:.6}, {self.outline[0][1]:.6}'
|
||||
yield f'M {self.outline[0][0]:.6} {self.outline[0][1]:.6}'
|
||||
|
||||
for old, new, arc in self.segments:
|
||||
if not arc:
|
||||
yield f'L {new[0]:.6} {new[1]:.6}'
|
||||
|
|
@ -239,7 +238,8 @@ class ArcPoly(GraphicPrimitive):
|
|||
clockwise, center = arc
|
||||
yield svg_arc(old, new, center, clockwise)
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
def to_svg(self, tag, fg, bg):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('path', d=' '.join(self._path_d()), style=f'fill: {color}')
|
||||
|
||||
class Polyline:
|
||||
|
|
@ -270,7 +270,8 @@ class Polyline:
|
|||
else:
|
||||
return False
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
def to_svg(self, tag, fg, bg):
|
||||
color = fg if self.polarity_dark else bg
|
||||
if not self.coords:
|
||||
return None
|
||||
|
||||
|
|
@ -290,7 +291,8 @@ class Line(GraphicPrimitive):
|
|||
r = self.width / 2
|
||||
return add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
def to_svg(self, tag, fg, bg):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
|
||||
style=f'fill: none; stroke: {color}; stroke-width: {self.width:.6}; stroke-linecap: round')
|
||||
|
||||
|
|
@ -310,7 +312,7 @@ class Arc(GraphicPrimitive):
|
|||
r = self.width/2
|
||||
endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
|
||||
|
||||
arc_r = point_distance((self.cx, self.cy), (self.x1, self.y1))
|
||||
arc_r = math.dist((self.cx, self.cy), (self.x1, self.y1))
|
||||
|
||||
# extend C -> P1 line by line width / 2 along radius
|
||||
dx, dy = self.x1 - self.cx, self.y1 - self.cy
|
||||
|
|
@ -325,7 +327,8 @@ class Arc(GraphicPrimitive):
|
|||
arc = arc_bounds(x1, y1, x2, y2, self.cx, self.cy, self.clockwise)
|
||||
return add_bounds(endpoints, arc) # FIXME add "include_center" switch
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
def to_svg(self, tag, fg, bg):
|
||||
color = fg if self.polarity_dark else bg
|
||||
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
|
||||
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}',
|
||||
style=f'fill: none; stroke: {color}; stroke-width: {self.width:.6}; stroke-linecap: round; fill: none')
|
||||
|
|
@ -361,7 +364,8 @@ class Rectangle(GraphicPrimitive):
|
|||
def center(self):
|
||||
return self.x + self.w/2, self.y + self.h/2
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
def to_svg(self, tag, fg, bg):
|
||||
color = fg if self.polarity_dark else bg
|
||||
x, y = self.x - self.w/2, self.y - self.h/2
|
||||
return tag('rect', x=x, y=y, width=self.w, height=self.h,
|
||||
transform=svg_rotation(self.rotation, self.x, self.y), style=f'fill: {color}')
|
||||
|
|
@ -387,6 +391,6 @@ class RegularPolygon(GraphicPrimitive):
|
|||
def bounding_box(self):
|
||||
return self.to_arc_poly().bounding_box()
|
||||
|
||||
def to_svg(self, tag, color='black'):
|
||||
return self.to_arc_poly().to_svg(tag, color)
|
||||
def to_svg(self, tag, fg, bg):
|
||||
return self.to_arc_poly().to_svg(tag, color, fg, bg)
|
||||
|
||||
|
|
|
|||
|
|
@ -256,31 +256,28 @@ class GerberFile(CamFile):
|
|||
|
||||
|
||||
class GraphicsState:
|
||||
polarity_dark : bool = True
|
||||
image_polarity : str = 'positive' # IP image polarity; deprecated
|
||||
point : tuple = None
|
||||
aperture : apertures.Aperture = None
|
||||
file_settings : FileSettings = None
|
||||
interpolation_mode : InterpMode = InterpMode.LINEAR
|
||||
multi_quadrant_mode : bool = None # used only for syntax checking
|
||||
aperture_mirroring = (False, False) # LM mirroring (x, y)
|
||||
aperture_rotation = 0 # LR rotation in degree, ccw
|
||||
aperture_scale = 1 # LS scale factor, NOTE: same for both axes
|
||||
# The following are deprecated file-wide settings. We normalize these during parsing.
|
||||
image_offset : (float, float) = (0, 0)
|
||||
image_rotation: int = 0 # IR image rotation in degree ccw, one of 0, 90, 180 or 270; deprecated
|
||||
image_mirror : tuple = (False, False) # IM image mirroring, (x, y); deprecated
|
||||
image_scale : tuple = (1.0, 1.0) # SF image scaling (x, y); deprecated
|
||||
image_axes : str = 'AXBY' # AS axis mapping; deprecated
|
||||
# for statement generation
|
||||
aperture_map = {}
|
||||
|
||||
|
||||
def __init__(self, file_settings=None, aperture_map=None):
|
||||
self.image_polarity = 'positive' # IP image polarity; deprecated
|
||||
self.polarity_dark = True
|
||||
self.point = None
|
||||
self.aperture = None
|
||||
self.file_settings = None
|
||||
self.interpolation_mode = InterpMode.LINEAR
|
||||
self.multi_quadrant_mode = None # used only for syntax checking
|
||||
self.aperture_mirroring = (False, False) # LM mirroring (x, y)
|
||||
self.aperture_rotation = 0 # LR rotation in degree, ccw
|
||||
self.aperture_scale = 1 # LS scale factor, NOTE: same for both axes
|
||||
# The following are deprecated file-wide settings. We normalize these during parsing.
|
||||
self.image_offset = (0, 0)
|
||||
self.image_rotation = 0 # IR image rotation in degree ccw, one of 0, 90, 180 or 270; deprecated
|
||||
self.image_mirror = (False, False) # IM image mirroring, (x, y); deprecated
|
||||
self.image_scale = (1.0, 1.0) # SF image scaling (x, y); deprecated
|
||||
self.image_axes = 'AXBY' # AS axis mapping; deprecated
|
||||
self._mat = None
|
||||
self.file_settings = file_settings
|
||||
if aperture_map is not None:
|
||||
self.aperture_map = aperture_map
|
||||
self.aperture_map = {}
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
# input validation
|
||||
|
|
@ -299,7 +296,7 @@ class GraphicsState:
|
|||
|
||||
# polarity handling
|
||||
if name == 'image_polarity': # global IP statement image polarity, can only be set at beginning of file
|
||||
if self.image_polarity == 'negative':
|
||||
if getattr(self, 'image_polarity', None) == 'negative':
|
||||
self.polarity_dark = False # evaluated before image_polarity is set below through super().__setattr__
|
||||
|
||||
elif name == 'polarity_dark': # local LP statement polarity for subsequent objects
|
||||
|
|
@ -347,13 +344,15 @@ class GraphicsState:
|
|||
rx, ry = (a*x + b*y), (c*x + d*y)
|
||||
return rx, ry
|
||||
|
||||
def flash(self, x, y):
|
||||
def flash(self, x, y, attrs=None):
|
||||
attrs = attrs or {}
|
||||
self.update_point(x, y)
|
||||
return go.Flash(*self.map_coord(*self.point), self.aperture,
|
||||
polarity_dark=self.polarity_dark,
|
||||
unit=self.file_settings.unit)
|
||||
unit=self.file_settings.unit,
|
||||
attrs=attrs)
|
||||
|
||||
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
|
||||
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False, attrs=None):
|
||||
if self.point is None:
|
||||
warnings.warn('D01 interpolation without preceding D02 move.', SyntaxWarning)
|
||||
self.point = (0, 0)
|
||||
|
|
@ -372,13 +371,13 @@ class GraphicsState:
|
|||
if i is not None or j is not None:
|
||||
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
|
||||
|
||||
return self._create_line(old_point, self.map_coord(*self.point), aperture)
|
||||
return self._create_line(old_point, self.map_coord(*self.point), aperture, attrs)
|
||||
|
||||
else:
|
||||
|
||||
if i is None and j is None:
|
||||
warnings.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values', SyntaxWarning)
|
||||
return self._create_line(old_point, self.map_coord(*self.point), aperture)
|
||||
return self._create_line(old_point, self.map_coord(*self.point), aperture, attrs)
|
||||
|
||||
else:
|
||||
if i is None:
|
||||
|
|
@ -387,26 +386,28 @@ class GraphicsState:
|
|||
if j is None:
|
||||
warnings.warn('Arc is missing J value', SyntaxWarning)
|
||||
j = 0
|
||||
return self._create_arc(old_point, self.map_coord(*self.point), (i, j), aperture, multi_quadrant)
|
||||
return self._create_arc(old_point, self.map_coord(*self.point), (i, j), aperture, multi_quadrant, attrs)
|
||||
|
||||
def _create_line(self, old_point, new_point, aperture=True):
|
||||
def _create_line(self, old_point, new_point, aperture=True, attrs=None):
|
||||
attrs = attrs or {}
|
||||
return go.Line(*old_point, *new_point, self.aperture if aperture else None,
|
||||
polarity_dark=self.polarity_dark, unit=self.file_settings.unit)
|
||||
polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs)
|
||||
|
||||
def _create_arc(self, old_point, new_point, control_point, aperture=True, multi_quadrant=False):
|
||||
def _create_arc(self, old_point, new_point, control_point, aperture=True, multi_quadrant=False, attrs=None):
|
||||
attrs = attrs or {}
|
||||
clockwise = self.interpolation_mode == InterpMode.CIRCULAR_CW
|
||||
|
||||
if not multi_quadrant:
|
||||
return go.Arc(*old_point, *new_point, *self.map_coord(*control_point, relative=True),
|
||||
clockwise=clockwise, aperture=(self.aperture if aperture else None),
|
||||
polarity_dark=self.polarity_dark, unit=self.file_settings.unit)
|
||||
polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs)
|
||||
|
||||
else:
|
||||
# Super-legacy. No one uses this EXCEPT everything that mentor graphics / siemens make uses this m(
|
||||
(cx, cy) = self.map_coord(*control_point, relative=True)
|
||||
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
|
||||
clockwise=clockwise, aperture=(self.aperture if aperture else None),
|
||||
polarity_dark=self.polarity_dark, unit=self.file_settings.unit)
|
||||
polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs)
|
||||
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
|
||||
arcs = [ a for a in arcs if a.sweep_angle() <= math.pi/2 ]
|
||||
arcs = sorted(arcs, key=lambda a: a.numeric_error())
|
||||
|
|
@ -469,7 +470,6 @@ class GerberParser:
|
|||
fr"(I(?P<i>{NUMBER}))?(J(?P<j>{NUMBER}))?" \
|
||||
fr"(?P<operation>D0?[123])?$",
|
||||
'aperture': r"(G54|G55)?D(?P<number>\d+)",
|
||||
'comment': r"G0?4(?P<comment>[^*]*)",
|
||||
# Allegro combines format spec and unit into one long illegal extended command.
|
||||
'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)",
|
||||
'unit_mode': r"MO(?P<unit>(MM|IN))",
|
||||
|
|
@ -493,6 +493,10 @@ class GerberParser:
|
|||
'old_notation': r'(?P<mode>G9[01])',
|
||||
'eof': r"M0?[02]",
|
||||
'ignored': r"(?P<stmt>M01)",
|
||||
# NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense.
|
||||
'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)(,(?P<value>.*))",
|
||||
# Eagle file attributes handled above.
|
||||
'comment': r"G0?4(?P<comment>[^*]*)",
|
||||
}
|
||||
|
||||
STATEMENT_REGEXES = { key: re.compile(value) for key, value in STATEMENT_REGEXES.items() }
|
||||
|
|
@ -514,6 +518,9 @@ class GerberParser:
|
|||
self.last_operation = None
|
||||
self.generator_hints = []
|
||||
self.layer_hints = []
|
||||
self.file_attrs = {}
|
||||
self.object_attrs = {}
|
||||
self.aperture_attrs = {}
|
||||
|
||||
@classmethod
|
||||
def _split_commands(kls, data):
|
||||
|
|
@ -531,7 +538,8 @@ class GerberParser:
|
|||
extended_command = False
|
||||
|
||||
else:
|
||||
# Ignore % inside G04 comments
|
||||
# Ignore % inside G04 comments. Eagle uses a completely borked file attribute syntax with unbalanced
|
||||
# percent signs inside G04 comments.
|
||||
if not data[start:pos].startswith('G04'):
|
||||
extended_command = True
|
||||
|
||||
|
|
@ -685,10 +693,10 @@ class GerberParser:
|
|||
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
|
||||
warnings.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' , SyntaxWarning)
|
||||
|
||||
new_aperture = kls(*modifiers, unit=self.file_settings.unit)
|
||||
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy())
|
||||
|
||||
elif (macro := self.aperture_macros.get(match['shape'])):
|
||||
new_aperture = apertures.ApertureMacroInstance(macro, modifiers, unit=self.file_settings.unit)
|
||||
new_aperture = apertures.ApertureMacroInstance(macro, modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy())
|
||||
|
||||
else:
|
||||
raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
|
||||
|
|
@ -854,6 +862,32 @@ class GerberParser:
|
|||
self.file_settings.notation = 'absolute' if match['mode'] == 'G90' else 'incremental'
|
||||
warnings.warn(f'Deprecated {match["mode"]} notation mode statement found. This deprecated since 2012.', DeprecationWarning)
|
||||
self.target.comments.append('Replaced deprecated {match["mode"]} notation mode statement with FS statement')
|
||||
|
||||
def _parse_attribtue(self, match):
|
||||
if match['type'] == 'TD':
|
||||
if match['value']:
|
||||
raise SyntaxError('TD attribute deletion command must not contain attribute fields')
|
||||
|
||||
if not match['name']:
|
||||
self.object_attrs = {}
|
||||
self.aperture_attrs = {}
|
||||
return
|
||||
|
||||
if match['name'] in self.file_attrs:
|
||||
raise SyntaxError('Attempt to TD delete file attribute. This does not make sense.')
|
||||
elif match['name'] in self.object_attrs:
|
||||
del self.object_attrs[match['name']]
|
||||
elif match['name'] in self.aperture_attrs:
|
||||
del self.aperture_attrs[match['name']]
|
||||
else:
|
||||
raise SyntaxError(f'Attempt to TD delete previously undefined attribute {match["name"]}.')
|
||||
|
||||
else:
|
||||
target = {'TF': self.file_attrs, 'TO': self.object_attrs, 'TA': self.aperture_attrs}[match['type']]
|
||||
target[match['name']] = match['value'].split(',')
|
||||
|
||||
if 'eagle' in self.file_attrs.get('.GenerationSoftware', '').lower() or match['eagle_garbage']:
|
||||
self.generator_hints.append('eagle')
|
||||
|
||||
def _parse_eof(self, _match):
|
||||
self.eof_found = True
|
||||
|
|
@ -885,6 +919,6 @@ if __name__ == '__main__':
|
|||
args = parser.parse_args()
|
||||
|
||||
bounds = (0.0, 0.0), (6.0, 6.0) # bottom left, top right
|
||||
svg = str(GerberFile.open(args.testfile).to_svg(force_bounds=bounds, arg_unit='inch', color='white'))
|
||||
svg = str(GerberFile.open(args.testfile).to_svg(force_bounds=bounds, arg_unit='inch', fg='white', bg='black'))
|
||||
print(svg)
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ def svg_to_png(in_svg, out_png, dpi=100, bg='black'):
|
|||
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
|
||||
|
||||
def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
|
||||
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
|
||||
# and project file color settings.
|
||||
# TODO: File issue upstream.
|
||||
with tempfile.NamedTemporaryFile('w') as f:
|
||||
if override_unit_spec:
|
||||
units, zeros, digits = override_unit_spec
|
||||
|
|
@ -92,6 +95,7 @@ def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6
|
|||
'--border=0',
|
||||
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
|
||||
f'--background={bg}',
|
||||
f'--foreground={fg}',
|
||||
'-o', str(out_svg), '-p', f.name]
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
|
|
|||
|
|
@ -433,14 +433,14 @@ def test_svg_export(reference, tmpfile):
|
|||
|
||||
out_svg = tmpfile('Output', '.svg')
|
||||
with open(out_svg, 'w') as f:
|
||||
f.write(str(grb.to_svg(force_bounds=bounds, arg_unit='inch', color='white')))
|
||||
f.write(str(grb.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white')))
|
||||
|
||||
# NOTE: Instead of having gerbv directly export a PNG, we ask gerbv to output SVG which we then rasterize using
|
||||
# resvg. We have to do this since gerbv's built-in cairo-based PNG export has severe aliasing issues. In contrast,
|
||||
# using resvg for both allows an apples-to-apples comparison of both results.
|
||||
ref_svg = tmpfile('Reference export', '.svg')
|
||||
ref_png = tmpfile('Reference render', '.png')
|
||||
gerbv_export(reference, ref_svg, origin=bounds[0], size=bounds[1])
|
||||
gerbv_export(reference, ref_svg, origin=bounds[0], size=bounds[1], fg='#000000', bg='#ffffff')
|
||||
svg_to_png(ref_svg, ref_png, dpi=72) # make dpi match Cairo's default
|
||||
|
||||
out_png = tmpfile('Output render', '.png')
|
||||
|
|
@ -471,7 +471,7 @@ def test_bounding_box(reference, tmpfile):
|
|||
grb = GerberFile.open(reference)
|
||||
out_svg = tmpfile('Output', '.svg')
|
||||
with open(out_svg, 'w') as f:
|
||||
f.write(str(grb.to_svg(margin=margin, arg_unit='inch', color='white')))
|
||||
f.write(str(grb.to_svg(margin=margin, arg_unit='inch', fg='white', bg='black')))
|
||||
|
||||
out_png = tmpfile('Render', '.png')
|
||||
svg_to_png(out_svg, out_png, dpi=dpi)
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ class Tag:
|
|||
|
||||
def __str__(self):
|
||||
prefix = '<?xml version="1.0" encoding="utf-8"?>\n' if self.root else ''
|
||||
opening = ' '.join([self.name] + [f'{key.replace("__", ":")}="{value}"' for key, value in self.attrs.items()])
|
||||
opening = ' '.join([self.name] + [f'{key.replace("__", ":").replace("_", "-")}="{value}"' for key, value in self.attrs.items()])
|
||||
if self.children:
|
||||
children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children)
|
||||
return f'{prefix}<{opening}>\n{children}\n</{self.name}>'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue