#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright 2026 Jan Sebastian Götte # # 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]