This fixes region rendering with programatically generated primitives
such that clear level polarity works in an intuitive way. This is useful
for e.g. cutouts in regions. Before, the renderer would set level
polarity twice, both when starting the region and then again once for
each region primitive (line or arc). The problem was that the primitives
in a region with "clear" polarity would when constructed with unset
polarity default to "dark". Thus the renderer would emit something like
LPC (clear polarity) -> G36 (start region) -> LPD (dark polarity) ->
{lines...} instead of LPC -> G36 -> {lines...}.
After this commit, Line and Arc will retain None as level polarity when
created with unset level polarity, and region rendering will override
None with the region's polarity. Outside regions, the old dark default
remains unchanged.
Note on verification: Somehow, gEDA gerbv would still render the broken
regions the way one would have intended, but other viewers (KiCAD
gerbview, the online EasyEDA one and whatever JLC uses to make their
silkscreens) would not.
510 lines
18 KiB
Python
510 lines
18 KiB
Python
"""Renders an in-memory Gerber file to statements which can be written to a string
|
|
"""
|
|
from copy import deepcopy
|
|
|
|
try:
|
|
from cStringIO import StringIO
|
|
except(ImportError):
|
|
from io import StringIO
|
|
|
|
from .render import GerberContext
|
|
from ..am_statements import *
|
|
from ..gerber_statements import *
|
|
from ..primitives import AMGroup, Arc, Circle, Line, Obround, Outline, Polygon, Rectangle
|
|
|
|
|
|
class AMGroupContext(object):
|
|
'''A special renderer to generate aperature macros from an AMGroup'''
|
|
|
|
def __init__(self):
|
|
self.statements = []
|
|
|
|
def render(self, amgroup, name):
|
|
|
|
if amgroup.stmt:
|
|
# We know the statement it was generated from, so use that to create the AMParamStmt
|
|
# It will give a much better result
|
|
|
|
stmt = deepcopy(amgroup.stmt)
|
|
stmt.name = name
|
|
|
|
return stmt
|
|
|
|
else:
|
|
# Clone ourselves, then offset by the psotion so that
|
|
# our render doesn't have to consider offset. Just makes things simpler
|
|
nooffset_group = deepcopy(amgroup)
|
|
nooffset_group.position = (0, 0)
|
|
|
|
# Now draw the shapes
|
|
for primitive in nooffset_group.primitives:
|
|
if isinstance(primitive, Outline):
|
|
self._render_outline(primitive)
|
|
elif isinstance(primitive, Circle):
|
|
self._render_circle(primitive)
|
|
elif isinstance(primitive, Rectangle):
|
|
self._render_rectangle(primitive)
|
|
elif isinstance(primitive, Line):
|
|
self._render_line(primitive)
|
|
elif isinstance(primitive, Polygon):
|
|
self._render_polygon(primitive)
|
|
else:
|
|
raise ValueError('amgroup')
|
|
|
|
statement = AMParamStmt('AM', name, self._statements_to_string())
|
|
return statement
|
|
|
|
def _statements_to_string(self):
|
|
macro = ''
|
|
|
|
for statement in self.statements:
|
|
macro += statement.to_gerber()
|
|
|
|
return macro
|
|
|
|
def _render_circle(self, circle):
|
|
self.statements.append(AMCirclePrimitive.from_primitive(circle))
|
|
|
|
def _render_rectangle(self, rectangle):
|
|
self.statements.append(AMCenterLinePrimitive.from_primitive(rectangle))
|
|
|
|
def _render_line(self, line):
|
|
self.statements.append(AMVectorLinePrimitive.from_primitive(line))
|
|
|
|
def _render_outline(self, outline):
|
|
self.statements.append(AMOutlinePrimitive.from_primitive(outline))
|
|
|
|
def _render_polygon(self, polygon):
|
|
self.statements.append(AMPolygonPrimitive.from_primitive(polygon))
|
|
|
|
def _render_thermal(self, thermal):
|
|
pass
|
|
|
|
|
|
class Rs274xContext(GerberContext):
|
|
|
|
def __init__(self, settings):
|
|
GerberContext.__init__(self)
|
|
self.comments = []
|
|
self.header = []
|
|
self.body = []
|
|
self.end = [EofStmt()]
|
|
|
|
# Current values so we know if we have to execute
|
|
# moves, levey changes before anything else
|
|
self._level_polarity = None
|
|
self._pos = (None, None)
|
|
self._func = None
|
|
self._quadrant_mode = None
|
|
self._dcode = None
|
|
|
|
# Primarily for testing and comarison to files, should we write
|
|
# flashes as a single statement or a move plus flash? Set to true
|
|
# to do in a single statement. Normally this can be false
|
|
self.condensed_flash = True
|
|
|
|
# When closing a region, force a D02 staement to close a region.
|
|
# This is normally not necessary because regions are closed with a G37
|
|
# staement, but this will add an extra statement for doubly close
|
|
# the region
|
|
self.explicit_region_move_end = False
|
|
|
|
self._next_dcode = 10
|
|
self._rects = {}
|
|
self._circles = {}
|
|
self._obrounds = {}
|
|
self._polygons = {}
|
|
self._macros = {}
|
|
|
|
self._i_none = 0
|
|
self._j_none = 0
|
|
|
|
self.settings = settings
|
|
|
|
self._start_header(settings)
|
|
|
|
def _start_header(self, settings):
|
|
self.header.append(FSParamStmt.from_settings(settings))
|
|
self.header.append(MOParamStmt.from_units(settings.units))
|
|
|
|
def _simplify_point(self, point):
|
|
return (point[0] if point[0] != self._pos[0] else None, point[1] if point[1] != self._pos[1] else None)
|
|
|
|
def _simplify_offset(self, point, offset):
|
|
|
|
if point[0] != offset[0]:
|
|
xoffset = point[0] - offset[0]
|
|
else:
|
|
xoffset = self._i_none
|
|
|
|
if point[1] != offset[1]:
|
|
yoffset = point[1] - offset[1]
|
|
else:
|
|
yoffset = self._j_none
|
|
|
|
return (xoffset, yoffset)
|
|
|
|
@property
|
|
def statements(self):
|
|
return self.comments + self.header + self.body + self.end
|
|
|
|
def set_bounds(self, bounds, *args, **kwargs):
|
|
pass
|
|
|
|
def paint_background(self):
|
|
pass
|
|
|
|
def _select_aperture(self, aperture):
|
|
|
|
# Select the right aperture if not already selected
|
|
if aperture:
|
|
if isinstance(aperture, Circle):
|
|
aper = self._get_circle(aperture.diameter, aperture.hole_diameter, aperture.hole_width, aperture.hole_height)
|
|
elif isinstance(aperture, Rectangle):
|
|
aper = self._get_rectangle(aperture.width, aperture.height)
|
|
elif isinstance(aperture, Obround):
|
|
aper = self._get_obround(aperture.width, aperture.height)
|
|
elif isinstance(aperture, AMGroup):
|
|
aper = self._get_amacro(aperture)
|
|
else:
|
|
raise NotImplementedError('Line with invalid aperture type')
|
|
|
|
if aper.d != self._dcode:
|
|
self.body.append(ApertureStmt(aper.d))
|
|
self._dcode = aper.d
|
|
|
|
def pre_render_primitive(self, primitive):
|
|
|
|
if hasattr(primitive, 'comment'):
|
|
self.body.append(CommentStmt(primitive.comment))
|
|
|
|
def _render_line(self, line, color, default_polarity='dark'):
|
|
|
|
self._select_aperture(line.aperture)
|
|
|
|
self._render_level_polarity(line, default_polarity)
|
|
|
|
# Get the right function
|
|
if self._func != CoordStmt.FUNC_LINEAR:
|
|
func = CoordStmt.FUNC_LINEAR
|
|
else:
|
|
func = None
|
|
self._func = CoordStmt.FUNC_LINEAR
|
|
|
|
if self._pos != line.start:
|
|
self.body.append(CoordStmt.move(func, self._simplify_point(line.start)))
|
|
self._pos = line.start
|
|
# We already set the function, so the next command doesn't require that
|
|
func = None
|
|
|
|
point = self._simplify_point(line.end)
|
|
|
|
# In some files, we see a lot of duplicated ponts, so omit those
|
|
if point[0] != None or point[1] != None:
|
|
self.body.append(CoordStmt.line(func, self._simplify_point(line.end)))
|
|
self._pos = line.end
|
|
elif func:
|
|
self.body.append(CoordStmt.mode(func))
|
|
|
|
def _render_arc(self, arc, color, default_polarity='dark'):
|
|
|
|
# Optionally set the quadrant mode if it has changed:
|
|
if arc.quadrant_mode != self._quadrant_mode:
|
|
|
|
if arc.quadrant_mode != 'multi-quadrant':
|
|
self.body.append(QuadrantModeStmt.single())
|
|
else:
|
|
self.body.append(QuadrantModeStmt.multi())
|
|
|
|
self._quadrant_mode = arc.quadrant_mode
|
|
|
|
# Select the right aperture if not already selected
|
|
self._select_aperture(arc.aperture)
|
|
|
|
self._render_level_polarity(arc, default_polarity)
|
|
|
|
# Find the right movement mode. Always set to be sure it is really right
|
|
dir = arc.direction
|
|
if dir == 'clockwise':
|
|
func = CoordStmt.FUNC_ARC_CW
|
|
self._func = CoordStmt.FUNC_ARC_CW
|
|
elif dir == 'counterclockwise':
|
|
func = CoordStmt.FUNC_ARC_CCW
|
|
self._func = CoordStmt.FUNC_ARC_CCW
|
|
else:
|
|
raise ValueError('Invalid circular interpolation mode')
|
|
|
|
if self._pos != arc.start:
|
|
# TODO I'm not sure if this is right
|
|
self.body.append(CoordStmt.move(CoordStmt.FUNC_LINEAR, self._simplify_point(arc.start)))
|
|
self._pos = arc.start
|
|
|
|
center = self._simplify_offset(arc.center, arc.start)
|
|
end = self._simplify_point(arc.end)
|
|
self.body.append(CoordStmt.arc(func, end, center))
|
|
self._pos = arc.end
|
|
|
|
def _render_region(self, region, color):
|
|
|
|
self._render_level_polarity(region)
|
|
|
|
self.body.append(RegionModeStmt.on())
|
|
|
|
for p in region.primitives:
|
|
|
|
# Make programmatically generated primitives within a region with
|
|
# unset level polarity inherit the region's level polarity
|
|
if isinstance(p, Line):
|
|
self._render_line(p, color, default_polarity=region.level_polarity)
|
|
else:
|
|
self._render_arc(p, color, default_polarity=region.level_polarity)
|
|
|
|
if self.explicit_region_move_end:
|
|
self.body.append(CoordStmt.move(None, None))
|
|
|
|
self.body.append(RegionModeStmt.off())
|
|
|
|
def _render_level_polarity(self, obj, default='dark'):
|
|
obj_polarity = obj.level_polarity if obj.level_polarity is not None else default
|
|
if obj_polarity != self._level_polarity:
|
|
self._level_polarity = obj_polarity
|
|
self.body.append(LPParamStmt('LP', obj_polarity))
|
|
|
|
def _render_flash(self, primitive, aperture):
|
|
|
|
self._render_level_polarity(primitive)
|
|
|
|
if aperture.d != self._dcode:
|
|
self.body.append(ApertureStmt(aperture.d))
|
|
self._dcode = aperture.d
|
|
|
|
if self.condensed_flash:
|
|
self.body.append(CoordStmt.flash(self._simplify_point(primitive.position)))
|
|
else:
|
|
self.body.append(CoordStmt.move(None, self._simplify_point(primitive.position)))
|
|
self.body.append(CoordStmt.flash(None))
|
|
|
|
self._pos = primitive.position
|
|
|
|
def _get_circle(self, diameter, hole_diameter=None, hole_width=None,
|
|
hole_height=None, dcode = None):
|
|
'''Define a circlar aperture'''
|
|
|
|
key = (diameter, hole_diameter, hole_width, hole_height)
|
|
aper = self._circles.get(key, None)
|
|
|
|
if not aper:
|
|
if not dcode:
|
|
dcode = self._next_dcode
|
|
self._next_dcode += 1
|
|
else:
|
|
self._next_dcode = max(dcode + 1, self._next_dcode)
|
|
|
|
aper = ADParamStmt.circle(dcode, diameter, hole_diameter, hole_width, hole_height)
|
|
self._circles[(diameter, hole_diameter, hole_width, hole_height)] = aper
|
|
self.header.append(aper)
|
|
|
|
return aper
|
|
|
|
def _render_circle(self, circle, color):
|
|
|
|
aper = self._get_circle(circle.diameter, circle.hole_diameter, circle.hole_width, circle.hole_height)
|
|
self._render_flash(circle, aper)
|
|
|
|
def _get_rectangle(self, width, height, hole_diameter=None, hole_width=None,
|
|
hole_height=None, dcode = None):
|
|
'''Get a rectanglar aperture. If it isn't defined, create it'''
|
|
|
|
key = (width, height, hole_diameter, hole_width, hole_height)
|
|
aper = self._rects.get(key, None)
|
|
|
|
if not aper:
|
|
if not dcode:
|
|
dcode = self._next_dcode
|
|
self._next_dcode += 1
|
|
else:
|
|
self._next_dcode = max(dcode + 1, self._next_dcode)
|
|
|
|
aper = ADParamStmt.rect(dcode, width, height, hole_diameter, hole_width, hole_height)
|
|
self._rects[(width, height, hole_diameter, hole_width, hole_height)] = aper
|
|
self.header.append(aper)
|
|
|
|
return aper
|
|
|
|
def _render_rectangle(self, rectangle, color):
|
|
|
|
aper = self._get_rectangle(rectangle.width, rectangle.height,
|
|
rectangle.hole_diameter,
|
|
rectangle.hole_width, rectangle.hole_height)
|
|
self._render_flash(rectangle, aper)
|
|
|
|
def _get_obround(self, width, height, hole_diameter=None, hole_width=None,
|
|
hole_height=None, dcode = None):
|
|
|
|
key = (width, height, hole_diameter, hole_width, hole_height)
|
|
aper = self._obrounds.get(key, None)
|
|
|
|
if not aper:
|
|
if not dcode:
|
|
dcode = self._next_dcode
|
|
self._next_dcode += 1
|
|
else:
|
|
self._next_dcode = max(dcode + 1, self._next_dcode)
|
|
|
|
aper = ADParamStmt.obround(dcode, width, height, hole_diameter, hole_width, hole_height)
|
|
self._obrounds[key] = aper
|
|
self.header.append(aper)
|
|
|
|
return aper
|
|
|
|
def _render_obround(self, obround, color):
|
|
|
|
aper = self._get_obround(obround.width, obround.height,
|
|
obround.hole_diameter, obround.hole_width,
|
|
obround.hole_height)
|
|
self._render_flash(obround, aper)
|
|
|
|
def _render_polygon(self, polygon, color):
|
|
|
|
aper = self._get_polygon(polygon.radius, polygon.sides,
|
|
polygon.rotation, polygon.hole_diameter,
|
|
polygon.hole_width, polygon.hole_height)
|
|
self._render_flash(polygon, aper)
|
|
|
|
def _get_polygon(self, radius, num_vertices, rotation, hole_diameter=None,
|
|
hole_width=None, hole_height=None, dcode = None):
|
|
|
|
key = (radius, num_vertices, rotation, hole_diameter, hole_width, hole_height)
|
|
aper = self._polygons.get(key, None)
|
|
|
|
if not aper:
|
|
if not dcode:
|
|
dcode = self._next_dcode
|
|
self._next_dcode += 1
|
|
else:
|
|
self._next_dcode = max(dcode + 1, self._next_dcode)
|
|
|
|
aper = ADParamStmt.polygon(dcode, radius * 2, num_vertices,
|
|
rotation, hole_diameter, hole_width,
|
|
hole_height)
|
|
self._polygons[key] = aper
|
|
self.header.append(aper)
|
|
|
|
return aper
|
|
|
|
def _render_drill(self, drill, color):
|
|
raise ValueError('Drills are not valid in RS274X files')
|
|
|
|
def _hash_amacro(self, amgroup):
|
|
'''Calculate a very quick hash code for deciding if we should even check AM groups for comparision'''
|
|
|
|
# We always start with an X because this forms part of the name
|
|
# Basically, in some cases, the name might start with a C, R, etc. That can appear
|
|
# to conflict with normal aperture definitions. Technically, it shouldn't because normal
|
|
# aperture definitions should have a comma, but in some cases the commit is omitted
|
|
hash = 'X'
|
|
for primitive in amgroup.primitives:
|
|
|
|
hash += primitive.__class__.__name__[0]
|
|
|
|
bbox = primitive.bounding_box
|
|
hash += str((bbox[0][1] - bbox[0][0]) * 100000)[0:2]
|
|
hash += str((bbox[1][1] - bbox[1][0]) * 100000)[0:2]
|
|
|
|
if hasattr(primitive, 'primitives'):
|
|
hash += str(len(primitive.primitives))
|
|
|
|
if isinstance(primitive, Rectangle):
|
|
hash += str(primitive.width * 1000000)[0:2]
|
|
hash += str(primitive.height * 1000000)[0:2]
|
|
elif isinstance(primitive, Circle):
|
|
hash += str(primitive.diameter * 1000000)[0:2]
|
|
|
|
if len(hash) > 20:
|
|
# The hash might actually get quite complex, so stop before
|
|
# it gets too long
|
|
break
|
|
|
|
return hash
|
|
|
|
def _get_amacro(self, amgroup, dcode = None):
|
|
# Macros are a little special since we don't have a good way to compare them quickly
|
|
# but in most cases, this should work
|
|
|
|
hash = self._hash_amacro(amgroup)
|
|
macro = None
|
|
macroinfo = self._macros.get(hash, None)
|
|
|
|
if macroinfo:
|
|
|
|
# We have a definition, but check that the groups actually are the same
|
|
for macro in macroinfo:
|
|
|
|
# Macros should have positions, right? But if the macro is selected for non-flashes
|
|
# then it won't have a position. This is of course a bad gerber, but they do exist
|
|
if amgroup.position:
|
|
position = amgroup.position
|
|
else:
|
|
position = (0, 0)
|
|
|
|
offset = (position[0] - macro[1].position[0], position[1] - macro[1].position[1])
|
|
if amgroup.equivalent(macro[1], offset):
|
|
break
|
|
macro = None
|
|
|
|
# Did we find one in the group0
|
|
if not macro:
|
|
# This is a new macro, so define it
|
|
if not dcode:
|
|
dcode = self._next_dcode
|
|
self._next_dcode += 1
|
|
else:
|
|
self._next_dcode = max(dcode + 1, self._next_dcode)
|
|
|
|
# Create the statements
|
|
# TODO
|
|
amrenderer = AMGroupContext()
|
|
statement = amrenderer.render(amgroup, hash)
|
|
|
|
self.header.append(statement)
|
|
|
|
aperdef = ADParamStmt.macro(dcode, hash)
|
|
self.header.append(aperdef)
|
|
|
|
# Store the dcode and the original so we can check if it really is the same
|
|
# If it didn't have a postition, set it to 0, 0
|
|
if amgroup.position == None:
|
|
amgroup.position = (0, 0)
|
|
macro = (aperdef, amgroup)
|
|
|
|
if macroinfo:
|
|
macroinfo.append(macro)
|
|
else:
|
|
self._macros[hash] = [macro]
|
|
|
|
return macro[0]
|
|
|
|
def _render_amgroup(self, amgroup, color):
|
|
|
|
aper = self._get_amacro(amgroup)
|
|
self._render_flash(amgroup, aper)
|
|
|
|
def _render_inverted_layer(self):
|
|
pass
|
|
|
|
def new_render_layer(self):
|
|
# TODO Might need to implement this
|
|
pass
|
|
|
|
def flatten(self):
|
|
# TODO Might need to implement this
|
|
pass
|
|
|
|
def dump(self):
|
|
"""Write the rendered file to a StringIO steam"""
|
|
statements = map(lambda stmt: stmt.to_gerber(self.settings), self.statements)
|
|
stream = StringIO()
|
|
for statement in statements:
|
|
stream.write(statement + '\n')
|
|
|
|
return stream
|