summaryrefslogtreecommitdiff
path: root/gerbonara/gerber
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara/gerber')
-rw-r--r--gerbonara/gerber/apertures.py17
-rw-r--r--gerbonara/gerber/cam.py114
-rwxr-xr-xgerbonara/gerber/excellon.py120
-rw-r--r--gerbonara/gerber/graphic_objects.py4
-rw-r--r--gerbonara/gerber/rs274x.py81
-rw-r--r--gerbonara/gerber/tests/test_excellon.py13
-rw-r--r--gerbonara/gerber/tests/test_rs274x.py84
-rw-r--r--gerbonara/gerber/utils.py5
8 files changed, 187 insertions, 251 deletions
diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py
index 3d3c074..0f723d4 100644
--- a/gerbonara/gerber/apertures.py
+++ b/gerbonara/gerber/apertures.py
@@ -26,6 +26,13 @@ def strip_right(*args):
args.pop()
return args
+def none_close(a, b):
+ if a is None and b is None:
+ return True
+ elif a is not None and b is not None:
+ return math.isclose(a, b)
+ else:
+ return False
class Length:
def __init__(self, obj_type):
@@ -88,13 +95,13 @@ class ExcellonTool(Aperture):
diameter : Length(float)
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 to_xnc(self, settings):
- z_off += 'Z' + settings.write_gerber_value(self.depth_offset) if self.depth_offset is not None else ''
- return 'C' + settings.write_gerber_value(self.diameter) + z_off
+ z_off = 'Z' + settings.write_excellon_value(self.depth_offset) if self.depth_offset is not None else ''
+ return 'C' + settings.write_excellon_value(self.diameter) + z_off
def __eq__(self, other):
if not isinstance(other, ExcellonTool):
@@ -103,10 +110,10 @@ class ExcellonTool(Aperture):
if not self.plated == other.plated:
return False
- if not math.isclose(self.depth_offset, self.unit(other.depth_offset, other.unit)):
+ if not none_close(self.depth_offset, self.unit(other.depth_offset, other.unit)):
return False
- return math.isclose(self.diameter, self.unit(other.diameter, other.unit))
+ return none_close(self.diameter, self.unit(other.diameter, other.unit))
def __str__(self):
plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated')
diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py
index 57ac000..bbcd042 100644
--- a/gerbonara/gerber/cam.py
+++ b/gerbonara/gerber/cam.py
@@ -33,7 +33,7 @@ class FileSettings:
`zeros='trailing'`
'''
notation : str = 'absolute'
- unit : LengthUnit = Inch
+ unit : LengthUnit = MM
angle_unit : str = 'degree'
zeros : bool = None
number_format : tuple = (2, 5)
@@ -52,7 +52,7 @@ class FileSettings:
if len(value) != 2:
raise ValueError(f'Number format must be a (integer, fractional) tuple of integers, not {value}')
- if value[0] > 6 or value[1] > 7:
+ if value != (None, None) and (value[0] > 6 or value[1] > 7):
raise ValueError(f'Requested precision of {value} is too high. Only up to 6.7 digits are supported by spec.')
@@ -131,44 +131,92 @@ class FileSettings:
return sign + (num or '0')
+ def write_excellon_value(self, value, unit=None):
+ if unit is not None:
+ value = self.unit(value, unit)
+
+ integer_digits, decimal_digits = self.number_format
+ if integer_digits is None:
+ integer_digits = 2
+ if decimal_digits is None:
+ decimal_digits = 6
+
+ return format(value, f'0{integer_digits+decimal_digits+1}.{decimal_digits}f')
+
+
+class Tag:
+ def __init__(self, name, children=None, root=False, **attrs):
+ self.name, self.attrs = name, attrs
+ self.children = children or []
+ self.root = root
+
+ 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()])
+ if self.children:
+ children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children)
+ return f'{prefix}<{opening}>\n{children}\n</{self.name}>'
+ else:
+ return f'{prefix}<{opening}/>'
+
class CamFile:
def __init__(self, filename=None, layer_name=None):
self.filename = filename
self.layer_name = layer_name
self.import_settings = None
-
- @property
- def bounds(self):
- """ File boundaries
+ self.objects = []
+
+ def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color='black'):
+
+ if force_bounds is None:
+ (min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
+ else:
+ (min_x, min_y), (max_x, max_y) = force_bounds
+ min_x = svg_unit(min_x, arg_unit)
+ min_y = svg_unit(min_y, arg_unit)
+ max_x = svg_unit(max_x, arg_unit)
+ max_y = svg_unit(max_y, arg_unit)
+
+ if margin:
+ margin = svg_unit(margin, arg_unit)
+ min_x -= margin
+ min_y -= margin
+ max_x += margin
+ max_y += margin
+
+ w, h = max_x - min_x, max_y - min_y
+ w = 1.0 if math.isclose(w, 0.0) else w
+ h = 1.0 if math.isclose(h, 0.0) else h
+
+ primitives = [ prim.to_svg(tag, color) for obj in self.objects for prim in obj.to_primitives(unit=svg_unit) ]
+
+ # 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', primitives, 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)
+
+ def size(self, unit=MM):
+ (x0, y0), (x1, y1) = self.bounding_box(unit, default=((0, 0), (0, 0)))
+ return (x1 - x0, y1 - y0)
+
+ def bounding_box(self, unit=MM, default=None):
+ """ Calculate bounding box of file. Returns value given by 'default' argument when there are no graphical
+ objects (default: None)
"""
- pass
-
- @property
- def bounding_box(self):
- pass
+ bounds = [ p.bounding_box(unit) for p in self.objects ]
+ if not bounds:
+ return default
- def render(self, ctx=None, invert=False, filename=None):
- """ Generate image of layer.
+ min_x = min(x0 for (x0, y0), (x1, y1) in bounds)
+ min_y = min(y0 for (x0, y0), (x1, y1) in bounds)
+ max_x = max(x1 for (x0, y0), (x1, y1) in bounds)
+ max_y = max(y1 for (x0, y0), (x1, y1) in bounds)
- Parameters
- ----------
- ctx : :class:`GerberContext`
- GerberContext subclass used for rendering the image
+ return ((min_x, min_y), (max_x, max_y))
- filename : string <optional>
- If provided, save the rendered image to `filename`
- """
- if ctx is None:
- from .render import GerberCairoContext
- ctx = GerberCairoContext()
- ctx.set_bounds(self.bounding_box)
- ctx.paint_background()
- ctx.invert = invert
- ctx.new_render_layer()
- for p in self.primitives:
- ctx.render(p)
- ctx.flatten()
-
- if filename is not None:
- ctx.dump(filename)
diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py
index 6cc5e69..a42a667 100755
--- a/gerbonara/gerber/excellon.py
+++ b/gerbonara/gerber/excellon.py
@@ -20,6 +20,7 @@ import operator
import warnings
import functools
import dataclasses
+import re
from enum import Enum
from dataclasses import dataclass
from collections import Counter
@@ -41,7 +42,7 @@ class ExcellonContext:
def select_tool(self, tool):
if self.current_tool != tool:
self.current_tool = tool
- yield f'T{tools[tool]:02d}'
+ yield f'T{self.tools[id(tool)]:02d}'
def drill_mode(self):
if self.mode != ProgramState.DRILLING:
@@ -54,10 +55,10 @@ class ExcellonContext:
if self.mode == ProgramState.ROUTING and (self.x, self.y) == (x, y):
return # nothing to do
- yield 'G00' + 'X' + self.settings.write_gerber_value(x) + 'Y' + self.settings.write_gerber_value(y)
+ yield 'G00' + 'X' + self.settings.write_excellon_value(x) + 'Y' + self.settings.write_excellon_value(y)
def set_current_point(self, unit, x, y):
- self.current_point = self.unit(x, unit), self.unit(y, unit)
+ self.current_point = self.settings.unit(x, unit), self.settings.unit(y, unit)
def parse_allegro_ncparam(data, settings=None):
# This function parses data from allegro's nc_param.txt and ncdrill.log files. We have to parse these files because
@@ -70,7 +71,7 @@ def parse_allegro_ncparam(data, settings=None):
lz_supp, tz_supp = False, False
for line in data.splitlines():
- line = re.sub('\s+', ' ', line.strip())
+ line = re.sub(r'\s+', ' ', line.strip())
if (match := re.fullmatch(r'FORMAT ([0-9]+\.[0-9]+)', line)):
x, _, y = match[1].partition('.')
@@ -165,7 +166,7 @@ class ExcellonFile(CamFile):
# Prefer nc_param.txt over ncparam.log since the txt is the machine-readable one.
if settings is None:
for fn in 'nc_param.txt', 'ncdrill.log':
- if (param_file := filename.parent / fn).isfile():
+ if (param_file := filename.parent / fn).is_file():
settings = parse_allegro_ncparam(param_file.read_text())
break
@@ -176,7 +177,7 @@ class ExcellonFile(CamFile):
parser = ExcellonParser(settings)
parser._do_parse(data)
return kls(objects=parser.objects, comments=parser.comments, import_settings=settings,
- generator=parser.generator, filename=filename, plated=plated)
+ generator_hints=parser.generator_hints, filename=filename)
def _generate_statements(self, settings):
@@ -190,21 +191,23 @@ class ExcellonFile(CamFile):
yield 'METRIC' if settings.unit == MM else 'INCH'
# Build tool index
- tools = set(obj.tool for obj in self.objects)
- tools = sorted(tools, key=lambda tool: (tool.plated, tool.diameter, tool.depth_offset))
- tools = { tool: index for index, tool in enumerate(tools, start=1) }
+ tool_map = { id(obj.tool): obj.tool for obj in self.objects }
+ tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter, id_tool[1].depth_offset))
+ tools = { tool_id: index for index, (tool_id, _tool) in enumerate(tools, start=1) }
- if max(tools) >= 100:
+ if tools and max(tools.values()) >= 100:
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
- for tool, index in tools.items():
- yield f'T{index:02d}' + tool.to_xnc(settings)
+ for tool_id, index in tools.items():
+ yield f'T{index:02d}' + tool_map[tool_id].to_xnc(settings)
yield '%'
+ ctx = ExcellonContext(settings, tools)
+
# Export objects
for obj in self.objects:
- obj.to_xnc(ctx)
+ yield from obj.to_xnc(ctx)
yield 'M30'
@@ -212,15 +215,25 @@ class ExcellonFile(CamFile):
''' Export to Excellon format. This function always generates XNC, which is a well-defined subset of Excellon.
'''
if settings is None:
- settings = self.import_settings.copy() or FileSettings()
+ if self.import_settings:
+ settings = self.import_settings.copy()
+ else:
+ settings = FileSettings()
settings.zeros = None
settings.number_format = (3,5)
return '\n'.join(self._generate_statements(settings))
+ def save(self, filename, settings=None):
+ with open(filename, 'w') as f:
+ f.write(self.to_excellon(settings))
+
def offset(self, x=0, y=0, unit=MM):
self.objects = [ obj.with_offset(x, y, unit) for obj in self.objects ]
def rotate(self, angle, cx=0, cy=0, unit=MM):
+ if math.isclose(angle % (2*math.pi), 0):
+ return
+
for obj in self.objects:
obj.rotate(angle, cx, cy, unit=unit)
@@ -300,7 +313,7 @@ class ProgramState(Enum):
HEADER = 0
DRILLING = 1
ROUTING = 2
- FINISHED = 2
+ FINISHED = 3
class ExcellonParser(object):
@@ -320,6 +333,7 @@ class ExcellonParser(object):
self.pos = 0, 0
self.drill_down = False
self.is_plated = None
+ self.comments = []
self.generator_hints = []
def _do_parse(self, data):
@@ -350,7 +364,7 @@ class ExcellonParser(object):
exprs = RegexMatcher()
# NOTE: These must be kept before the generic comment handler at the end of this class so they match first.
- @exprs.match(';T(?P<index1>[0-9]+) Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
+ @exprs.match(r';T(?P<index1>[0-9]+) Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
def parse_allegro_tooldef(self, match):
# NOTE: We ignore the given tolerances here since they are non-standard.
self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
@@ -390,7 +404,7 @@ class ExcellonParser(object):
if (index := int(match['index'])) in self.tools:
warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning)
- tools[index] = tool
+ self.tools[index] = tool
self.generator_hints.append('easyeda')
@exprs.match('T([0-9]+)(([A-Z][.0-9]+)+)') # Tool definition: T** with at least one parameter
@@ -401,9 +415,10 @@ class ExcellonParser(object):
if (index := int(match[1])) in self.tools:
warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning)
- params = { m[0]: settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) }
+ params = { m[0]: self.settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) }
- self.tools[index] = ExcellonTool(diameter=params.get('C'), depth_offset=params.get('Z'), plated=self.is_plated)
+ self.tools[index] = ExcellonTool(diameter=params.get('C'), depth_offset=params.get('Z'), plated=self.is_plated,
+ unit=self.settings.unit)
if set(params.keys()) == set('TFSC'):
self.generator_hints.append('target3001') # target files look like altium files without the comments
@@ -422,7 +437,7 @@ class ExcellonParser(object):
self.active_tool = self.tools[index]
- coord = lambda name, key=None: f'(?P<{key or name}>{name}[+-]?[0-9]*\.?[0-9]*)?'
+ coord = lambda name, key=None: fr'{name}(?P<{key or name}>[+-]?[0-9]*\.?[0-9]*)?'
xy_coord = coord('X') + coord('Y')
@exprs.match(r'R(?P<count>[0-9]+)' + xy_coord)
@@ -442,15 +457,18 @@ class ExcellonParser(object):
self.objects.append(Flash(*self.pos, self.active_tool, unit=self.settings.unit))
- def header_command(fun):
- @functools.wraps(fun)
- def wrapper(*args, **kwargs):
- if self.program_state is None:
- warnings.warn('Header statement found before start of header')
- elif self.program_state != ProgramState.HEADER:
- warnings.warn('Header statement found after end of header')
- fun(*args, **kwargs)
- return wrapper
+ def header_command(name):
+ def wrap(fun):
+ @functools.wraps(fun)
+ def wrapper(self, *args, **kwargs):
+ nonlocal name
+ if self.program_state is None:
+ warnings.warn(f'{name} header statement found before start of header')
+ elif self.program_state != ProgramState.HEADER:
+ warnings.warn(f'{name} header statement found after end of header')
+ fun(self, *args, **kwargs)
+ return wrapper
+ return wrap
@exprs.match('M48')
def handle_begin_header(self, match):
@@ -463,7 +481,7 @@ class ExcellonParser(object):
self.program_state = ProgramState.HEADER
@exprs.match('M95')
- @header_command
+ @header_command('M95')
def handle_end_header(self, match):
self.program_state = ProgramState.DRILLING
@@ -489,28 +507,28 @@ class ExcellonParser(object):
def handle_end_of_program(self, match):
if self.program_state in (None, ProgramState.HEADER):
warnings.warn('M30 statement found before end of header.', SyntaxWarning)
- self.program_state = FINISHED
+ self.program_state = ProgramState.FINISHED
# ignore.
# TODO: maybe add warning if this is followed by other commands.
def do_move(self, match=None, x='X', y='Y'):
- x = settings.parse_gerber_value(match['X'])
- y = settings.parse_gerber_value(match['Y'])
+ x = self.settings.parse_gerber_value(match['X'])
+ y = self.settings.parse_gerber_value(match['Y'])
old_pos = self.pos
if self.settings.absolute:
if x is not None:
- self.pos[0] = x
+ self.pos = (x, self.pos[1])
if y is not None:
- self.pos[1] = y
+ self.pos = (self.pos[0], y)
else: # incremental
if x is not None:
- self.pos[0] += x
+ self.pos = (self.pos[0]+x, self.pos[1])
if y is not None:
- self.pos[1] += y
+ self.pos = (self.pos[0], self.pos[1]+y)
- return old_pos, new_pos
+ return old_pos, self.pos
@exprs.match('G00' + xy_coord)
def handle_start_routing(self, match):
@@ -554,8 +572,7 @@ class ExcellonParser(object):
start, end = self.do_move(match)
- # Yes, drills in the header doesn't follow the specification, but it there are many files like this
- if self.program_state not in (ProgramState.DRILLING, ProgramState.HEADER):
+ if self.program_state != ProgramState.ROUTING:
return
if not self.drill_down or not (match['x'] or match['y']) or not self.ensure_active_tool():
@@ -601,16 +618,16 @@ class ExcellonParser(object):
self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
@exprs.match('M71|METRIC') # XNC uses "METRIC"
- @header_command
+ @header_command('M71')
def handle_metric_mode(self, match):
self.settings.unit = MM
@exprs.match('M72|INCH') # XNC uses "INCH"
- @header_command
+ @header_command('M72')
def handle_inch_mode(self, match):
self.settings.unit = Inch
- @exprs.match('(METRIC|INCH)(,LZ|,TZ)?(0*\.0*)?')
+ @exprs.match(r'(METRIC|INCH)(,LZ|,TZ)?(0*\.0*)?')
def parse_easyeda_format(self, match):
# geda likes to omit the LZ/TZ
self.settings.unit = MM if match[1] == 'METRIC' else Inch
@@ -628,7 +645,7 @@ class ExcellonParser(object):
self.generator_hints.append('easyeda')
@exprs.match('G90')
- @header_command
+ @header_command('G90')
def handle_absolute_mode(self, match):
self.settings.notation = 'absolute'
@@ -662,7 +679,16 @@ class ExcellonParser(object):
@exprs.match(xy_coord)
def handle_naked_coordinate(self, match):
- self.do_interpolation(match)
+ _start, end = self.do_move(match)
+
+ if not self.ensure_active_tool():
+ return
+
+ # Yes, drills in the header doesn't follow the specification, but it there are many files like this
+ if self.program_state not in (ProgramState.DRILLING, ProgramState.HEADER):
+ return
+
+ self.objects.append(Flash(*end, self.active_tool, unit=self.settings.unit))
@exprs.match(r'; Format\s*: ([0-9]+\.[0-9]+) / (Absolute|Incremental) / (Inch|MM) / (Leading|Trailing)')
def parse_siemens_format(self, match):
@@ -684,7 +710,7 @@ class ExcellonParser(object):
@exprs.match(';FILE_FORMAT=([0-9]:[0-9])')
def parse_altium_easyeda_number_format_comment(self, match):
# Altium or newer EasyEDA exports
- x, _, y = fmt.partition(':')
+ x, _, y = match[1].partition(':')
self.settings.number_format = int(x), int(y)
@exprs.match(';Layer: (.*)')
@@ -710,7 +736,7 @@ class ExcellonParser(object):
self.program_state = ProgramState.HEADER
self.generator_hints.append('allegro')
- @exprs.match(';GenerationSoftware,Autodesk,EAGLE,.*\*%')
+ @exprs.match(r';GenerationSoftware,Autodesk,EAGLE,.*\*%')
def parse_eagle_version_header(self, match):
# NOTE: Only newer eagles export drills as XNC files. Older eagles produce an aperture-only gerber file called
# "profile.gbr" instead.
diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py
index c626ba3..b42a01c 100644
--- a/gerbonara/gerber/graphic_objects.py
+++ b/gerbonara/gerber/graphic_objects.py
@@ -90,8 +90,8 @@ class Flash(GerberObject):
yield from ctx.select_tool(self.tool)
yield from ctx.drill_mode()
- x = ctx.settings.write_gerber_value(self.x, self.unit)
- y = ctx.settings.write_gerber_value(self.y, self.unit)
+ x = ctx.settings.write_excellon_value(self.x, self.unit)
+ y = ctx.settings.write_excellon_value(self.y, self.unit)
yield f'X{x}Y{y}'
ctx.set_current_point(self.unit, self.x, self.y)
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py
index 80605a5..e986225 100644
--- a/gerbonara/gerber/rs274x.py
+++ b/gerbonara/gerber/rs274x.py
@@ -53,33 +53,21 @@ def points_close(a, b):
else:
return math.isclose(a[0], b[0]) and math.isclose(a[1], b[1])
-class Tag:
- def __init__(self, name, children=None, root=False, **attrs):
- self.name, self.attrs = name, attrs
- self.children = children or []
- self.root = root
-
- 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()])
- if self.children:
- children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children)
- return f'{prefix}<{opening}>\n{children}\n</{self.name}>'
- else:
- return f'{prefix}<{opening}/>'
-
class GerberFile(CamFile):
""" A class representing a single gerber file
The GerberFile class represents a single gerber file.
"""
- def __init__(self, filename=None):
- super().__init__(filename)
+ def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator_hints=None,
+ layer_hints=None):
+ super().__init__(filename=filename)
+ self.objects = objects or []
+ self.comments = comments or []
+ self.generator_hints = generator_hints or []
+ 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.comments = []
- self.objects = []
- self.import_settings = None
def to_excellon(self):
new_objs = []
@@ -96,40 +84,6 @@ class GerberFile(CamFile):
return ExcellonFile(objects=new_objs, comments=self.comments)
- def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color='black'):
-
- if force_bounds is None:
- (min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
- else:
- (min_x, min_y), (max_x, max_y) = force_bounds
- min_x = svg_unit(min_x, arg_unit)
- min_y = svg_unit(min_y, arg_unit)
- max_x = svg_unit(max_x, arg_unit)
- max_y = svg_unit(max_y, arg_unit)
-
- if margin:
- margin = svg_unit(margin, arg_unit)
- min_x -= margin
- min_y -= margin
- max_x += margin
- max_y += margin
-
- w, h = max_x - min_x, max_y - min_y
- w = 1.0 if math.isclose(w, 0.0) else w
- h = 1.0 if math.isclose(h, 0.0) else h
-
- primitives = [ prim.to_svg(tag, color) for obj in self.objects for prim in obj.to_primitives(unit=svg_unit) ]
-
- # 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', primitives, 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)
-
def merge(self, other):
""" Merge other GerberFile into this one """
if other is None:
@@ -216,25 +170,6 @@ class GerberFile(CamFile):
GerberParser(obj, include_dir=enable_include_dir).parse(data)
return obj
- def size(self, unit=MM):
- (x0, y0), (x1, y1) = self.bounding_box(unit, default=((0, 0), (0, 0)))
- return (x1 - x0, y1 - y0)
-
- def bounding_box(self, unit=MM, default=None):
- """ Calculate bounding box of file. Returns value given by 'default' argument when there are no graphical
- objects (default: None)
- """
- bounds = [ p.bounding_box(unit) for p in self.objects ]
- if not bounds:
- return default
-
- min_x = min(x0 for (x0, y0), (x1, y1) in bounds)
- min_y = min(y0 for (x0, y0), (x1, y1) in bounds)
- max_x = max(x1 for (x0, y0), (x1, y1) in bounds)
- max_y = max(y1 for (x0, y0), (x1, y1) in bounds)
-
- return ((min_x, min_y), (max_x, max_y))
-
def generate_statements(self, settings, drop_comments=True):
yield '%MOMM*%' if (settings.unit == 'mm') else '%MOIN*%'
diff --git a/gerbonara/gerber/tests/test_excellon.py b/gerbonara/gerber/tests/test_excellon.py
index 545bec9..f8d54ed 100644
--- a/gerbonara/gerber/tests/test_excellon.py
+++ b/gerbonara/gerber/tests/test_excellon.py
@@ -1,17 +1,7 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# Author: Jan Götte <code@jaseg.de>
-import os
-import re
import math
-import functools
-import tempfile
-import shutil
-from argparse import Namespace
-from itertools import chain
-from pathlib import Path
-from contextlib import contextmanager
-from PIL import Image
import pytest
@@ -24,7 +14,6 @@ from .utils import *
REFERENCE_FILES = [
'easyeda/Gerber_Drill_NPTH.DRL',
'easyeda/Gerber_Drill_PTH.DRL',
- 'allegro-2/MinnowMax_RevA1_IPC356A.ipc',
'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-SlotHoles.TXT',
'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-RoundHoles.TXT',
'pcb-rnd/power-art.xln',
@@ -48,7 +37,7 @@ def test_round_trip(reference, tmpfile):
ExcellonFile.open(reference).save(tmp)
- mean, _max, hist = excellon_difference(reference, tmp, diff_out=tmpfile('Difference', '.png'))
+ mean, _max, hist = gerber_difference(reference, tmp, diff_out=tmpfile('Difference', '.png'))
assert mean < 5e-5
assert hist[9] == 0
assert hist[3:].sum() < 5e-5*hist.size
diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py
index 68c5367..2256525 100644
--- a/gerbonara/gerber/tests/test_rs274x.py
+++ b/gerbonara/gerber/tests/test_rs274x.py
@@ -1,88 +1,16 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# Author: Jan Götte <code@jaseg.de>
-import os
-import re
import math
-import functools
-import tempfile
-import shutil
-from argparse import Namespace
-from itertools import chain
-from pathlib import Path
-from contextlib import contextmanager
-from PIL import Image
+from PIL import Image
import pytest
from ..rs274x import GerberFile
from ..cam import FileSettings
from .image_support import *
-
-
-deg_to_rad = lambda a: a/180 * math.pi
-
-fail_dir = Path('gerbonara_test_failures')
-reference_path = lambda reference: Path(__file__).parent / 'resources' / reference
-
-def path_test_name(request):
- """ Create a slug suitable for use in file names from the test's nodeid """
- module, _, test_name = request.node.nodeid.rpartition('::')
- _test, _, test_name = test_name.partition('_')
- test_name, _, _ext = test_name.partition('.')
- return re.sub(r'[^\w\d]', '_', test_name)
-
-@pytest.fixture
-def print_on_error(request):
- messages = []
-
- def register_print(*args, sep=' ', end='\n'):
- nonlocal messages
- messages.append(sep.join(str(arg) for arg in args) + end)
-
- yield register_print
-
- if request.node.rep_call.failed:
- for msg in messages:
- print(msg, end='')
-
-@pytest.fixture
-def tmpfile(request):
- registered = []
-
- def register_tempfile(name, suffix):
- nonlocal registered
- f = tempfile.NamedTemporaryFile(suffix=suffix)
- registered.append((name, suffix, f))
- return Path(f.name)
-
- yield register_tempfile
-
- if request.node.rep_call.failed:
- fail_dir.mkdir(exist_ok=True)
- test_name = path_test_name(request)
- for name, suffix, tmp in registered:
- slug = re.sub(r'[^\w\d]+', '_', name.lower())
- perm_path = fail_dir / f'failure_{test_name}_{slug}{suffix}'
- shutil.copy(tmp.name, perm_path)
- print(f'{name} saved to {perm_path}')
-
- for _name, _suffix, tmp in registered:
- tmp.close()
-
-@pytest.fixture
-def reference(request, print_on_error):
- ref = reference_path(request.param)
- yield ref
- print_on_error(f'Reference file: {ref}')
-
-def filter_syntax_warnings(fun):
- a = pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning')
- b = pytest.mark.filterwarnings('ignore::SyntaxWarning')
- return a(b(fun))
-
-to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
+from .utils import *
REFERENCE_FILES = [ l.strip() for l in '''
board_outline.GKO
@@ -179,7 +107,7 @@ def test_rotation(reference, angle, tmpfile):
tmp_gbr = tmpfile('Output gerber', '.gbr')
f = GerberFile.open(reference)
- f.rotate(deg_to_rad(angle))
+ f.rotate(math.radians(angle))
f.save(tmp_gbr)
cx, cy = 0, to_gerbv_svg_units(10, unit='inch')
@@ -200,7 +128,7 @@ def test_rotation_center(reference, angle, center, tmpfile):
tmp_gbr = tmpfile('Output gerber', '.gbr')
f = GerberFile.open(reference)
- f.rotate(deg_to_rad(angle), center=center)
+ f.rotate(math.radians(angle), center=center)
f.save(tmp_gbr)
# calculate circle center in SVG coordinates
@@ -243,7 +171,7 @@ def test_combined(reference, angle, center, offset, tmpfile):
tmp_gbr = tmpfile('Output gerber', '.gbr')
f = GerberFile.open(reference)
- f.rotate(deg_to_rad(angle), center=center)
+ f.rotate(math.radians(angle), center=center)
f.offset(*offset)
f.save(tmp_gbr, settings=FileSettings(unit=f.unit, number_format=(4,7)))
@@ -284,7 +212,7 @@ def test_compositing(file_a, file_b, angle, offset, tmpfile, print_on_error):
ax, ay, bx, by = offset
grb_a = GerberFile.open(ref_a)
- grb_a.rotate(deg_to_rad(angle))
+ grb_a.rotate(math.radians(angle))
grb_a.offset(ax, ay)
grb_b = GerberFile.open(ref_b)
diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py
index d1045ad..30b03c9 100644
--- a/gerbonara/gerber/utils.py
+++ b/gerbonara/gerber/utils.py
@@ -24,6 +24,7 @@ files.
"""
import os
+import re
from enum import Enum
from math import radians, sin, cos, sqrt, atan2, pi
@@ -41,7 +42,9 @@ class RegexMatcher:
def handle(self, inst, line):
for regex, handler in self.mapping.items():
if (match := re.fullmatch(regex, line)):
- handler(match)
+ #print(' handler', handler.__name__)
+ handler(inst, match)
+ break
class LengthUnit:
def __init__(self, name, shorthand, this_in_mm):