Make bounding box tests pass

This commit is contained in:
jaseg 2022-01-09 19:59:15 +01:00
parent d6f0f0cff4
commit 4d77937f01
5 changed files with 112 additions and 28 deletions

View file

@ -43,7 +43,7 @@ class GerberObject:
self._rotate(rotation, cx, cy)
def bounding_box(self, unit=None):
bboxes = [ p.bounding_box for p in self.to_primitives(unit) ]
bboxes = [ p.bounding_box() for p in self.to_primitives(unit) ]
min_x = min(min_x for (min_x, _min_y), _ in bboxes)
min_y = min(min_y for (_min_x, min_y), _ in bboxes)
max_x = max(max_x for _, (max_x, _max_y) in bboxes)
@ -237,6 +237,7 @@ class Arc(GerberObject):
y1 : Length(float)
x2 : Length(float)
y2 : Length(float)
# relative to (x1, x2)
cx : Length(float)
cy : Length(float)
clockwise : bool
@ -268,7 +269,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, cy=conv.cy,
cx=conv.cx+conv.x1, cy=conv.cy+conv.y1,
clockwise=self.clockwise,
width=self.aperture.equivalent_width(unit),
polarity_dark=self.polarity_dark)

View file

@ -40,6 +40,9 @@ def add_bounds(b1, b2):
max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2)
return ((min_x, min_y), (max_x, max_y))
def rad_to_deg(x):
return x/math.pi * 180
@dataclass
class Circle(GraphicPrimitive):
x : float
@ -174,11 +177,14 @@ def point_line_distance(l1, l2, p):
return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length
def svg_arc(old, new, center, clockwise):
print(f'{old=} {new=} {center=}')
r = point_distance(old, new)
d = point_line_distance(old, new, center)
sweep_flag = int(clockwise)
# invert sweep flag since the svg y axis is mirrored
sweep_flag = int(not clockwise)
large_arc = int((d > 0) == clockwise) # FIXME check signs
return f'A {r:.6} {r:.6} {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'
print(f'{r=:.3} {d=:.3} {sweep_flag=} {large_arc=} {clockwise=}')
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'
@dataclass
class ArcPoly(GraphicPrimitive):
@ -206,6 +212,7 @@ class ArcPoly(GraphicPrimitive):
else:
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
bbox = add_bounds(bbox, line_bounds)
return bbox
def __len__(self):
return len(self.outline)
@ -251,13 +258,14 @@ class Arc(GraphicPrimitive):
y1 : float
x2 : float
y2 : float
# absolute coordinates
cx : float
cy : float
clockwise : bool
width : float
def bounding_box(self):
r = self.w/2
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))
@ -272,16 +280,16 @@ class Arc(GraphicPrimitive):
x2 = self.x2 + dx/arc_r * r
y2 = self.y2 + dy/arc_r * r
arc = arc_bounds(x1, y1, x2, y2, cx, cy, self.clockwise)
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'):
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'stroke: {color}, stroke-width: {self.width:.6}; stroke-linecap: round')
style=f'stroke: {color}, stroke-width: {self.width:.6}; stroke-linecap: round; fill: none')
def svg_rotation(angle_rad):
return f'rotation({angle_rad/math.pi*180:.4})'
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})'
@dataclass
class Rectangle(GraphicPrimitive):
@ -313,7 +321,8 @@ class Rectangle(GraphicPrimitive):
def to_svg(self, tag, color='black'):
x, y = self.x - self.w/2, self.y - self.h/2
return tag('rect', x=x, y=y, w=self.w, h=self.h, transform=svg_rotation(self.rotation), style=f'fill: {color}')
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}')
@dataclass
class RegularPolygon(GraphicPrimitive):

View file

@ -90,7 +90,8 @@ class GerberFile(CamFile):
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)
(min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
print('bounding box:', (min_x, min_y), (max_x, max_y))
else:
(min_x, min_y), (max_x, max_y) = force_bounds
min_x = convert(min_x, arg_unit, svg_unit)
@ -106,17 +107,19 @@ class GerberFile(CamFile):
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'scale(0 -1) translate(0 {h})'
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', [*primitives],
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}', transform=xform,
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):
@ -200,21 +203,24 @@ class GerberFile(CamFile):
GerberParser(obj, include_dir=enable_include_dir).parse(data)
return obj
@property
def size(self):
(x0, y0), (x1, y1) = self.bounding_box
def size(self, unit='mm'):
(x0, y0), (x1, y1) = self.bounding_box(unit, default=((0, 0), (0, 0)))
return (x1 - x0, y1 - y0)
@property
def bounding_box(self, unit='mm'):
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, max_x), (min_y, max_y))
return ((min_x, min_y), (max_x, max_y))
def generate_statements(self, drop_comments=True):
yield UnitStmt()
@ -421,6 +427,11 @@ class GraphicsState:
self.point = (0, 0)
old_point = self.map_coord(*self.update_point(x, y))
if aperture and math.isclose(self.aperture.equivalent_width(), 0):
warnings.warn('D01 interpolation with a zero-size aperture. This is invalid according to spec, however, we '
'pass through the created objects here. Note that these will not show up in e.g. SVG output since '
'their line width is zero.', SyntaxWarning)
if self.interpolation_mode == LinearModeStmt:
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)")
@ -448,7 +459,11 @@ class GraphicsState:
def _create_arc(self, old_point, new_point, control_point, aperture=True):
clockwise = self.interpolation_mode == CircularCWModeStmt
return go.Arc(*old_point, *new_point,* self.map_coord(*control_point, relative=True),
print('creating arc')
print(' old point', old_point)
print(' new point', new_point)
print(' control point', self.map_coord(*control_point, relative=True))
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)
@ -643,7 +658,7 @@ class GerberParser:
if self.current_region is None:
self.target.objects.append(self.graphics_state.interpolate(x, y, i, j))
else:
self.current_region.append(self.graphics_state.interpolate(x, y, i, j))
self.current_region.append(self.graphics_state.interpolate(x, y, i, j, aperture=False))
else:
if i is not None or j is not None:
@ -687,6 +702,12 @@ class GerberParser:
}
if (kls := aperture_classes.get(match['shape'])):
if match['shape'] == 'P' and math.isclose(modifiers[0], 0):
warnings.warn('Definition of zero-size polygon aperture. This is invalid according to spec.' , SyntaxWarning)
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)
elif (macro := self.aperture_macros.get(match['shape'])):

View file

@ -60,8 +60,8 @@ def run_cargo_cmd(cmd, args, **kwargs):
except FileNotFoundError:
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
def svg_to_png(in_svg, out_png, dpi=100):
run_cargo_cmd('resvg', ['--dpi', str(dpi), in_svg, out_png], check=True, stdout=subprocess.DEVNULL)
def svg_to_png(in_svg, out_png, dpi=100, bg='black'):
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, out_png], check=True, stdout=subprocess.DEVNULL)
def gerbv_export(in_gbr, out_svg, format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff'):
x, y = origin

View file

@ -12,6 +12,7 @@ from argparse import Namespace
from itertools import chain
from pathlib import Path
from contextlib import contextmanager
from PIL import Image
import pytest
@ -54,7 +55,7 @@ def tmpfile(request):
def register_tempfile(name, suffix):
nonlocal registered
f = tempfile.NamedTemporaryFile(suffix=suffix)
registered.append((name, f))
registered.append((name, suffix, f))
return Path(f.name)
yield register_tempfile
@ -62,13 +63,13 @@ def tmpfile(request):
if request.node.rep_call.failed:
fail_dir.mkdir(exist_ok=True)
test_name = path_test_name(request)
for name, tmp in registered:
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, tmp in registered:
for _name, _suffix, tmp in registered:
tmp.close()
@pytest.fixture
@ -143,6 +144,15 @@ MIN_REFERENCE_FILES = [
'eagle_files/copper_bottom_l4.gbr'
]
HAS_ZERO_SIZE_APERTURES = [
'bottom_copper.GBL',
'bottom_silk.GBO',
'top_copper.GTL',
'top_silk.GTO',
'board_outline.GKO',
'eagle_files/silkscreen_top.gbr',
]
@filter_syntax_warnings
@pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True)
@ -310,7 +320,7 @@ 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')))
f.write(str(grb.to_svg(force_bounds=bounds, arg_unit='inch', color='white')))
ref_png = tmpfile('Reference render', '.png')
gerbv_export(reference, ref_png, origin=bounds[0], size=bounds[1], format='png', fg='#000000')
@ -323,3 +333,46 @@ def test_svg_export(reference, tmpfile):
assert hist[3:].sum() < 1e-3*hist.size
# FIXME test svg margin, bounding box computation
@filter_syntax_warnings
@pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True)
def test_bounding_box(reference, tmpfile):
# 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)
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')))
out_png = tmpfile('Render', '.png')
svg_to_png(out_svg, out_png, dpi=dpi)
img = np.array(Image.open(out_png))
img = img[:, :, :3].mean(axis=2) # drop alpha and convert to grayscale
img = np.round(img).astype(int) # convert to int
assert (img > 0).any() # there must be some content, none of the test gerbers are completely empty.
cols = img.sum(axis=1)
rows = img.sum(axis=0)
print('shape:', img.shape)
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', 'prefix:', row_prefix, 'suffix:', row_suffix)
print('rows', 'prefix:', row_prefix, 'suffix:', 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