From 27a992f1c8c0a37245168e23db160412494d0e18 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 1 Jan 2022 17:47:50 +0100 Subject: Add dilation code --- gerbonara/gerber/aperture_macros/expression.py | 41 ++++++++++++++++++++++++++ gerbonara/gerber/aperture_macros/parse.py | 14 +++++++++ gerbonara/gerber/aperture_macros/primitive.py | 29 ++++++++++++++++++ gerbonara/gerber/apertures.py | 27 +++++++++++++++++ gerbonara/gerber/rs274x.py | 41 ++++++++++++++++++++++++-- gerbonara/gerber/tests/image_support.py | 2 +- 6 files changed, 150 insertions(+), 4 deletions(-) (limited to 'gerbonara/gerber') diff --git a/gerbonara/gerber/aperture_macros/expression.py b/gerbonara/gerber/aperture_macros/expression.py index 390b7b7..fb399d3 100644 --- a/gerbonara/gerber/aperture_macros/expression.py +++ b/gerbonara/gerber/aperture_macros/expression.py @@ -90,6 +90,47 @@ class UnitExpression(Expression): else: raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".') + def __add__(self, other): + if not isinstance(other, UnitExpression): + raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.') + + if self.unit == other.unit or self.unit is None or other.unit is None: + return UnitExpression(self._expr + other._expr, self.unit) + + if other.unit == 'mm': # -> and self.unit == 'inch' + return UnitExpression(self._expr + (other._expr / MILLIMETERS_PER_INCH), self.unit) + else: # other.unit == 'inch' and self.unit == 'mm' + return UnitExpression(self._expr + (other._expr * MILLIMETERS_PER_INCH), self.unit) + + def __radd__(self, other): + # left hand side cannot have been an UnitExpression or __radd__ would not have been called + raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.') + + def __sub__(self, other): + return (self + (-other)).optimize() + + def __rsub__(self, other): + # see __radd__ above + raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.') + + def __mul__(self, other): + return UnitExpression(self._expr * other, self.unit) + + def __rmul__(self, other): + return UnitExpression(other * self._expr, self.unit) + + def __truediv__(self, other): + return UnitExpression(self._expr / other, self.unit) + + def __rtruediv__(self, other): + return UnitExpression(other / self._expr, self.unit) + + def __neg__(self): + return UnitExpression(-self._expr, self.unit) + + def __pos__(self): + return self + class ConstantExpression(Expression): def __init__(self, value): diff --git a/gerbonara/gerber/aperture_macros/parse.py b/gerbonara/gerber/aperture_macros/parse.py index 86f2882..00227c6 100644 --- a/gerbonara/gerber/aperture_macros/parse.py +++ b/gerbonara/gerber/aperture_macros/parse.py @@ -98,6 +98,20 @@ class ApertureMacro: def __hash__(self): return hash(self.to_gerber()) + def dilated(self, offset, unit='mm'): + dup = copy.deepcopy(self) + new_primitives = [] + for primitive in dup.primitives: + try: + if primitive.exposure.calculate(): + primitive.dilate(offset, unit) + new_primitives.append(primitive) + except IndexError: + warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.') + pass + dup.primitives = new_primitives + return dup + def to_gerber(self, unit=None): comments = [ c.to_gerber() for c in self.comments ] variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in self.variables.items() ] diff --git a/gerbonara/gerber/aperture_macros/primitive.py b/gerbonara/gerber/aperture_macros/primitive.py index a587d7e..b28fdb5 100644 --- a/gerbonara/gerber/aperture_macros/primitive.py +++ b/gerbonara/gerber/aperture_macros/primitive.py @@ -4,6 +4,7 @@ # Copyright 2019 Hiroshi Murayama # Copyright 2022 Jan Götte +import warnings import contextlib import math @@ -20,6 +21,13 @@ def point_distance(a, b): def deg_to_rad(a): return (a / 180) * math.pi +def convert(value, src, dst): + if src == dst or src is None or dst is None or value is None: + return value + elif dst == 'mm': + return value * 25.4 + else: + return value / 25.4 class Primitive: def __init__(self, unit, args): @@ -88,6 +96,9 @@ class Circle(Primitive): x, y = x+offset[0], y+offset[1] return [ gp.Circle(x, y, calc.r, polarity_dark=bool(calc.exposure)) ] + def dilate(self, offset, unit): + self.diameter += UnitExpression(offset, unit) + class VectorLine(Primitive): code = 20 exposure : Expression @@ -112,6 +123,9 @@ class VectorLine(Primitive): return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation, polarity_dark=bool(calc.exposure)) ] + def dilate(self, offset, unit): + self.width += UnitExpression(2*offset, unit) + class CenterLine(Primitive): code = 21 @@ -131,6 +145,9 @@ class CenterLine(Primitive): w, h = calc.width, calc.height return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=bool(calc.exposure)) ] + + def dilate(self, offset, unit): + self.width += UnitExpression(2*offset, unit) class Polygon(Primitive): @@ -151,6 +168,9 @@ class Polygon(Primitive): return [ gp.RegularPolygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation, polarity_dark=bool(calc.exposure)) ] + def dilate(self, offset, unit): + self.diameter += UnitExpression(2*offset, unit) + class Thermal(Primitive): code = 7 @@ -178,6 +198,11 @@ class Thermal(Primitive): gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark), ] + def dilate(self, offset, unit): + # I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than + # producing macros that may evaluate to primitives with negative values. + warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.') + class Outline(Primitive): code = 4 @@ -220,6 +245,10 @@ class Outline(Primitive): return gp.ArcPoly(bound_coords, bound_radii, polarity_dark=calc.exposure) + def dilate(self, offset, unit): + # we would need a whole polygon offset/clipping library here + warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.') + class Comment: code = 0 diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index 2723c8d..7c98775 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -50,6 +50,14 @@ class Aperture: else: return value / 25.4 + def convert_from(self, value, unit): + if self.unit == unit or self.unit is None or unit is None or value is None: + return value + elif unit == 'mm': + return value / 25.4 + else: + return value * 25.4 + def params(self, unit=None): out = [] for f in fields(self): @@ -112,6 +120,10 @@ class CircleAperture(Aperture): def equivalent_width(self): return self.diameter + def dilated(self, offset, unit='mm'): + offset = self.convert_from(offset, unit) + return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None) + def _rotated(self): if math.isclose(self.rotation % (2*math.pi), 0) or self.hole_rect_h is None: return self @@ -150,6 +162,10 @@ class RectangleAperture(Aperture): def equivalent_width(self): return math.sqrt(self.w**2 + self.h**2) + def dilated(self, offset, unit='mm'): + offset = self.convert_from(offset, unit) + return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None) + def _rotated(self): if math.isclose(self.rotation % math.pi, 0): return self @@ -192,6 +208,10 @@ class ObroundAperture(Aperture): flash = _flash_hole + def dilated(self, offset, unit='mm'): + offset = self.convert_from(offset, unit) + return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None) + def _rotated(self): if math.isclose(self.rotation % math.pi, 0): return self @@ -232,6 +252,10 @@ class PolygonAperture(Aperture): def __str__(self): return f'<{self.n_vertices}-gon aperture d={self.diameter:.3}' + def dilated(self, offset, unit='mm'): + offset = self.convert_from(offset, unit) + return replace(self, diameter=self.diameter+2*offset, hole_dia=None) + flash = _flash_hole def _rotated(self): @@ -266,6 +290,9 @@ class ApertureMacroInstance(Aperture): # 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 dilated(self, offset, unit='mm'): + return replace(self, macro=self.macro.dilated(offset, unit)) + def _rotated(self): if math.isclose(self.rotation % (2*math.pi), 0): return self diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 4313a11..38dfe06 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -49,6 +49,16 @@ def convert(self, value, src, dst): else: return value / 25.4 +def points_close(a, b): + if a == b: + return True + elif a is None or b is None: + return False + elif None in a or None in b: + return False + else: + return math.isclose(a[0], b[0]) and math.isclose(a[1], b[1]) + class GerberFile(CamFile): """ A class representing a single gerber file @@ -102,6 +112,32 @@ class GerberFile(CamFile): macro.name = new_name 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) + self.apertures.append(offset_circle) + + new_primitives = [] + for p in self.primitives: + + p.polarity_dark = polarity_dark + + # Ignore Line, Arc, Flash. Their actual dilation has already been done by dilating the apertures above. + if isinstance(p, Region): + ol = p.poly.outline + for start, end, arc_center in zip(ol, ol[1:] + ol[0], p.poly.arc_centers): + if arc_center is not None: + new_primitives.append(Arc(*start, *end, *arc_center, + polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle)) + + else: + new_primitives.append(Line(*start, *end, + polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle)) + + # it's safe to append these at the end since we compute a logical OR of opaque areas anyway. + self.primitives.extend(new_primitives) @classmethod def open(kls, filename, enable_includes=False, enable_include_dir=None): @@ -406,14 +442,13 @@ class GraphicsState: yield ApertureStmt(self.aperture_map[id(aperture)]) def set_current_point(self, point, unit=None): - # FIXME use math.isclose for point comparisons here and elsewhere due to converted coords - # FIXME maybe even calculate appropriate precision given file_settings.notation + # TODO calculate appropriate precision for math.isclose given file_settings.notation if unit == 'inch': point_mm = point[0]*25.4, point[1]*25.4 else: point_mm = point - if self.point != point_mm: + if not points_close(self.point, point_mm): self.point = point_mm yield MoveStmt(*point, unit=unit) diff --git a/gerbonara/gerber/tests/image_support.py b/gerbonara/gerber/tests/image_support.py index 9c80eec..bb202ab 100644 --- a/gerbonara/gerber/tests/image_support.py +++ b/gerbonara/gerber/tests/image_support.py @@ -92,7 +92,7 @@ def cleanup_clips(soup): # canvas size. This is just wrong, so we just nuke the clip path from these SVG groups here. # # Apart from being graphically broken, this additionally causes very bad rendering performance. - del group['clip-path'] # remove broken clip + del group['clip-path'] def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10)): with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\ -- cgit