Multi-quadrant code still borked

This commit is contained in:
jaseg 2022-01-23 17:54:47 +01:00
parent 4ed8358096
commit 940cf9df6e
6 changed files with 79 additions and 35 deletions

View file

@ -168,6 +168,8 @@ class CamFile:
max_x = svg_unit(max_x, arg_unit)
max_y = svg_unit(max_y, arg_unit)
content_min_x, content_min_y = min_x, min_y
content_w, content_h = max_x - min_x, max_y - min_y
if margin:
margin = svg_unit(margin, arg_unit)
min_x -= margin
@ -201,7 +203,7 @@ class CamFile:
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})'
xform = f'translate({content_min_x} {content_min_y+content_h}) scale(1 -1) translate({-content_min_x} {-content_min_y})'
svg_unit = 'in' if svg_unit == 'inch' else 'mm'
# TODO export apertures as <uses> where reasonable.
@ -231,5 +233,10 @@ class CamFile:
max_x = max(x1 for (x0, y0), (x1, y1) in bounds)
max_y = max(y1 for (x0, y0), (x1, y1) in bounds)
#for p in self.objects:
# bb = (o_min_x, o_min_y), (o_max_x, o_max_y) = p.bounding_box(unit)
# if o_min_x == min_x or o_min_y == min_y or o_max_x == max_x or o_max_y == max_y:
# print('\033[91m bounds\033[0m', bb, p)
return ((min_x, min_y), (max_x, max_y))

View file

@ -108,7 +108,7 @@ class ExcellonFile(CamFile):
self.generator_hints = generator_hints or [] # This is a purely informational goodie from the parser. Use it as you wish.
def __bool__(self):
return bool(self.objects)
return not self.is_empty
@property
def is_plated(self):
@ -257,8 +257,9 @@ class ExcellonFile(CamFile):
def is_nonplated(self):
return not any(obj.plated for obj in self.objects)
def empty(self):
return self.objects.empty()
@property
def is_empty(self):
return not self.objects
def __len__(self):
return len(self.objects)

View file

@ -226,7 +226,8 @@ class Line(GerberObject):
def to_primitives(self, unit=None):
conv = self.converted(unit)
yield gp.Line(*conv.p1, *conv.p2, self.aperture.equivalent_width(unit), polarity_dark=self.polarity_dark)
w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
yield gp.Line(*conv.p1, *conv.p2, w, polarity_dark=self.polarity_dark)
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)
@ -277,16 +278,22 @@ class Arc(GerberObject):
return abs(r1 - r2)
def sweep_angle(self):
f = math.atan2(self.x2, self.y2) - math.atan2(self.x1, self.y1)
f = (f + math.pi) % (2*math.pi) - math.pi
cx, cy = self.cx + self.x1, self.cy + self.y1
x1, y1 = self.x1 - cx, self.y1 - cy
x2, y2 = self.x2 - cx, self.y2 - cy
if self.clockwise:
f = -f
if f > math.pi:
f = 2*math.pi - f
return f
a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2)
f = abs(a2 - a1)
if not self.clockwise:
if a2 > a1:
return a2 - a1
else:
return 2*math.pi - abs(a2 - a1)
else:
if a1 > a2:
return a1 - a2
else:
return 2*math.pi - abs(a1 - a2)
@property
def p1(self):
@ -329,11 +336,12 @@ class Arc(GerberObject):
def to_primitives(self, unit=None):
conv = self.converted(unit)
w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
yield gp.Arc(x1=conv.x1, y1=conv.y1,
x2=conv.x2, y2=conv.y2,
cx=conv.cx, cy=conv.cy,
clockwise=self.clockwise,
width=self.aperture.equivalent_width(unit),
width=w,
polarity_dark=self.polarity_dark)
def to_statements(self, gs):

View file

@ -89,8 +89,12 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
#
# This solution manages to handle circular arcs given in gerber format (with explicit center and endpoints, plus
# sweep direction instead of a format with e.g. angles and radius) without any trigonometric functions (e.g. atan2).
#
# cx, cy are relative to p1.
# Center arc on cx, cy
cx += x1
cy += y1
x1 -= cx
x2 -= cx
y1 -= cy
@ -212,7 +216,7 @@ class ArcPoly(GraphicPrimitive):
for (x1, y1), (x2, y2), arc in self.segments:
if arc:
clockwise, (cx, cy) = arc
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, (cx+x1, cy+y1), clockwise))
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
else:
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
@ -277,7 +281,8 @@ class Polyline:
(x0, y0), *rest = self.coords
d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest)
return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {self.width:.6}; stroke-linecap: round')
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round')
@dataclass
class Line(GraphicPrimitive):
@ -293,8 +298,9 @@ class Line(GraphicPrimitive):
def to_svg(self, tag, fg, bg):
color = fg if self.polarity_dark else bg
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
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')
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round')
@dataclass
class Arc(GraphicPrimitive):
@ -330,8 +336,9 @@ class Arc(GraphicPrimitive):
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)
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
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')
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round; fill: none')
def svg_rotation(angle_rad, cx=0, cy=0):
return f'rotate({float(rad_to_deg(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'

View file

@ -54,7 +54,7 @@ class GerberFile(CamFile):
"""
def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator_hints=None,
layer_hints=None):
layer_hints=None, file_attrs=None):
super().__init__(filename=filename)
self.objects = objects or []
self.comments = comments or []
@ -62,6 +62,7 @@ class GerberFile(CamFile):
self.layer_hints = layer_hints or []
self.import_settings = import_settings
self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
self.file_attrs = file_attrs or {}
def to_excellon(self):
new_objs = []
@ -125,7 +126,6 @@ class GerberFile(CamFile):
seen_macro_names.add(new_name)
def dilate(self, offset, unit=MM, polarity_dark=True):
self.apertures = [ aperture.dilated(offset, unit) for aperture in self.apertures ]
offset_circle = CircleAperture(offset, unit=unit)
@ -166,6 +166,9 @@ class GerberFile(CamFile):
def generate_statements(self, settings, drop_comments=True):
yield 'G04 Gerber file generated by Gerbonara*'
for name, value in self.file_attrs.items():
attrdef = ','.join([name, *map(str, value)])
yield f'%TF{attrdef}*%'
yield '%MOMM*%' if (settings.unit == 'mm') else '%MOIN*%'
zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified
@ -224,6 +227,16 @@ class GerberFile(CamFile):
settings.number_format = (5,6)
return '\n'.join(self.generate_statements(settings))
@property
def is_empty(self):
return not self.objects
def __len__(self):
return len(self.objects)
def __bool__(self):
return not self.is_empty
def offset(self, dx=0, dy=0, unit=MM):
# TODO round offset to file resolution
@ -275,9 +288,7 @@ class GraphicsState:
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 = {}
self.aperture_map = aperture_map or {}
def __setattr__(self, name, value):
# input validation
@ -408,7 +419,11 @@ class GraphicsState:
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
clockwise=clockwise, aperture=(self.aperture if aperture else None),
polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs)
print('arcs:')
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
for a in arcs:
print(f'{a.sweep_angle()=} {a.numeric_error()=} {a}')
print(GerberFile(arcs).to_svg())
arcs = [ a for a in arcs if a.sweep_angle() <= math.pi/2 ]
arcs = sorted(arcs, key=lambda a: a.numeric_error())
return arcs[0]
@ -585,6 +600,7 @@ class GerberParser:
self.target.apertures = list(self.aperture_map.values())
self.target.import_settings = self.file_settings
self.target.unit = self.file_settings.unit
self.target.file_attrs = self.file_attrs
if not self.eof_found:
warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning)
@ -863,7 +879,7 @@ class GerberParser:
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):
def _parse_attribute(self, match):
if match['type'] == 'TD':
if match['value']:
raise SyntaxError('TD attribute deletion command must not contain attribute fields')
@ -886,7 +902,7 @@ class GerberParser:
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']:
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
self.generator_hints.append('eagle')
def _parse_eof(self, _match):

View file

@ -456,19 +456,22 @@ def test_svg_export(reference, tmpfile):
@filter_syntax_warnings
@pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True)
def test_bounding_box(reference, tmpfile):
if reference.name == 'MinnowMax_assy.art':
# This leads to worst-case performance in resvg, this testcase takes over 1h to finish. So skip.
pytest.skip()
# skip this check on files that contain lines with a zero-size aperture at the board edge
if any(reference.match(f'*/{f}') for f in HAS_ZERO_SIZE_APERTURES):
pytest.skip()
# skip this file because it does not contain any graphical objects
if reference.match('*/multiline_read.ger'):
pytest.skip()
margin = 1.0 # inch
dpi = 200
margin_px = int(dpi*margin) # intentionally round down to avoid aliasing artifacts
grb = GerberFile.open(reference)
if grb.is_empty:
pytest.skip()
out_svg = tmpfile('Output', '.svg')
with open(out_svg, 'w') as f:
f.write(str(grb.to_svg(margin=margin, arg_unit='inch', fg='white', bg='black')))
@ -484,11 +487,13 @@ def test_bounding_box(reference, tmpfile):
rows = img.sum(axis=0)
col_prefix, col_suffix = np.argmax(cols > 0), np.argmax(cols[::-1] > 0)
row_prefix, row_suffix = np.argmax(rows > 0), np.argmax(rows[::-1] > 0)
print('cols:', col_prefix, col_suffix)
print('rows:', row_prefix, row_suffix)
# Check that all margins are completely black and that the content touches the margins. Allow for some tolerance to
# allow for antialiasing artifacts.
assert margin_px-1 <= col_prefix <= margin_px+1
assert margin_px-1 <= col_suffix <= margin_px+1
assert margin_px-1 <= row_prefix <= margin_px+1
assert margin_px-1 <= row_suffix <= margin_px+1
# allow for antialiasing artifacts and for things like very thin features.
assert margin_px-2 <= col_prefix <= margin_px+2
assert margin_px-2 <= col_suffix <= margin_px+2
assert margin_px-2 <= row_prefix <= margin_px+2
assert margin_px-2 <= row_suffix <= margin_px+2