269 lines
11 KiB
Python
269 lines
11 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# Copyright 2026 Jan Sebastian Götte <gerbonara@jaseg.de>
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
# Based on https://github.com/tracespace/tracespace
|
|
#
|
|
|
|
import math
|
|
from contextlib import contextmanager
|
|
|
|
from PIL import Image
|
|
import pytest
|
|
|
|
from gerbonara.rs274x import GerberFile
|
|
from gerbonara.graphic_objects import Line, Arc, Flash, Region
|
|
from gerbonara.apertures import CircleAperture, RectangleAperture, ObroundAperture, PolygonAperture
|
|
from gerbonara.cam import FileSettings
|
|
from gerbonara.utils import MM, Inch
|
|
|
|
from .image_support import svg_soup
|
|
from .utils import *
|
|
|
|
@contextmanager
|
|
def object_test(tmpfile, img_support, epsilon=1e-4):
|
|
gbr = GerberFile()
|
|
|
|
yield gbr
|
|
|
|
gbr.offset(25.0, 25.0)
|
|
|
|
bounds = (0.0, 0.0), (2.0, 2.0) # bottom left, top right
|
|
|
|
# The below code is mostly copy-pasted from test_rs274x.py.
|
|
|
|
out_svg = tmpfile('SVG Output', '.svg')
|
|
with open(out_svg, 'w') as f:
|
|
# Use inch units here to make sure we and gerbv agree on the exact pixel size of the output since both calculate
|
|
# it from the DPI setting.
|
|
f.write(str(gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white')))
|
|
|
|
# test primitive to_arc_poly
|
|
arc_poly_gbr = GerberFile()
|
|
arc_poly_approx_gbr = GerberFile()
|
|
for obj in gbr.objects:
|
|
for primitive in obj.to_primitives(MM):
|
|
poly = primitive.to_arc_poly()
|
|
region = Region.from_arc_poly(poly)
|
|
arc_poly_gbr.objects.append(region)
|
|
|
|
# Regression test for gitlab issue #17
|
|
assert primitive.polarity_dark == poly.polarity_dark
|
|
assert region.polarity_dark == poly.polarity_dark
|
|
|
|
# Test for arc poly arc approximation, and regression test for gitlab issue #16
|
|
region = Region.from_arc_poly(poly.approximate_arcs())
|
|
arc_poly_approx_gbr.objects.append(region)
|
|
|
|
arc_poly_svg = tmpfile('ArcPoly SVG Output', '.svg')
|
|
with open(arc_poly_svg, 'w') as f:
|
|
f.write(str(arc_poly_gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white')))
|
|
|
|
arc_poly_png = tmpfile('ArcPoly conversion render', '.png')
|
|
img_support.svg_to_png(arc_poly_svg, arc_poly_png, dpi=300, bg='white')
|
|
|
|
# Arc approximation test
|
|
arc_poly_approx_svg = tmpfile('ArcPoly Approximation SVG Output', '.svg')
|
|
with open(arc_poly_approx_svg, 'w') as f:
|
|
f.write(str(arc_poly_approx_gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white')))
|
|
|
|
arc_poly_approx_png = tmpfile('ArcPoly Approximation conversion render', '.png')
|
|
img_support.svg_to_png(arc_poly_approx_svg, arc_poly_approx_png, dpi=300, bg='white')
|
|
|
|
# Reference export via gerber through GerbV
|
|
out_gbr = tmpfile('GBR Output', '.gbr')
|
|
gbr.save(out_gbr)
|
|
|
|
# NOTE: Instead of having gerbv directly export a PNG, we ask gerbv to output SVG which we then rasterize using
|
|
# resvg. We have to do this since gerbv's built-in cairo-based PNG export has severe aliasing issues. In contrast,
|
|
# using resvg for both allows an apples-to-apples comparison of both results.
|
|
ref_svg = tmpfile('Reference export', '.svg')
|
|
ref_png = tmpfile('Reference render', '.png')
|
|
img_support.gerbv_export(out_gbr, ref_svg, origin=bounds[0], size=bounds[1], fg='#000000', bg='#ffffff')
|
|
with svg_soup(ref_svg) as soup:
|
|
img_support.cleanup_gerbv_svg(soup)
|
|
img_support.svg_to_png(ref_svg, ref_png, dpi=300, bg='white')
|
|
|
|
out_png = tmpfile('Output render', '.png')
|
|
img_support.svg_to_png(out_svg, out_png, dpi=300, bg='white')
|
|
|
|
mean, _max, hist = img_support.image_difference(ref_png, out_png, diff_out=tmpfile('Difference', '.png'))
|
|
assert hist[9] < 1
|
|
assert mean < epsilon
|
|
assert hist[3:].sum() < epsilon*hist.size
|
|
|
|
mean, _max, hist = img_support.image_difference(out_png, arc_poly_png, diff_out=tmpfile('ArcPoly Difference', '.png'))
|
|
assert hist[9] < 1
|
|
assert mean < epsilon
|
|
assert hist[3:].sum() < epsilon*hist.size
|
|
|
|
mean, _max, hist = img_support.image_difference(out_png, arc_poly_approx_png, diff_out=tmpfile('ArcPoly Approximation Difference', '.png'))
|
|
assert hist[9] < 1
|
|
assert mean < epsilon
|
|
assert hist[3:].sum() < epsilon*hist.size
|
|
|
|
@pytest.mark.parametrize('angle_deg', [0, 5, -5, 10, -10, 15, -15, 30, -30, 45, -45, 60, -60, 75, -75, 90, -90, 120, -120, 180, 153, 155, 157])
|
|
def test_line(angle_deg, tmpfile, img_support):
|
|
with object_test(tmpfile, img_support) as gbr:
|
|
angle = math.radians(angle_deg)
|
|
l = 10
|
|
obj = Line(
|
|
x1=0, y1=0,
|
|
x2=l*math.cos(angle), y2=l*math.sin(angle),
|
|
aperture=CircleAperture(3.0, unit=MM),
|
|
unit=MM
|
|
)
|
|
|
|
gbr.objects.append(obj)
|
|
|
|
|
|
def test_zero_length_line(tmpfile, img_support):
|
|
with object_test(tmpfile, img_support) as gbr:
|
|
for x, y in [(0, 0), (0, 10), (10, 15)]:
|
|
obj = Line(
|
|
x1=x, y1=y,
|
|
x2=x, y2=y,
|
|
aperture=CircleAperture(3.0, unit=MM),
|
|
unit=MM
|
|
)
|
|
gbr.objects.append(obj)
|
|
|
|
# NOTE: We explicitly do not test the sweep_angle = 0 deg case here. In this case, we decided to always draw a full circle.
|
|
# IIRC this is in line with some Gerber implementations in the wild, and it enables drawing a clean circle using a single Arc.
|
|
# gerbv will not do this, and so this would cause a failing test for no particular reason.
|
|
# TODO: Check 360 degree sweep angle case
|
|
@pytest.mark.parametrize('start_angle_deg', [0, 5, 10, 15, 30, 45, 60, 75, 90, 120, 180, 153, 155, 157, 190, 200, 210, 240, 250, 255, 270, 280, 290, 340, 350, 355, 358])
|
|
@pytest.mark.parametrize('sweep_angle_deg', [1, 5, 10, 15, 30, 45, 60, 75, 90, 120, 180, 153, 155, 157, 190, 200, 210, 240, 250, 255, 270, 280, 290, 340, 350, 355, 358])
|
|
@pytest.mark.parametrize('clockwise', [True, False])
|
|
def test_arc(start_angle_deg, sweep_angle_deg, clockwise, tmpfile, img_support):
|
|
# Use large epsilon since someone approximates arcs with SVG beziers here, and that approximation really shows up.
|
|
with object_test(tmpfile, img_support, epsilon=1e-2) as gbr:
|
|
start_angle = math.radians(start_angle_deg)
|
|
sweep_angle = math.radians(sweep_angle_deg)
|
|
r = 10
|
|
|
|
x1, y1 = r*math.cos(start_angle), r*math.sin(start_angle)
|
|
x2, y2 = r*math.cos(start_angle + sweep_angle), r*math.sin(start_angle + sweep_angle)
|
|
|
|
obj = Arc(
|
|
x1=x1, y1=y1,
|
|
x2=x2, y2=y2,
|
|
cx=-x1, cy=-y1,
|
|
clockwise=clockwise,
|
|
aperture=CircleAperture(3.0, unit=MM),
|
|
unit=MM
|
|
)
|
|
|
|
gbr.objects.append(obj)
|
|
|
|
|
|
@pytest.mark.parametrize('x,y', [(0, 0), (5, 5), (10, 0), (0, 10), (-5, 5)])
|
|
@pytest.mark.parametrize('aperture', [
|
|
CircleAperture(3.0, unit=MM),
|
|
CircleAperture(2.5, hole_dia=1.0, unit=MM),
|
|
])
|
|
def test_flash_circle(x, y, aperture, tmpfile, img_support):
|
|
with object_test(tmpfile, img_support) as gbr:
|
|
obj = Flash(
|
|
x=x, y=y,
|
|
aperture=aperture,
|
|
unit=MM
|
|
)
|
|
gbr.objects.append(obj)
|
|
|
|
|
|
@pytest.mark.parametrize('aperture_type', [
|
|
lambda: CircleAperture(4.0, unit=MM),
|
|
lambda: CircleAperture(4.0, hole_dia=1.5, unit=MM),
|
|
lambda: RectangleAperture(4.0, 3.0, unit=MM),
|
|
lambda: ObroundAperture(4.0, 2.5, unit=MM),
|
|
lambda: PolygonAperture(4.0, 6, unit=MM),
|
|
])
|
|
@pytest.mark.parametrize('polarity', [True, False])
|
|
def test_flash_aperture_types(aperture_type, polarity, tmpfile, img_support):
|
|
"""Test Flash with different aperture types."""
|
|
|
|
if aperture_type().hole_dia and not polarity:
|
|
# Our SVG export (intentionally, for simplicity) does not exactly conform to the Gerber spec in aperture
|
|
# rendering. The Gerber spec requires rendering the aperture from its primitives to an off-screen canvas, then
|
|
# compositing that on top of the target surface. The result is that inverted polarity, e.g. for holes, is opaque
|
|
# in our renders, but transparent in spec-compliant renders.
|
|
#
|
|
# We implemented it this way since any other way would be really slow (using lots of SVG masks, filters etc.),
|
|
# and in practical applications this is never an actual problem.
|
|
pytest.skip()
|
|
|
|
with object_test(tmpfile, img_support, epsilon=1e-3) as gbr:
|
|
aperture = aperture_type()
|
|
|
|
# Create a grid of flashes with this aperture type
|
|
positions = [(0, 0), (8, 0), (0, 8), (8, 8), (4, 4)]
|
|
for x, y in positions:
|
|
obj = Flash(
|
|
x=x, y=y,
|
|
aperture=aperture,
|
|
unit=MM,
|
|
polarity_dark=polarity
|
|
)
|
|
gbr.objects.append(obj)
|
|
|
|
|
|
@pytest.mark.parametrize('w,h', [(5, 5), (8, 4), (4, 8), (10, 3)])
|
|
def test_region_rectangle(w, h, tmpfile, img_support):
|
|
"""Test Region objects creating rectangles."""
|
|
with object_test(tmpfile, img_support) as gbr:
|
|
x, y = 5, 5
|
|
obj = Region.from_rectangle(x, y, w, h, unit=MM)
|
|
gbr.objects.append(obj)
|
|
|
|
|
|
@pytest.mark.parametrize('start_angle_deg', [0, 45, 90, 135, 180, 225, 270, 315])
|
|
@pytest.mark.parametrize('sweep_angle_deg', [90, 180, 270])
|
|
def test_region_arc_segments(start_angle_deg, sweep_angle_deg, tmpfile, img_support):
|
|
"""Test Region objects with arc segments."""
|
|
with object_test(tmpfile, img_support, epsilon=1e-2) as gbr:
|
|
print(f'{start_angle_deg=} {sweep_angle_deg=}')
|
|
start_angle = math.radians(start_angle_deg)
|
|
sweep_angle = math.radians(sweep_angle_deg)
|
|
r = 15
|
|
|
|
# Create a region that looks like a pie slice
|
|
region = Region(unit=MM)
|
|
|
|
region.outline.append((0, 0))
|
|
region.outline.append((r * math.cos(start_angle + sweep_angle), r * math.sin(start_angle + sweep_angle)))
|
|
region.outline.append((r * math.cos(start_angle), r * math.sin(start_angle)))
|
|
region.outline.append((0, 0))
|
|
region.arc_centers.append(None)
|
|
region.arc_centers.append((True, (0, 0)))
|
|
region.arc_centers.append(None)
|
|
|
|
gbr.objects.append(region)
|
|
|
|
def test_region_close():
|
|
""" Regression test for gitlab issue #18 """
|
|
go_region = Region([
|
|
(0, 0),
|
|
(100, 0),
|
|
(100, 100),
|
|
(0, 100),
|
|
# (0, 0), # expected
|
|
],
|
|
None,
|
|
polarity_dark=False,
|
|
unit=MM)
|
|
assert go_region.outline[-1] == go_region.outline[0]
|
|
|